mirror of
https://github.com/MervinPraison/PraisonAI.git
synced 2026-11-07 17:33:33 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bba75d9fdd | ||
|
|
bf8cd488da | ||
|
|
28905db46d | ||
|
|
3e7aa44dfa | ||
|
|
2f7a65ab92 | ||
|
|
e6e2379b02 | ||
|
|
f31f9a04aa | ||
|
|
5a8adf295e | ||
|
|
8dc243fd4f | ||
|
|
548b4afb88 | ||
|
|
ffa938b3df | ||
|
|
8a9beed264 | ||
|
|
daca5a1762 | ||
|
|
b183ac5cbd | ||
|
|
5f57e1d2f1 | ||
|
|
5114165944 | ||
|
|
389442ea04 | ||
|
|
6d7cc9c3ba | ||
|
|
690e4497cc | ||
|
|
041f5f0b08 | ||
|
|
bc0d9a6213 | ||
|
|
a4427fa135 | ||
|
|
51e8c65c8c | ||
|
|
285ed672c5 | ||
|
|
c03e51e337 | ||
|
|
f3ba05f6aa | ||
|
|
9cea3edcd6 | ||
|
|
02a3bd3597 | ||
|
|
128c77926d | ||
|
|
3aa1b825e4 | ||
|
|
b4ee30d837 | ||
|
|
825648aead | ||
|
|
1532c31e72 | ||
|
|
b313fefd86 | ||
|
|
435e1afb55 | ||
|
|
4cb3c0565a | ||
|
|
a7634b66dc |
No files matched your search
@@ -27,6 +27,9 @@ jobs:
|
||||
comprehensive-test:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
OPENAI_MODEL_NAME: gpt-4o-mini
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.11"]
|
||||
@@ -55,11 +58,13 @@ jobs:
|
||||
# Install knowledge dependencies from praisonai-agents
|
||||
uv pip install --system "praisonaiagents[knowledge]"
|
||||
|
||||
- name: Set environment variables
|
||||
- name: Verify environment variables
|
||||
run: |
|
||||
echo "OPENAI_API_KEY=${{ secrets.OPENAI_API_KEY || 'sk-test-key-for-github-actions-testing-only-not-real' }}" >> $GITHUB_ENV
|
||||
echo "OPENAI_API_BASE=${{ secrets.OPENAI_API_BASE || 'https://api.openai.com/v1' }}" >> $GITHUB_ENV
|
||||
echo "OPENAI_MODEL_NAME=${{ secrets.OPENAI_MODEL_NAME || 'gpt-5-nano' }}" >> $GITHUB_ENV
|
||||
if [ -n "$OPENAI_API_KEY" ]; then
|
||||
echo "✅ OPENAI_API_KEY is set (length: ${#OPENAI_API_KEY})"
|
||||
else
|
||||
echo "⚠️ OPENAI_API_KEY is NOT set"
|
||||
fi
|
||||
|
||||
- name: Run Comprehensive Test Suite
|
||||
run: |
|
||||
|
||||
@@ -34,7 +34,7 @@ jobs:
|
||||
cd src/praisonai
|
||||
uv pip install --system ."[ui,gradio,api,agentops,google,openai,anthropic,cohere,chat,code,realtime,call,crewai,autogen]"
|
||||
uv pip install --system duckduckgo_search
|
||||
uv pip install --system pytest pytest-asyncio pytest-cov
|
||||
uv pip install --system pytest pytest-asyncio pytest-cov pytest-timeout
|
||||
# Install knowledge dependencies from praisonai-agents
|
||||
uv pip install --system "praisonaiagents[knowledge]"
|
||||
|
||||
@@ -262,7 +262,7 @@ jobs:
|
||||
export OPENAI_API_KEY="$OPENAI_API_KEY"
|
||||
export OPENAI_API_BASE="$OPENAI_API_BASE"
|
||||
export OPENAI_MODEL_NAME="$OPENAI_MODEL_NAME"
|
||||
cd src/praisonai && python -m pytest tests/test.py -v --tb=short --disable-warnings
|
||||
cd src/praisonai && python -m pytest tests/test.py -v --tb=short --disable-warnings -m "not real" --timeout=60
|
||||
|
||||
- name: Upload coverage reports to Codecov
|
||||
if: matrix.python-version == '3.11'
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
cd src/praisonai
|
||||
uv pip install --system ."[ui,gradio,api,agentops,google,openai,anthropic,cohere,chat,code,realtime,call,crewai,autogen]"
|
||||
uv pip install --system duckduckgo_search
|
||||
uv pip install --system pytest pytest-asyncio pytest-cov
|
||||
uv pip install --system pytest pytest-asyncio pytest-cov pytest-timeout
|
||||
# Install knowledge dependencies from praisonai-agents
|
||||
uv pip install --system "praisonaiagents[knowledge]"
|
||||
|
||||
@@ -489,7 +489,7 @@ jobs:
|
||||
|
||||
- name: Run Legacy Example Tests
|
||||
run: |
|
||||
cd src/praisonai && python -m pytest tests/test.py -v --tb=short --disable-warnings
|
||||
cd src/praisonai && python -m pytest tests/test.py -v --tb=short --disable-warnings -m "not real" --timeout=60
|
||||
continue-on-error: true
|
||||
|
||||
- name: Upload coverage reports to Codecov
|
||||
|
||||
@@ -55,6 +55,14 @@ PraisonAI is a production-ready Multi-AI Agents framework with self-reflection,
|
||||
- 💯 100+ LLM Support
|
||||
- 🔬 **Deep Research Agents** (OpenAI & Gemini)
|
||||
- 🔄 **Query Rewriter Agent** (HyDE, Step-back, Multi-query)
|
||||
- 🌐 **Native Web Search** (OpenAI, Gemini, Anthropic, xAI, Perplexity)
|
||||
- 📥 **Web Fetch** (Retrieve full content from URLs - Anthropic)
|
||||
- 💾 **Prompt Caching** (Reduce costs & latency - OpenAI, Anthropic, Bedrock, Deepseek)
|
||||
- 🧠 **Claude Memory Tool** (Persistent cross-conversation memory - Anthropic Beta)
|
||||
- 💾 **File-Based Memory** (Zero-dependency persistent memory for all agents)
|
||||
- 📋 **Planning Mode** (Plan before execution - Agent & Multi-Agent)
|
||||
- 🔧 **Planning Tools** (Research with tools during planning)
|
||||
- 🧠 **Planning Reasoning** (Chain-of-thought planning)
|
||||
|
||||
## Using Python Code
|
||||
|
||||
@@ -98,7 +106,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.
|
||||
|
||||
@@ -136,7 +171,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.
|
||||
|
||||
@@ -171,6 +206,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:
|
||||
@@ -718,6 +907,97 @@ agent = Agent(tools=["my_tool"]) # Works automatically!
|
||||
```
|
||||
|
||||
|
||||
## Prompt Expansion
|
||||
|
||||
Expand short prompts into detailed, actionable prompts:
|
||||
|
||||
### CLI Usage
|
||||
|
||||
```bash
|
||||
# Expand a short prompt into detailed prompt
|
||||
praisonai "write a movie script in 3 lines" --expand-prompt
|
||||
|
||||
# With verbose output
|
||||
praisonai "blog about AI" --expand-prompt -v
|
||||
|
||||
# With tools for context gathering
|
||||
praisonai "latest AI trends" --expand-prompt --expand-tools tools.py
|
||||
|
||||
# Combine with query rewrite
|
||||
praisonai "AI news" --query-rewrite --expand-prompt
|
||||
```
|
||||
|
||||
### Programmatic Usage
|
||||
|
||||
```python
|
||||
from praisonaiagents import PromptExpanderAgent, ExpandStrategy
|
||||
|
||||
# Basic usage
|
||||
agent = PromptExpanderAgent()
|
||||
result = agent.expand("write a movie script in 3 lines")
|
||||
print(result.expanded_prompt)
|
||||
|
||||
# With specific strategy
|
||||
result = agent.expand("blog about AI", strategy=ExpandStrategy.DETAILED)
|
||||
|
||||
# Available strategies: BASIC, DETAILED, STRUCTURED, CREATIVE, AUTO
|
||||
```
|
||||
|
||||
**Key Difference:**
|
||||
- `--query-rewrite`: Optimizes queries for **search/retrieval** (RAG)
|
||||
- `--expand-prompt`: Expands prompts for **detailed task execution**
|
||||
|
||||
|
||||
## Web Search, Web Fetch & Prompt Caching
|
||||
|
||||
### CLI Usage
|
||||
|
||||
```bash
|
||||
# Web Search - Get real-time information
|
||||
praisonai "What are the latest AI news today?" --web-search --llm openai/gpt-4o-search-preview
|
||||
|
||||
# Web Fetch - Retrieve and analyze URL content (Anthropic only)
|
||||
praisonai "Summarize https://docs.praison.ai" --web-fetch --llm anthropic/claude-sonnet-4-20250514
|
||||
|
||||
# Prompt Caching - Reduce costs for repeated prompts
|
||||
praisonai "Analyze this document..." --prompt-caching --llm anthropic/claude-sonnet-4-20250514
|
||||
```
|
||||
|
||||
### Programmatic Usage
|
||||
|
||||
```python
|
||||
from praisonaiagents import Agent
|
||||
|
||||
# Web Search
|
||||
agent = Agent(
|
||||
instructions="You are a research assistant",
|
||||
llm="openai/gpt-4o-search-preview",
|
||||
web_search=True
|
||||
)
|
||||
|
||||
# Web Fetch (Anthropic only)
|
||||
agent = Agent(
|
||||
instructions="You are a content analyzer",
|
||||
llm="anthropic/claude-sonnet-4-20250514",
|
||||
web_fetch=True
|
||||
)
|
||||
|
||||
# Prompt Caching
|
||||
agent = Agent(
|
||||
instructions="You are an AI assistant..." * 50, # Long system prompt
|
||||
llm="anthropic/claude-sonnet-4-20250514",
|
||||
prompt_caching=True
|
||||
)
|
||||
```
|
||||
|
||||
**Supported Providers:**
|
||||
| Feature | Providers |
|
||||
|---------|-----------|
|
||||
| Web Search | OpenAI, Gemini, Anthropic, xAI, Perplexity |
|
||||
| Web Fetch | Anthropic |
|
||||
| Prompt Caching | OpenAI (auto), Anthropic, Bedrock, Deepseek |
|
||||
|
||||
|
||||
## Development:
|
||||
|
||||
Below is used for development only.
|
||||
@@ -735,6 +1015,19 @@ uv pip install -r pyproject.toml --extra code
|
||||
uv pip install -r pyproject.toml --extra "crewai,autogen"
|
||||
```
|
||||
|
||||
### Bump and Release
|
||||
|
||||
```bash
|
||||
# From project root - bumps version and releases in one command
|
||||
python src/praisonai/scripts/bump_and_release.py 2.2.99
|
||||
|
||||
# With praisonaiagents dependency
|
||||
python src/praisonai/scripts/bump_and_release.py 2.2.99 --agents 0.0.169
|
||||
|
||||
# Then publish
|
||||
cd src/praisonai && uv publish
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
- Fork on GitHub: Use the "Fork" button on the repository page.
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
|
||||
# Install Python packages (using latest versions)
|
||||
RUN pip install --no-cache-dir \
|
||||
flask \
|
||||
"praisonai>=2.2.98" \
|
||||
"praisonai>=2.3.6" \
|
||||
"praisonai[api]" \
|
||||
gunicorn \
|
||||
markdown
|
||||
|
||||
@@ -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.2.98" \
|
||||
"praisonai>=2.3.6" \
|
||||
"praisonai[chat]" \
|
||||
"embedchain[github,youtube]"
|
||||
|
||||
|
||||
@@ -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.2.98" \
|
||||
"praisonai>=2.3.6" \
|
||||
"praisonai[ui]" \
|
||||
"praisonai[chat]" \
|
||||
"praisonai[realtime]" \
|
||||
|
||||
@@ -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.2.98" \
|
||||
"praisonai>=2.3.6" \
|
||||
"praisonai[ui]" \
|
||||
"praisonai[crewai]"
|
||||
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -347,6 +685,160 @@ agent = Agent(
|
||||
|
||||
---
|
||||
|
||||
## Native Web Search
|
||||
|
||||
Enable real-time web search capabilities using LiteLLM's native web search.
|
||||
|
||||
```python
|
||||
from praisonaiagents import Agent
|
||||
|
||||
# Enable web search with a supported model
|
||||
agent = Agent(
|
||||
name="Researcher",
|
||||
instructions="You are a helpful research assistant.",
|
||||
llm="openai/gpt-4o-search-preview",
|
||||
web_search=True
|
||||
)
|
||||
|
||||
result = agent.start("What are the latest developments in AI today?")
|
||||
```
|
||||
|
||||
**Supported Providers:**
|
||||
- **OpenAI**: `gpt-4o-search-preview`, `gpt-4o-mini-search-preview`
|
||||
- **Anthropic**: `claude-3-5-sonnet-latest`, `claude-sonnet-4`
|
||||
- **Google/Gemini**: `gemini-2.0-flash`, `gemini-2.5-pro`
|
||||
- **xAI**: `grok-3`
|
||||
- **Perplexity**: All models
|
||||
|
||||
---
|
||||
|
||||
## Web Fetch
|
||||
|
||||
Retrieve full content from specific URLs (Anthropic models only).
|
||||
|
||||
```python
|
||||
from praisonaiagents import Agent
|
||||
|
||||
# Enable web fetch with an Anthropic model
|
||||
agent = Agent(
|
||||
name="Content Analyzer",
|
||||
instructions="You can fetch and analyze web content.",
|
||||
llm="anthropic/claude-sonnet-4-20250514",
|
||||
web_fetch=True
|
||||
)
|
||||
|
||||
result = agent.start("Fetch and summarize https://example.com")
|
||||
```
|
||||
|
||||
**Web Fetch Options:**
|
||||
```python
|
||||
agent = Agent(
|
||||
llm="anthropic/claude-sonnet-4-20250514",
|
||||
web_fetch={
|
||||
"max_uses": 10,
|
||||
"allowed_domains": ["example.com"],
|
||||
"citations": {"enabled": True}
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Prompt Caching
|
||||
|
||||
Reduce costs and latency by caching parts of prompts.
|
||||
|
||||
```python
|
||||
from praisonaiagents import Agent
|
||||
|
||||
# Enable prompt caching for Anthropic models
|
||||
agent = Agent(
|
||||
name="Legal Analyst",
|
||||
instructions="You are an AI assistant analyzing legal documents." * 50, # Long prompt
|
||||
llm="anthropic/claude-3-5-sonnet-latest",
|
||||
prompt_caching=True
|
||||
)
|
||||
|
||||
# First call creates cache, subsequent calls use cached tokens
|
||||
result = agent.start("Summarize the key terms")
|
||||
```
|
||||
|
||||
**Supported Providers:**
|
||||
- **OpenAI** (`openai/`) - Automatic caching for prompts ≥1024 tokens
|
||||
- **Anthropic** (`anthropic/`) - Manual caching with `cache_control`
|
||||
- **Bedrock** (`bedrock/`) - All models supporting prompt caching
|
||||
- **Deepseek** (`deepseek/`) - Works like OpenAI
|
||||
|
||||
---
|
||||
|
||||
## 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,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)
|
||||
@@ -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()
|
||||
@@ -31,6 +31,11 @@ from .agent.query_rewriter_agent import (
|
||||
RewriteStrategy,
|
||||
RewriteResult
|
||||
)
|
||||
from .agent.prompt_expander_agent import (
|
||||
PromptExpanderAgent,
|
||||
ExpandStrategy,
|
||||
ExpandResult
|
||||
)
|
||||
from .agents.agents import PraisonAIAgents
|
||||
from .task.task import Task
|
||||
from .tools.tools import Tools
|
||||
@@ -50,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:
|
||||
@@ -228,7 +259,10 @@ __all__ = [
|
||||
'Provider',
|
||||
'QueryRewriterAgent',
|
||||
'RewriteStrategy',
|
||||
'RewriteResult'
|
||||
'RewriteResult',
|
||||
'PromptExpanderAgent',
|
||||
'ExpandStrategy',
|
||||
'ExpandResult'
|
||||
]
|
||||
|
||||
# Add MCP to __all__ if available
|
||||
|
||||
@@ -20,6 +20,11 @@ from .query_rewriter_agent import (
|
||||
RewriteStrategy,
|
||||
RewriteResult
|
||||
)
|
||||
from .prompt_expander_agent import (
|
||||
PromptExpanderAgent,
|
||||
ExpandStrategy,
|
||||
ExpandResult
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
'Agent',
|
||||
@@ -43,5 +48,8 @@ __all__ = [
|
||||
'Provider',
|
||||
'QueryRewriterAgent',
|
||||
'RewriteStrategy',
|
||||
'RewriteResult'
|
||||
'RewriteResult',
|
||||
'PromptExpanderAgent',
|
||||
'ExpandStrategy',
|
||||
'ExpandResult'
|
||||
]
|
||||
@@ -234,7 +234,15 @@ class Agent:
|
||||
max_guardrail_retries: int = 3,
|
||||
handoffs: Optional[List[Union['Agent', 'Handoff']]] = None,
|
||||
base_url: Optional[str] = None,
|
||||
api_key: Optional[str] = None
|
||||
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,
|
||||
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.
|
||||
|
||||
@@ -400,7 +408,11 @@ class Agent:
|
||||
model=model_name,
|
||||
base_url=base_url,
|
||||
api_key=api_key,
|
||||
metrics=metrics
|
||||
metrics=metrics,
|
||||
web_search=web_search,
|
||||
web_fetch=web_fetch,
|
||||
prompt_caching=prompt_caching,
|
||||
claude_memory=claude_memory
|
||||
)
|
||||
self._using_custom_llm = True
|
||||
except ImportError as e:
|
||||
@@ -435,6 +447,10 @@ class Agent:
|
||||
if api_key:
|
||||
llm_params['api_key'] = api_key
|
||||
llm_params['metrics'] = metrics
|
||||
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
|
||||
|
||||
@@ -471,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
|
||||
@@ -511,6 +528,32 @@ 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():
|
||||
from ..tools.duckduckgo_tools import internet_search
|
||||
# Check if internet_search is not already in tools
|
||||
tool_names = [getattr(t, '__name__', str(t)) for t in self.tools]
|
||||
if 'internet_search' not in tool_names and 'duckduckgo' not in tool_names:
|
||||
self.tools.append(internet_search)
|
||||
logging.info("Model does not support native web search. Added DuckDuckGo fallback tool.")
|
||||
|
||||
# Log warning if web_fetch is enabled but model doesn't support it
|
||||
if web_fetch and not self._model_supports_web_fetch():
|
||||
logging.warning(f"Model '{self.llm}' does not support native web fetch. Web fetch will be ignored.")
|
||||
|
||||
# Initialize guardrail settings
|
||||
self.guardrail = guardrail
|
||||
@@ -576,6 +619,294 @@ 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.
|
||||
|
||||
Returns:
|
||||
bool: True if the model supports native web search, False otherwise
|
||||
"""
|
||||
from ..llm.model_capabilities import supports_web_search
|
||||
|
||||
# Get the model name
|
||||
if hasattr(self, 'llm_instance') and self.llm_instance:
|
||||
model_name = self.llm_instance.model
|
||||
elif hasattr(self, 'llm') and self.llm:
|
||||
model_name = self.llm
|
||||
else:
|
||||
model_name = "gpt-5-nano"
|
||||
|
||||
return supports_web_search(model_name)
|
||||
|
||||
def _model_supports_web_fetch(self) -> bool:
|
||||
"""
|
||||
Check if the agent's model supports web fetch via LiteLLM.
|
||||
|
||||
Web fetch allows the model to retrieve full content from specific URLs.
|
||||
Currently only supported by Anthropic Claude models.
|
||||
|
||||
Returns:
|
||||
bool: True if the model supports web fetch, False otherwise
|
||||
"""
|
||||
from ..llm.model_capabilities import supports_web_fetch
|
||||
|
||||
# Get the model name
|
||||
if hasattr(self, 'llm_instance') and self.llm_instance:
|
||||
model_name = self.llm_instance.model
|
||||
elif hasattr(self, 'llm') and self.llm:
|
||||
model_name = self.llm
|
||||
else:
|
||||
model_name = "gpt-5-nano"
|
||||
|
||||
return supports_web_fetch(model_name)
|
||||
|
||||
def _model_supports_prompt_caching(self) -> bool:
|
||||
"""
|
||||
Check if the agent's model supports prompt caching via LiteLLM.
|
||||
|
||||
Prompt caching allows caching parts of prompts to reduce costs and latency.
|
||||
Supported by OpenAI, Anthropic, Bedrock, and Deepseek.
|
||||
|
||||
Returns:
|
||||
bool: True if the model supports prompt caching, False otherwise
|
||||
"""
|
||||
from ..llm.model_capabilities import supports_prompt_caching
|
||||
|
||||
# Get the model name
|
||||
if hasattr(self, 'llm_instance') and self.llm_instance:
|
||||
model_name = self.llm_instance.model
|
||||
elif hasattr(self, 'llm') and self.llm:
|
||||
model_name = self.llm
|
||||
else:
|
||||
model_name = "gpt-4o-mini"
|
||||
|
||||
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.
|
||||
@@ -822,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
|
||||
@@ -862,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
|
||||
|
||||
@@ -2150,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))
|
||||
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
"""
|
||||
Prompt Expander Agent Module
|
||||
|
||||
This module provides the PromptExpanderAgent class for expanding short prompts
|
||||
into detailed, comprehensive prompts for better task execution.
|
||||
|
||||
Unlike QueryRewriterAgent (which optimizes queries for search/retrieval),
|
||||
PromptExpanderAgent focuses on enriching prompts for task execution.
|
||||
|
||||
Supported Expansion Strategies:
|
||||
- **BASIC**: Simple expansion with clarity improvements
|
||||
- **DETAILED**: Rich expansion with context, constraints, and examples
|
||||
- **STRUCTURED**: Expansion with clear structure (task, format, requirements)
|
||||
- **CREATIVE**: Expansion with creative flair and vivid language
|
||||
- **AUTO**: Automatically selects the best strategy based on prompt analysis
|
||||
|
||||
Example:
|
||||
from praisonaiagents import PromptExpanderAgent, ExpandStrategy
|
||||
|
||||
agent = PromptExpanderAgent(verbose=True)
|
||||
|
||||
# Basic expansion
|
||||
result = agent.expand("write a movie script in 3 lines")
|
||||
print(result.expanded_prompt)
|
||||
|
||||
# Detailed expansion
|
||||
result = agent.expand("blog about AI", strategy=ExpandStrategy.DETAILED)
|
||||
print(result.expanded_prompt)
|
||||
|
||||
# With tools for context gathering
|
||||
from praisonaiagents.tools import internet_search
|
||||
agent = PromptExpanderAgent(tools=[internet_search], verbose=True)
|
||||
result = agent.expand("latest AI developments")
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import List, Optional, Any, Dict
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from datetime import datetime
|
||||
|
||||
class ExpandStrategy(Enum):
|
||||
"""Enumeration of available prompt expansion strategies."""
|
||||
BASIC = "basic"
|
||||
DETAILED = "detailed"
|
||||
STRUCTURED = "structured"
|
||||
CREATIVE = "creative"
|
||||
AUTO = "auto"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExpandResult:
|
||||
"""Result of a prompt expansion operation."""
|
||||
original_prompt: str
|
||||
expanded_prompt: str
|
||||
strategy_used: ExpandStrategy
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def __repr__(self):
|
||||
return f"ExpandResult(strategy={self.strategy_used.value}, original_len={len(self.original_prompt)}, expanded_len={len(self.expanded_prompt)})"
|
||||
|
||||
|
||||
class PromptExpanderAgent:
|
||||
"""
|
||||
Agent for expanding short prompts into detailed, comprehensive prompts.
|
||||
|
||||
This agent transforms brief task descriptions into rich, detailed prompts
|
||||
that provide better context and guidance for task execution.
|
||||
|
||||
Key difference from QueryRewriterAgent:
|
||||
- QueryRewriterAgent: Optimizes queries for search/retrieval (RAG)
|
||||
- PromptExpanderAgent: Expands prompts for detailed task execution
|
||||
|
||||
Attributes:
|
||||
name: Name of the agent
|
||||
model: LLM model to use for expansion
|
||||
verbose: Whether to print detailed logs
|
||||
temperature: Temperature for LLM generation (higher = more creative)
|
||||
tools: Optional tools for context gathering
|
||||
|
||||
Example:
|
||||
agent = PromptExpanderAgent(model="gpt-4o-mini", verbose=True)
|
||||
result = agent.expand("write a poem", strategy=ExpandStrategy.CREATIVE)
|
||||
print(result.expanded_prompt)
|
||||
"""
|
||||
|
||||
# Default prompts for each strategy
|
||||
PROMPTS = {
|
||||
"basic": """You are an expert at improving task prompts.
|
||||
|
||||
Current date: {current_date}
|
||||
|
||||
Given the following task prompt, expand it to be clearer and more actionable while preserving the original intent.
|
||||
|
||||
Guidelines:
|
||||
- Keep it as a TASK/COMMAND (do NOT convert to a question)
|
||||
- Add clarity without changing the core request
|
||||
- Fix any ambiguity
|
||||
- Keep it concise but complete
|
||||
- Use the current date for any time-sensitive context
|
||||
|
||||
Original prompt: {prompt}
|
||||
|
||||
Expanded prompt:""",
|
||||
|
||||
"detailed": """You are an expert at creating comprehensive task prompts.
|
||||
|
||||
Current date: {current_date}
|
||||
|
||||
Given the following brief task prompt, expand it into a detailed, actionable prompt that provides rich context and clear guidance.
|
||||
|
||||
Guidelines:
|
||||
- Keep it as a TASK/COMMAND (do NOT convert to a question)
|
||||
- Add specific details about what to include
|
||||
- Specify quality expectations
|
||||
- Add relevant constraints or requirements
|
||||
- Include format guidance if applicable
|
||||
- Mention style or tone if relevant
|
||||
- Use the current date for any time-sensitive context
|
||||
|
||||
Original prompt: {prompt}
|
||||
|
||||
Detailed expanded prompt:""",
|
||||
|
||||
"structured": """You are an expert at creating well-structured task prompts.
|
||||
|
||||
Current date: {current_date}
|
||||
|
||||
Given the following task prompt, expand it into a structured format with clear sections.
|
||||
|
||||
Guidelines:
|
||||
- Keep it as a TASK/COMMAND (do NOT convert to a question)
|
||||
- Use a clear structure:
|
||||
* Task: What to do
|
||||
* Format: Expected output format
|
||||
* Requirements: Specific requirements
|
||||
* Style: Tone or style guidance
|
||||
* Constraints: Any limitations
|
||||
- Be specific and actionable
|
||||
- Use the current date for any time-sensitive context
|
||||
|
||||
Original prompt: {prompt}
|
||||
|
||||
Structured expanded prompt:""",
|
||||
|
||||
"creative": """You are an expert at crafting inspiring, creative task prompts.
|
||||
|
||||
Current date: {current_date}
|
||||
|
||||
Given the following task prompt, expand it with vivid language and creative direction while keeping it actionable.
|
||||
|
||||
Guidelines:
|
||||
- Keep it as a TASK/COMMAND (do NOT convert to a question)
|
||||
- Use evocative, inspiring language
|
||||
- Paint a picture of the desired outcome
|
||||
- Add creative direction and artistic guidance
|
||||
- Make it exciting and engaging
|
||||
- Preserve the core task intent
|
||||
- Use the current date for any time-sensitive context
|
||||
|
||||
Original prompt: {prompt}
|
||||
|
||||
Creative expanded prompt:""",
|
||||
|
||||
"auto": """You are an expert at analyzing and expanding task prompts.
|
||||
|
||||
Current date: {current_date}
|
||||
|
||||
Analyze the following prompt and expand it appropriately based on its nature.
|
||||
|
||||
For short/vague prompts: Add significant detail and context
|
||||
For task prompts: Preserve the action-oriented nature
|
||||
For creative tasks: Add artistic direction
|
||||
For technical tasks: Add precision and requirements
|
||||
|
||||
IMPORTANT: Keep it as a TASK/COMMAND. Never convert to a question.
|
||||
Use the current date for any time-sensitive context.
|
||||
|
||||
Original prompt: {prompt}
|
||||
{context}
|
||||
|
||||
Expanded prompt:"""
|
||||
}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str = "PromptExpanderAgent",
|
||||
model: str = "gpt-4o-mini",
|
||||
instructions: Optional[str] = None,
|
||||
verbose: bool = False,
|
||||
temperature: float = 0.7, # Higher for creativity
|
||||
max_tokens: int = 1000,
|
||||
tools: Optional[List[Any]] = None
|
||||
):
|
||||
"""
|
||||
Initialize the PromptExpanderAgent.
|
||||
|
||||
Args:
|
||||
name: Name of the agent
|
||||
model: LLM model to use (default: gpt-4o-mini)
|
||||
instructions: Custom instructions for the agent
|
||||
verbose: Whether to print detailed logs
|
||||
temperature: Temperature for LLM generation (default: 0.7 for creativity)
|
||||
max_tokens: Maximum tokens for LLM response
|
||||
tools: Optional list of tools for context gathering
|
||||
"""
|
||||
self.name = name
|
||||
self.model = model
|
||||
self.instructions = instructions
|
||||
self.verbose = verbose
|
||||
self.temperature = temperature
|
||||
self.max_tokens = max_tokens
|
||||
self.tools = tools or []
|
||||
self._agent = None # Lazy initialized
|
||||
|
||||
# Setup logging
|
||||
self.logger = logging.getLogger(__name__)
|
||||
if self.verbose:
|
||||
self.logger.setLevel(logging.DEBUG)
|
||||
|
||||
@property
|
||||
def agent(self):
|
||||
"""Lazy initialization of internal Agent."""
|
||||
if self._agent is None:
|
||||
from .agent import Agent
|
||||
self._agent = Agent(
|
||||
name=self.name,
|
||||
role="Prompt Expansion Specialist",
|
||||
goal="Expand brief prompts into detailed, actionable prompts for better task execution. Use available tools when needed to gather context.",
|
||||
backstory="You are an expert at understanding user intent and transforming brief requests into comprehensive, detailed prompts.",
|
||||
tools=self.tools,
|
||||
llm=self.model,
|
||||
verbose=self.verbose,
|
||||
markdown=False
|
||||
)
|
||||
return self._agent
|
||||
|
||||
def _call_agent(self, prompt: str) -> str:
|
||||
"""Call the internal Agent with the given prompt."""
|
||||
try:
|
||||
response = self.agent.chat(prompt)
|
||||
return response if response else ""
|
||||
except Exception as e:
|
||||
self.logger.error(f"Agent call failed: {e}")
|
||||
raise
|
||||
|
||||
def _is_short_prompt(self, prompt: str, threshold: int = 10) -> bool:
|
||||
"""Check if prompt is considered short."""
|
||||
return len(prompt.split()) < threshold
|
||||
|
||||
def _is_task_prompt(self, prompt: str) -> bool:
|
||||
"""Check if prompt is a task/command (vs a question)."""
|
||||
task_verbs = [
|
||||
"write", "create", "generate", "build", "make", "design",
|
||||
"develop", "implement", "compose", "draft", "produce",
|
||||
"craft", "construct", "prepare", "draw", "paint", "code"
|
||||
]
|
||||
prompt_lower = prompt.lower().strip()
|
||||
return any(prompt_lower.startswith(verb) for verb in task_verbs)
|
||||
|
||||
def _is_creative_task(self, prompt: str) -> bool:
|
||||
"""Check if prompt is a creative task."""
|
||||
creative_indicators = [
|
||||
"poem", "story", "script", "song", "art", "creative",
|
||||
"fiction", "novel", "essay", "blog", "article", "write"
|
||||
]
|
||||
prompt_lower = prompt.lower()
|
||||
return any(ind in prompt_lower for ind in creative_indicators)
|
||||
|
||||
def _detect_strategy(self, prompt: str) -> ExpandStrategy:
|
||||
"""Auto-detect the best expansion strategy for a prompt."""
|
||||
# Very short prompts need detailed expansion
|
||||
if self._is_short_prompt(prompt, threshold=5):
|
||||
return ExpandStrategy.DETAILED
|
||||
|
||||
# Creative tasks benefit from creative expansion
|
||||
if self._is_creative_task(prompt):
|
||||
return ExpandStrategy.CREATIVE
|
||||
|
||||
# Task prompts get structured expansion
|
||||
if self._is_task_prompt(prompt):
|
||||
return ExpandStrategy.STRUCTURED
|
||||
|
||||
# Default to basic
|
||||
return ExpandStrategy.BASIC
|
||||
|
||||
def expand(
|
||||
self,
|
||||
prompt: str,
|
||||
strategy: ExpandStrategy = ExpandStrategy.AUTO,
|
||||
context: Optional[str] = None
|
||||
) -> ExpandResult:
|
||||
"""
|
||||
Expand a prompt using the specified strategy.
|
||||
|
||||
All operations go through an internal Agent which automatically handles
|
||||
tool calling when tools are provided. The agent decides when to use tools
|
||||
based on the prompt context.
|
||||
|
||||
Args:
|
||||
prompt: The original user prompt
|
||||
strategy: Expansion strategy to use
|
||||
context: Additional context to help with expansion
|
||||
|
||||
Returns:
|
||||
ExpandResult containing the expanded prompt
|
||||
"""
|
||||
if self.verbose:
|
||||
self.logger.debug(f"Expanding prompt: '{prompt}' with strategy: {strategy.value}")
|
||||
if self.tools:
|
||||
print(f"[cyan]Agent has {len(self.tools)} tools available (will use if needed)...[/cyan]")
|
||||
|
||||
# Auto-detect strategy if needed
|
||||
if strategy == ExpandStrategy.AUTO:
|
||||
strategy = self._detect_strategy(prompt)
|
||||
if self.verbose:
|
||||
self.logger.debug(f"Auto-detected strategy: {strategy.value}")
|
||||
|
||||
# Build the expansion prompt
|
||||
if strategy == ExpandStrategy.AUTO:
|
||||
# Fallback if auto-detect returns AUTO
|
||||
strategy = ExpandStrategy.DETAILED
|
||||
|
||||
# Get current date for time-sensitive context
|
||||
current_date = datetime.now().strftime("%B %d, %Y")
|
||||
|
||||
expansion_prompt = self.PROMPTS[strategy.value].format(
|
||||
prompt=prompt,
|
||||
context=context or "",
|
||||
current_date=current_date
|
||||
)
|
||||
|
||||
# Call the agent
|
||||
expanded = self._call_agent(expansion_prompt).strip()
|
||||
|
||||
# If expansion failed, return original
|
||||
if not expanded:
|
||||
expanded = prompt
|
||||
|
||||
return ExpandResult(
|
||||
original_prompt=prompt,
|
||||
expanded_prompt=expanded,
|
||||
strategy_used=strategy,
|
||||
metadata={
|
||||
"original_length": len(prompt),
|
||||
"expanded_length": len(expanded),
|
||||
"has_context": bool(context),
|
||||
"has_tools": bool(self.tools)
|
||||
}
|
||||
)
|
||||
|
||||
# Convenience methods for direct strategy access
|
||||
def expand_basic(self, prompt: str, context: Optional[str] = None) -> ExpandResult:
|
||||
"""Convenience method for basic expansion."""
|
||||
return self.expand(prompt, strategy=ExpandStrategy.BASIC, context=context)
|
||||
|
||||
def expand_detailed(self, prompt: str, context: Optional[str] = None) -> ExpandResult:
|
||||
"""Convenience method for detailed expansion."""
|
||||
return self.expand(prompt, strategy=ExpandStrategy.DETAILED, context=context)
|
||||
|
||||
def expand_structured(self, prompt: str, context: Optional[str] = None) -> ExpandResult:
|
||||
"""Convenience method for structured expansion."""
|
||||
return self.expand(prompt, strategy=ExpandStrategy.STRUCTURED, context=context)
|
||||
|
||||
def expand_creative(self, prompt: str, context: Optional[str] = None) -> ExpandResult:
|
||||
"""Convenience method for creative expansion."""
|
||||
return self.expand(prompt, strategy=ExpandStrategy.CREATIVE, context=context)
|
||||
@@ -110,20 +110,23 @@ class QueryRewriterAgent:
|
||||
|
||||
# Default prompts for each strategy
|
||||
PROMPTS = {
|
||||
"basic": """You are an expert at rewriting search queries for better retrieval.
|
||||
"basic": """You are an expert at improving prompts and queries.
|
||||
|
||||
Given the following query, rewrite it to be clearer, more specific, and better suited for searching a knowledge base.
|
||||
Given the following input, improve it while preserving its original intent and type.
|
||||
|
||||
Guidelines:
|
||||
- If it's a TASK (e.g., "write X", "create Y", "generate Z"), keep it as a task - do NOT convert to a question
|
||||
- If it's a QUESTION (e.g., "what is X?", "how does Y work?"), keep it as a question
|
||||
- Expand abbreviations and acronyms
|
||||
- Fix typos and grammatical errors
|
||||
- Add relevant context if the query is too short
|
||||
- Transform keyword searches into natural questions
|
||||
- Keep the original intent intact
|
||||
- Add relevant context if too short
|
||||
- Keep the original intent and action intact
|
||||
|
||||
Query: {query}
|
||||
IMPORTANT: If the input is a command/task to DO something, the output must also be a command/task to DO that thing.
|
||||
|
||||
Rewritten query:""",
|
||||
Input: {query}
|
||||
|
||||
Improved version:""",
|
||||
|
||||
"hyde": """You are an expert at generating hypothetical documents.
|
||||
|
||||
@@ -198,21 +201,23 @@ Follow-up query: {query}
|
||||
|
||||
Standalone question:""",
|
||||
|
||||
"auto": """You are an expert at analyzing and rewriting queries.
|
||||
"auto": """You are an expert at analyzing and improving prompts and queries.
|
||||
|
||||
Analyze the following query and determine the best rewriting strategy, then apply it.
|
||||
Analyze the following input and improve it while preserving its original intent and type.
|
||||
|
||||
Query types to consider:
|
||||
1. Short/keyword queries → Expand into full questions
|
||||
2. Ambiguous queries → Clarify with context
|
||||
3. Complex multi-part queries → Break into sub-queries
|
||||
4. Follow-up queries → Resolve references from context
|
||||
5. Well-formed queries → Minor optimization only
|
||||
Input types to consider:
|
||||
1. TASK/COMMAND (write, create, generate, build) → Keep as a task, make it clearer
|
||||
2. QUESTION (what, how, why, when) → Keep as a question, make it more specific
|
||||
3. Short/keyword inputs → Expand with context but preserve type
|
||||
4. Complex multi-part inputs → Clarify structure
|
||||
5. Well-formed inputs → Minor optimization only
|
||||
|
||||
Query: {query}
|
||||
IMPORTANT: Never convert a task into a question. "Write X" should remain "Write X", not "How to write X?"
|
||||
|
||||
Input: {query}
|
||||
{context}
|
||||
|
||||
Analysis and rewritten query:"""
|
||||
Improved version:"""
|
||||
}
|
||||
|
||||
def __init__(
|
||||
|
||||
@@ -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
|
||||
@@ -239,6 +239,10 @@ class LLM:
|
||||
api_key: Optional[str] = None,
|
||||
base_url: Optional[str] = None,
|
||||
events: List[Any] = [],
|
||||
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
|
||||
@@ -273,6 +277,11 @@ class LLM:
|
||||
self.base_url = base_url
|
||||
self.events = events
|
||||
self.extra_settings = extra_settings
|
||||
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)
|
||||
@@ -381,6 +390,109 @@ class LLM:
|
||||
else:
|
||||
return False
|
||||
|
||||
def _supports_web_search(self) -> bool:
|
||||
"""
|
||||
Check if the current model supports native web search via LiteLLM.
|
||||
|
||||
Native web search allows the model to search the web in real-time
|
||||
without requiring external tools like DuckDuckGo.
|
||||
|
||||
Returns:
|
||||
bool: True if the model supports native web search, False otherwise
|
||||
"""
|
||||
from .model_capabilities import supports_web_search
|
||||
return supports_web_search(self.model)
|
||||
|
||||
def _supports_web_fetch(self) -> bool:
|
||||
"""
|
||||
Check if the current model supports web fetch via LiteLLM.
|
||||
|
||||
Web fetch allows the model to retrieve full content from specific URLs
|
||||
(web pages and PDF documents). Currently only supported by Anthropic Claude models.
|
||||
|
||||
Returns:
|
||||
bool: True if the model supports web fetch, False otherwise
|
||||
"""
|
||||
from .model_capabilities import supports_web_fetch
|
||||
return supports_web_fetch(self.model)
|
||||
|
||||
def _supports_prompt_caching(self) -> bool:
|
||||
"""
|
||||
Check if the current model supports prompt caching via LiteLLM.
|
||||
|
||||
Prompt caching allows caching parts of prompts to reduce costs and latency.
|
||||
Supported by OpenAI, Anthropic, Bedrock, and Deepseek.
|
||||
|
||||
Returns:
|
||||
bool: True if the model supports prompt caching, False otherwise
|
||||
"""
|
||||
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.
|
||||
@@ -724,7 +836,21 @@ class LLM:
|
||||
|
||||
# Skip system messages for legacy o1 models as they don't support them
|
||||
if not self._needs_system_message_skip():
|
||||
messages.append({"role": "system", "content": system_prompt})
|
||||
# Apply prompt caching for Anthropic models if enabled
|
||||
if self.prompt_caching and self._supports_prompt_caching() and self._is_anthropic_model():
|
||||
# Anthropic requires cache_control in content array format
|
||||
messages.append({
|
||||
"role": "system",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": system_prompt,
|
||||
"cache_control": {"type": "ephemeral"}
|
||||
}
|
||||
]
|
||||
})
|
||||
else:
|
||||
messages.append({"role": "system", "content": system_prompt})
|
||||
|
||||
# Add chat history if provided
|
||||
if chat_history:
|
||||
@@ -2295,6 +2421,12 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
return False
|
||||
return any(prefix in self.model.lower() for prefix in ['gemini', 'gemini/', 'google/gemini'])
|
||||
|
||||
def _is_anthropic_model(self) -> bool:
|
||||
"""Check if the model is an Anthropic Claude model."""
|
||||
if not self.model:
|
||||
return False
|
||||
return any(prefix in self.model.lower() for prefix in ['claude', 'anthropic/', 'anthropic/claude'])
|
||||
|
||||
def _is_streaming_error_recoverable(self, error: Exception) -> bool:
|
||||
"""Check if a streaming error is recoverable (e.g., malformed chunk vs connection error)."""
|
||||
error_msg = str(error).lower()
|
||||
@@ -3171,6 +3303,79 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
logging.debug(f"Could not verify function calling support: {e}. Setting tool_choice anyway.")
|
||||
params['tool_choice'] = 'auto'
|
||||
|
||||
# Add web_search_options if web_search is enabled and model supports it
|
||||
if self.web_search and self._supports_web_search():
|
||||
if isinstance(self.web_search, dict):
|
||||
# Use custom web_search_options provided by user
|
||||
params['web_search_options'] = self.web_search
|
||||
else:
|
||||
# Use default web_search_options
|
||||
params['web_search_options'] = {"search_context_size": "medium"}
|
||||
logging.debug(f"Web search enabled with options: {params['web_search_options']}")
|
||||
|
||||
# Some web search models (like gpt-4o-search-preview) don't support certain parameters
|
||||
# Remove incompatible parameters to avoid BadRequestError
|
||||
incompatible_params = ['temperature', 'top_p', 'presence_penalty', 'frequency_penalty', 'logit_bias']
|
||||
for param in incompatible_params:
|
||||
if param in params:
|
||||
del params[param]
|
||||
logging.debug(f"Removed incompatible parameter '{param}' for web search model")
|
||||
|
||||
# Add web_fetch tool if web_fetch is enabled and model supports it
|
||||
# Web fetch is passed as a tool (unlike web_search which uses web_search_options)
|
||||
if self.web_fetch and self._supports_web_fetch():
|
||||
# Build the web_fetch tool definition
|
||||
web_fetch_tool = {
|
||||
"type": "web_fetch_20250910",
|
||||
"name": "web_fetch",
|
||||
}
|
||||
|
||||
# Add optional parameters from web_fetch dict if provided
|
||||
if isinstance(self.web_fetch, dict):
|
||||
if 'max_uses' in self.web_fetch:
|
||||
web_fetch_tool['max_uses'] = self.web_fetch['max_uses']
|
||||
if 'allowed_domains' in self.web_fetch:
|
||||
web_fetch_tool['allowed_domains'] = self.web_fetch['allowed_domains']
|
||||
if 'blocked_domains' in self.web_fetch:
|
||||
web_fetch_tool['blocked_domains'] = self.web_fetch['blocked_domains']
|
||||
if 'citations' in self.web_fetch:
|
||||
web_fetch_tool['citations'] = self.web_fetch['citations']
|
||||
if 'max_content_tokens' in self.web_fetch:
|
||||
web_fetch_tool['max_content_tokens'] = self.web_fetch['max_content_tokens']
|
||||
else:
|
||||
# Default max_uses if web_fetch=True
|
||||
web_fetch_tool['max_uses'] = 5
|
||||
|
||||
# Add web_fetch tool to existing tools or create tools list
|
||||
if 'tools' in params and params['tools']:
|
||||
params['tools'].append(web_fetch_tool)
|
||||
else:
|
||||
params['tools'] = [web_fetch_tool]
|
||||
|
||||
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,62 +1,30 @@
|
||||
"""
|
||||
Model capabilities configuration for different LLM providers.
|
||||
This module defines which models support specific features like structured outputs.
|
||||
|
||||
This module uses LiteLLM's helper functions as the primary source for model capability detection.
|
||||
LiteLLM is maintained by many contributors and is more accurate and up-to-date.
|
||||
|
||||
LiteLLM Helper Functions:
|
||||
- litellm.supports_web_search(model=) - Check web search support
|
||||
- litellm.supports_function_calling(model=) - Check function calling support
|
||||
- litellm.supports_parallel_function_calling(model=) - Check parallel function calling
|
||||
- litellm.supports_response_schema(model=) - Check JSON schema/structured outputs support
|
||||
- litellm.utils.supports_prompt_caching(model=) - Check prompt caching support
|
||||
- litellm.get_supported_openai_params(model=) - Get all supported params
|
||||
|
||||
Sources:
|
||||
- https://docs.litellm.ai/docs/completion/web_search
|
||||
- https://docs.litellm.ai/docs/completion/json_mode
|
||||
- https://docs.litellm.ai/docs/completion/function_call
|
||||
- https://docs.litellm.ai/docs/completion/prompt_caching
|
||||
"""
|
||||
|
||||
# Models that support OpenAI-style structured outputs (response_format with Pydantic models)
|
||||
MODELS_SUPPORTING_STRUCTURED_OUTPUTS = {
|
||||
# OpenAI models
|
||||
"gpt-5-nano",
|
||||
"gpt-5-nano",
|
||||
"gpt-4-turbo",
|
||||
"gpt-4-turbo-preview",
|
||||
"gpt-4-turbo-2024-04-09",
|
||||
"gpt-4-1106-preview",
|
||||
"gpt-4-0125-preview",
|
||||
"gpt-3.5-turbo",
|
||||
"gpt-3.5-turbo-1106",
|
||||
"gpt-3.5-turbo-0125",
|
||||
|
||||
# New/Future OpenAI models (as mentioned by user)
|
||||
"codex-mini",
|
||||
"o3-pro",
|
||||
"gpt-4.5-preview",
|
||||
"o3-mini",
|
||||
"o1",
|
||||
"o1-preview",
|
||||
"o1-mini",
|
||||
"gpt-4.1",
|
||||
"gpt-4.1-nano",
|
||||
"gpt-4.1-mini",
|
||||
"o4-mini",
|
||||
"o3",
|
||||
|
||||
# Gemini models that support structured outputs
|
||||
"gemini-2.0-flash",
|
||||
"gemini-2.0-flash-exp",
|
||||
"gemini-1.5-pro",
|
||||
"gemini-1.5-pro-latest",
|
||||
"gemini-1.5-flash",
|
||||
"gemini-1.5-flash-latest",
|
||||
"gemini-1.5-flash-8b",
|
||||
"gemini-1.5-flash-8b-latest",
|
||||
}
|
||||
|
||||
# Models that explicitly DON'T support structured outputs
|
||||
MODELS_NOT_SUPPORTING_STRUCTURED_OUTPUTS = {
|
||||
# Audio preview models
|
||||
"gpt-4o-audio-preview",
|
||||
"gpt-5-nano-audio-preview",
|
||||
|
||||
# Legacy o1 models (don't support system messages either)
|
||||
"o1-preview-2024-09-12",
|
||||
"o1-mini-2024-09-12",
|
||||
}
|
||||
|
||||
|
||||
def supports_structured_outputs(model_name: str) -> bool:
|
||||
"""
|
||||
Check if a model supports OpenAI-style structured outputs.
|
||||
Check if a model supports structured outputs (JSON schema).
|
||||
|
||||
Uses LiteLLM's supports_response_schema() as the primary check.
|
||||
|
||||
Args:
|
||||
model_name: The name of the model to check
|
||||
@@ -67,34 +35,72 @@ def supports_structured_outputs(model_name: str) -> bool:
|
||||
if not model_name:
|
||||
return False
|
||||
|
||||
# Strip provider prefixes (e.g., 'google/', 'openai/', etc.)
|
||||
model_without_provider = model_name
|
||||
for prefix in ['google/', 'openai/', 'anthropic/', 'gemini/', 'mistral/', 'deepseek/', 'groq/']:
|
||||
if model_name.startswith(prefix):
|
||||
model_without_provider = model_name[len(prefix):]
|
||||
break
|
||||
try:
|
||||
import litellm
|
||||
# Use LiteLLM's built-in check - most accurate and up-to-date
|
||||
if hasattr(litellm, 'supports_response_schema'):
|
||||
return litellm.supports_response_schema(model=model_name)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# First check if it's explicitly in the NOT supporting list
|
||||
if model_without_provider in MODELS_NOT_SUPPORTING_STRUCTURED_OUTPUTS:
|
||||
return False
|
||||
|
||||
|
||||
def supports_function_calling(model_name: str) -> bool:
|
||||
"""
|
||||
Check if a model supports function calling.
|
||||
|
||||
Uses LiteLLM's supports_function_calling() as the primary check.
|
||||
|
||||
Args:
|
||||
model_name: The name of the model to check
|
||||
|
||||
Returns:
|
||||
bool: True if the model supports function calling, False otherwise
|
||||
"""
|
||||
if not model_name:
|
||||
return False
|
||||
|
||||
# Then check if it's in the supporting list
|
||||
if model_without_provider in MODELS_SUPPORTING_STRUCTURED_OUTPUTS:
|
||||
return True
|
||||
try:
|
||||
import litellm
|
||||
# Use LiteLLM's built-in check - most accurate and up-to-date
|
||||
if hasattr(litellm, 'supports_function_calling'):
|
||||
return litellm.supports_function_calling(model=model_name)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# For models with version suffixes, check the base model name
|
||||
base_model = model_without_provider.split('-2024-')[0].split('-2025-')[0]
|
||||
if base_model in MODELS_SUPPORTING_STRUCTURED_OUTPUTS:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def supports_parallel_function_calling(model_name: str) -> bool:
|
||||
"""
|
||||
Check if a model supports parallel function calling.
|
||||
|
||||
Uses LiteLLM's supports_parallel_function_calling() as the primary check.
|
||||
|
||||
Args:
|
||||
model_name: The name of the model to check
|
||||
|
||||
Returns:
|
||||
bool: True if the model supports parallel function calling, False otherwise
|
||||
"""
|
||||
if not model_name:
|
||||
return False
|
||||
|
||||
try:
|
||||
import litellm
|
||||
# Use LiteLLM's built-in check - most accurate and up-to-date
|
||||
if hasattr(litellm, 'supports_parallel_function_calling'):
|
||||
return litellm.supports_parallel_function_calling(model=model_name)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Default to False for unknown models
|
||||
return False
|
||||
|
||||
|
||||
def supports_streaming_with_tools(model_name: str) -> bool:
|
||||
"""
|
||||
Check if a model supports streaming when tools are provided.
|
||||
Most models that support structured outputs also support streaming with tools.
|
||||
|
||||
Args:
|
||||
model_name: The name of the model to check
|
||||
@@ -102,15 +108,153 @@ def supports_streaming_with_tools(model_name: str) -> bool:
|
||||
Returns:
|
||||
bool: True if the model supports streaming with tools, False otherwise
|
||||
"""
|
||||
# For now, use the same logic as structured outputs
|
||||
# In the future, this could be a separate list if needed
|
||||
return supports_structured_outputs(model_name)
|
||||
# Models that support function calling generally support streaming with tools
|
||||
return supports_function_calling(model_name)
|
||||
|
||||
|
||||
# Supported Gemini internal tools
|
||||
GEMINI_INTERNAL_TOOLS = {'googleSearch', 'urlContext', 'codeExecution'}
|
||||
|
||||
|
||||
def supports_web_search(model_name: str) -> bool:
|
||||
"""
|
||||
Check if a model supports native web search via LiteLLM.
|
||||
|
||||
Uses LiteLLM's supports_web_search() as the primary check.
|
||||
|
||||
Native web search allows the model to search the web in real-time
|
||||
without requiring external tools like DuckDuckGo.
|
||||
|
||||
Supported providers:
|
||||
- OpenAI (gpt-4o-search-preview, gpt-4o-mini-search-preview)
|
||||
- xAI (grok-3)
|
||||
- Anthropic (claude-3-5-sonnet-latest, claude-sonnet-4, etc.)
|
||||
- Google/Vertex AI (gemini-2.0-flash, gemini-2.5-*, etc.)
|
||||
- Perplexity (all models)
|
||||
|
||||
Args:
|
||||
model_name: The name of the model to check (with or without provider prefix)
|
||||
|
||||
Returns:
|
||||
bool: True if the model supports native web search, False otherwise
|
||||
"""
|
||||
if not model_name:
|
||||
return False
|
||||
|
||||
try:
|
||||
import litellm
|
||||
# Use LiteLLM's built-in check - most accurate and up-to-date
|
||||
if hasattr(litellm, 'supports_web_search'):
|
||||
return litellm.supports_web_search(model=model_name)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def supports_prompt_caching(model_name: str) -> bool:
|
||||
"""
|
||||
Check if a model supports prompt caching via LiteLLM.
|
||||
|
||||
Uses LiteLLM's supports_prompt_caching() as the primary check.
|
||||
|
||||
Prompt caching allows caching parts of prompts to reduce costs and latency
|
||||
on subsequent requests with similar prompts.
|
||||
|
||||
Supported providers:
|
||||
- OpenAI (openai/) - Automatic caching for prompts ≥1024 tokens
|
||||
- Anthropic (anthropic/) - Manual caching with cache_control
|
||||
- Bedrock (bedrock/) - All models that support prompt caching
|
||||
- Deepseek (deepseek/) - Works like OpenAI
|
||||
|
||||
Args:
|
||||
model_name: The name of the model to check (with or without provider prefix)
|
||||
|
||||
Returns:
|
||||
bool: True if the model supports prompt caching, False otherwise
|
||||
"""
|
||||
if not model_name:
|
||||
return False
|
||||
|
||||
try:
|
||||
from litellm.utils import supports_prompt_caching as litellm_supports_prompt_caching
|
||||
return litellm_supports_prompt_caching(model=model_name)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return False
|
||||
|
||||
|
||||
# Models that support web fetch via LiteLLM (Anthropic only)
|
||||
# Web fetch retrieves full content from specific URLs (web pages and PDFs)
|
||||
# Source: https://docs.litellm.ai/docs/completion/web_fetch
|
||||
# Note: LiteLLM doesn't have a supports_web_fetch() helper yet, so we maintain a static list
|
||||
MODELS_SUPPORTING_WEB_FETCH = {
|
||||
# Anthropic Claude models with web fetch support
|
||||
"claude-opus-4-1-20250805",
|
||||
"claude-opus-4-1",
|
||||
"claude-opus-4-20250514",
|
||||
"claude-opus-4",
|
||||
"claude-sonnet-4-20250514",
|
||||
"claude-sonnet-4",
|
||||
"claude-3-7-sonnet-20250219",
|
||||
"claude-3-7-sonnet-latest",
|
||||
"claude-3-5-sonnet-latest",
|
||||
"claude-3-5-sonnet-20241022",
|
||||
"claude-3-5-haiku-latest",
|
||||
"claude-3-5-haiku-20241022",
|
||||
}
|
||||
|
||||
|
||||
def supports_web_fetch(model_name: str) -> bool:
|
||||
"""
|
||||
Check if a model supports web fetch via LiteLLM.
|
||||
|
||||
Web fetch allows the model to retrieve full content from specific URLs
|
||||
(web pages and PDF documents). Currently only supported by Anthropic Claude models.
|
||||
|
||||
Note: LiteLLM doesn't have a supports_web_fetch() helper yet, so we use a static list
|
||||
with auto-detection for Claude 4+ models.
|
||||
|
||||
Args:
|
||||
model_name: The name of the model to check (with or without provider prefix)
|
||||
|
||||
Returns:
|
||||
bool: True if the model supports web fetch, False otherwise
|
||||
"""
|
||||
if not model_name:
|
||||
return False
|
||||
|
||||
# Strip provider prefixes
|
||||
model_without_provider = model_name
|
||||
for prefix in ['anthropic/', 'bedrock/', 'vertex_ai/']:
|
||||
if model_name.startswith(prefix):
|
||||
model_without_provider = model_name[len(prefix):]
|
||||
break
|
||||
|
||||
# Check our static list
|
||||
if model_without_provider in MODELS_SUPPORTING_WEB_FETCH:
|
||||
return True
|
||||
|
||||
# Check base model name (without version suffix)
|
||||
base_model = model_without_provider.split('-2024-')[0].split('-2025-')[0]
|
||||
if base_model in MODELS_SUPPORTING_WEB_FETCH:
|
||||
return True
|
||||
|
||||
# Auto-support for Claude 4+ models (Anthropic only)
|
||||
model_lower = model_without_provider.lower()
|
||||
if 'claude' in model_lower:
|
||||
import re
|
||||
# Match patterns like claude-4, claude-5, claude-sonnet-4, claude-opus-4, etc.
|
||||
version_match = re.search(r'claude-(?:sonnet-|opus-|haiku-)?(\d+)', model_lower)
|
||||
if version_match:
|
||||
version = int(version_match.group(1))
|
||||
if version >= 4: # Claude 4 and later
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def is_gemini_internal_tool(tool) -> bool:
|
||||
"""
|
||||
Check if a tool is a Gemini internal tool and should be included in formatted tools.
|
||||
|
||||
@@ -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,9 @@ 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'),
|
||||
}
|
||||
|
||||
_instances = {} # Cache for class instances
|
||||
@@ -184,6 +187,11 @@ def __getattr__(name: str) -> Any:
|
||||
|
||||
module_path, class_name = TOOL_MAPPINGS[name]
|
||||
|
||||
# Return class itself (not instance) for ClaudeMemoryTool
|
||||
if name == 'ClaudeMemoryTool':
|
||||
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__)
|
||||
|
||||
@@ -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}')"
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "praisonaiagents"
|
||||
version = "0.0.169"
|
||||
version = "0.0.184"
|
||||
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,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,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,239 @@
|
||||
"""
|
||||
Tests for prompt caching feature.
|
||||
|
||||
Prompt caching allows caching parts of prompts to reduce costs and latency.
|
||||
Supported by OpenAI, Anthropic, Bedrock, and Deepseek.
|
||||
|
||||
Source: https://docs.litellm.ai/docs/completion/prompt_caching
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
|
||||
class TestSupportsPromptCaching:
|
||||
"""Tests for the supports_prompt_caching function in model_capabilities.py"""
|
||||
|
||||
def test_supports_prompt_caching_anthropic(self):
|
||||
"""Test that Anthropic Claude models support prompt caching."""
|
||||
from praisonaiagents.llm.model_capabilities import supports_prompt_caching
|
||||
|
||||
# Mock litellm.utils.supports_prompt_caching
|
||||
with patch('praisonaiagents.llm.model_capabilities.supports_prompt_caching') as mock_func:
|
||||
# Just test that the function exists and can be called
|
||||
mock_func.return_value = True
|
||||
result = mock_func(model="anthropic/claude-3-5-sonnet-latest")
|
||||
assert result == True
|
||||
|
||||
def test_supports_prompt_caching_openai(self):
|
||||
"""Test that OpenAI models support prompt caching."""
|
||||
from praisonaiagents.llm.model_capabilities import supports_prompt_caching
|
||||
|
||||
with patch('litellm.utils.supports_prompt_caching', return_value=True):
|
||||
# The function should delegate to litellm
|
||||
result = supports_prompt_caching("openai/gpt-4o")
|
||||
# Result depends on litellm's implementation
|
||||
assert isinstance(result, bool)
|
||||
|
||||
def test_supports_prompt_caching_empty_model(self):
|
||||
"""Test that empty model name returns False."""
|
||||
from praisonaiagents.llm.model_capabilities import supports_prompt_caching
|
||||
|
||||
assert supports_prompt_caching("") == False
|
||||
assert supports_prompt_caching(None) == False
|
||||
|
||||
|
||||
class TestLLMPromptCachingParameter:
|
||||
"""Tests for prompt_caching parameter in LLM class."""
|
||||
|
||||
def test_llm_init_with_prompt_caching_true(self):
|
||||
"""Test LLM initialization with prompt_caching=True."""
|
||||
from praisonaiagents.llm.llm import LLM
|
||||
|
||||
llm = LLM(model="anthropic/claude-3-5-sonnet-latest", prompt_caching=True)
|
||||
assert llm.prompt_caching == True
|
||||
|
||||
def test_llm_init_with_prompt_caching_false(self):
|
||||
"""Test LLM initialization with prompt_caching=False."""
|
||||
from praisonaiagents.llm.llm import LLM
|
||||
|
||||
llm = LLM(model="anthropic/claude-3-5-sonnet-latest", prompt_caching=False)
|
||||
assert llm.prompt_caching == False
|
||||
|
||||
def test_llm_init_without_prompt_caching(self):
|
||||
"""Test LLM initialization without prompt_caching parameter."""
|
||||
from praisonaiagents.llm.llm import LLM
|
||||
|
||||
llm = LLM(model="gpt-4o-mini")
|
||||
assert llm.prompt_caching is None
|
||||
|
||||
def test_llm_supports_prompt_caching_method(self):
|
||||
"""Test _supports_prompt_caching method exists and works."""
|
||||
from praisonaiagents.llm.llm import LLM
|
||||
|
||||
llm = LLM(model="anthropic/claude-3-5-sonnet-latest")
|
||||
# Method should exist and return a boolean
|
||||
result = llm._supports_prompt_caching()
|
||||
assert isinstance(result, bool)
|
||||
|
||||
def test_llm_is_anthropic_model_method(self):
|
||||
"""Test _is_anthropic_model method."""
|
||||
from praisonaiagents.llm.llm import LLM
|
||||
|
||||
# Anthropic model
|
||||
llm_anthropic = LLM(model="anthropic/claude-3-5-sonnet-latest")
|
||||
assert llm_anthropic._is_anthropic_model() == True
|
||||
|
||||
# OpenAI model
|
||||
llm_openai = LLM(model="gpt-4o-mini")
|
||||
assert llm_openai._is_anthropic_model() == False
|
||||
|
||||
# Gemini model
|
||||
llm_gemini = LLM(model="gemini/gemini-2.0-flash")
|
||||
assert llm_gemini._is_anthropic_model() == False
|
||||
|
||||
|
||||
class TestLLMBuildMessagesWithPromptCaching:
|
||||
"""Tests for _build_messages with prompt caching enabled."""
|
||||
|
||||
def test_build_messages_with_prompt_caching_anthropic(self):
|
||||
"""Test that _build_messages adds cache_control for Anthropic models."""
|
||||
from praisonaiagents.llm.llm import LLM
|
||||
|
||||
llm = LLM(model="anthropic/claude-3-5-sonnet-latest", prompt_caching=True)
|
||||
|
||||
# Mock _supports_prompt_caching to return True
|
||||
with patch.object(llm, '_supports_prompt_caching', return_value=True):
|
||||
messages, _ = llm._build_messages(
|
||||
prompt="Hello",
|
||||
system_prompt="You are a helpful assistant."
|
||||
)
|
||||
|
||||
# Check that system message has cache_control
|
||||
system_msg = messages[0]
|
||||
assert system_msg["role"] == "system"
|
||||
# For Anthropic with prompt_caching, content should be an array
|
||||
assert isinstance(system_msg["content"], list)
|
||||
assert system_msg["content"][0]["type"] == "text"
|
||||
assert system_msg["content"][0]["cache_control"] == {"type": "ephemeral"}
|
||||
|
||||
def test_build_messages_without_prompt_caching(self):
|
||||
"""Test that _build_messages doesn't add cache_control when disabled."""
|
||||
from praisonaiagents.llm.llm import LLM
|
||||
|
||||
llm = LLM(model="anthropic/claude-3-5-sonnet-latest", prompt_caching=False)
|
||||
|
||||
messages, _ = llm._build_messages(
|
||||
prompt="Hello",
|
||||
system_prompt="You are a helpful assistant."
|
||||
)
|
||||
|
||||
# Check that system message is a simple string
|
||||
system_msg = messages[0]
|
||||
assert system_msg["role"] == "system"
|
||||
assert isinstance(system_msg["content"], str)
|
||||
|
||||
def test_build_messages_prompt_caching_non_anthropic(self):
|
||||
"""Test that prompt caching doesn't affect non-Anthropic models."""
|
||||
from praisonaiagents.llm.llm import LLM
|
||||
|
||||
llm = LLM(model="gpt-4o-mini", prompt_caching=True)
|
||||
|
||||
messages, _ = llm._build_messages(
|
||||
prompt="Hello",
|
||||
system_prompt="You are a helpful assistant."
|
||||
)
|
||||
|
||||
# For non-Anthropic models, content should remain a string
|
||||
system_msg = messages[0]
|
||||
assert system_msg["role"] == "system"
|
||||
assert isinstance(system_msg["content"], str)
|
||||
|
||||
|
||||
class TestAgentPromptCachingParameter:
|
||||
"""Tests for prompt_caching parameter in Agent class."""
|
||||
|
||||
def test_agent_init_with_prompt_caching_true(self):
|
||||
"""Test Agent initialization with prompt_caching=True."""
|
||||
from praisonaiagents import Agent
|
||||
|
||||
agent = Agent(
|
||||
name="Test Agent",
|
||||
llm="anthropic/claude-3-5-sonnet-latest",
|
||||
prompt_caching=True
|
||||
)
|
||||
assert agent.prompt_caching == True
|
||||
|
||||
def test_agent_init_with_prompt_caching_false(self):
|
||||
"""Test Agent initialization with prompt_caching=False."""
|
||||
from praisonaiagents import Agent
|
||||
|
||||
agent = Agent(
|
||||
name="Test Agent",
|
||||
llm="anthropic/claude-3-5-sonnet-latest",
|
||||
prompt_caching=False
|
||||
)
|
||||
assert agent.prompt_caching == False
|
||||
|
||||
def test_agent_init_without_prompt_caching(self):
|
||||
"""Test Agent initialization without prompt_caching parameter."""
|
||||
from praisonaiagents import Agent
|
||||
|
||||
agent = Agent(name="Test Agent")
|
||||
assert agent.prompt_caching is None
|
||||
|
||||
def test_agent_model_supports_prompt_caching_method(self):
|
||||
"""Test _model_supports_prompt_caching method exists."""
|
||||
from praisonaiagents import Agent
|
||||
|
||||
agent = Agent(
|
||||
name="Test Agent",
|
||||
llm="anthropic/claude-3-5-sonnet-latest"
|
||||
)
|
||||
# Method should exist and return a boolean
|
||||
result = agent._model_supports_prompt_caching()
|
||||
assert isinstance(result, bool)
|
||||
|
||||
def test_agent_passes_prompt_caching_to_llm(self):
|
||||
"""Test that Agent passes prompt_caching to LLM instance."""
|
||||
from praisonaiagents import Agent
|
||||
|
||||
agent = Agent(
|
||||
name="Test Agent",
|
||||
llm="anthropic/claude-3-5-sonnet-latest",
|
||||
prompt_caching=True
|
||||
)
|
||||
|
||||
# Check that LLM instance has prompt_caching set
|
||||
if hasattr(agent, 'llm_instance') and agent.llm_instance:
|
||||
assert agent.llm_instance.prompt_caching == True
|
||||
|
||||
|
||||
class TestPromptCachingIntegration:
|
||||
"""Integration tests for prompt caching (requires API key)."""
|
||||
|
||||
@pytest.mark.skip(reason="Requires ANTHROPIC_API_KEY environment variable")
|
||||
def test_prompt_caching_with_anthropic(self):
|
||||
"""Test prompt caching 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="Caching Test Agent",
|
||||
instructions="You are a helpful assistant." * 100, # Long system prompt
|
||||
llm="anthropic/claude-3-5-sonnet-latest",
|
||||
prompt_caching=True,
|
||||
verbose=False
|
||||
)
|
||||
|
||||
# First call - should create cache
|
||||
result1 = agent.start("What is 2+2?")
|
||||
assert result1 is not None
|
||||
|
||||
# Second call - should use cache
|
||||
result2 = agent.start("What is 3+3?")
|
||||
assert result2 is not None
|
||||
@@ -0,0 +1,316 @@
|
||||
"""
|
||||
Tests for PromptExpanderAgent
|
||||
|
||||
Run with: python -m pytest tests/test_prompt_expander_agent.py -v
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
|
||||
class TestPromptExpanderImports:
|
||||
"""Test that all imports work correctly."""
|
||||
|
||||
def test_import_from_package(self):
|
||||
"""Test importing from main package."""
|
||||
from praisonaiagents import PromptExpanderAgent, ExpandStrategy, ExpandResult
|
||||
assert PromptExpanderAgent is not None
|
||||
assert ExpandStrategy is not None
|
||||
assert ExpandResult is not None
|
||||
|
||||
def test_import_from_agent_module(self):
|
||||
"""Test importing from agent module."""
|
||||
from praisonaiagents.agent import PromptExpanderAgent, ExpandStrategy, ExpandResult
|
||||
assert PromptExpanderAgent is not None
|
||||
assert ExpandStrategy is not None
|
||||
assert ExpandResult is not None
|
||||
|
||||
|
||||
class TestExpandStrategy:
|
||||
"""Test ExpandStrategy enum."""
|
||||
|
||||
def test_strategy_values(self):
|
||||
"""Test all strategy values exist."""
|
||||
from praisonaiagents import ExpandStrategy
|
||||
|
||||
assert ExpandStrategy.BASIC.value == "basic"
|
||||
assert ExpandStrategy.DETAILED.value == "detailed"
|
||||
assert ExpandStrategy.STRUCTURED.value == "structured"
|
||||
assert ExpandStrategy.CREATIVE.value == "creative"
|
||||
assert ExpandStrategy.AUTO.value == "auto"
|
||||
|
||||
|
||||
class TestExpandResult:
|
||||
"""Test ExpandResult dataclass."""
|
||||
|
||||
def test_result_creation(self):
|
||||
"""Test creating an ExpandResult."""
|
||||
from praisonaiagents import ExpandResult, ExpandStrategy
|
||||
|
||||
result = ExpandResult(
|
||||
original_prompt="write a movie script",
|
||||
expanded_prompt="Write a compelling movie script...",
|
||||
strategy_used=ExpandStrategy.BASIC
|
||||
)
|
||||
|
||||
assert result.original_prompt == "write a movie script"
|
||||
assert result.expanded_prompt == "Write a compelling movie script..."
|
||||
assert result.strategy_used == ExpandStrategy.BASIC
|
||||
|
||||
def test_result_with_metadata(self):
|
||||
"""Test ExpandResult with metadata."""
|
||||
from praisonaiagents import ExpandResult, ExpandStrategy
|
||||
|
||||
result = ExpandResult(
|
||||
original_prompt="test",
|
||||
expanded_prompt="expanded test",
|
||||
strategy_used=ExpandStrategy.DETAILED,
|
||||
metadata={"word_count": 50, "has_context": True}
|
||||
)
|
||||
|
||||
assert result.metadata["word_count"] == 50
|
||||
assert result.metadata["has_context"] is True
|
||||
|
||||
|
||||
class TestPromptExpanderAgentInit:
|
||||
"""Test PromptExpanderAgent initialization."""
|
||||
|
||||
def test_default_initialization(self):
|
||||
"""Test agent initializes with defaults."""
|
||||
from praisonaiagents import PromptExpanderAgent
|
||||
|
||||
agent = PromptExpanderAgent()
|
||||
|
||||
assert agent.name == "PromptExpanderAgent"
|
||||
assert agent.model == "gpt-4o-mini"
|
||||
assert not agent.verbose
|
||||
assert agent.temperature == 0.7 # Higher for creativity
|
||||
|
||||
def test_custom_initialization(self):
|
||||
"""Test agent initializes with custom values."""
|
||||
from praisonaiagents import PromptExpanderAgent
|
||||
|
||||
agent = PromptExpanderAgent(
|
||||
name="CustomExpander",
|
||||
model="gpt-4o",
|
||||
verbose=True,
|
||||
temperature=0.5
|
||||
)
|
||||
|
||||
assert agent.name == "CustomExpander"
|
||||
assert agent.model == "gpt-4o"
|
||||
assert agent.verbose
|
||||
assert agent.temperature == 0.5
|
||||
|
||||
def test_initialization_with_tools(self):
|
||||
"""Test agent initializes with tools."""
|
||||
from praisonaiagents import PromptExpanderAgent
|
||||
|
||||
def mock_tool(query: str) -> str:
|
||||
return "mock result"
|
||||
|
||||
agent = PromptExpanderAgent(tools=[mock_tool])
|
||||
|
||||
assert len(agent.tools) == 1
|
||||
|
||||
|
||||
class TestPromptExpansion:
|
||||
"""Test prompt expansion functionality."""
|
||||
|
||||
def test_expand_preserves_task_intent(self):
|
||||
"""Test that expansion preserves task intent (not converting to question)."""
|
||||
from praisonaiagents import PromptExpanderAgent, ExpandResult
|
||||
|
||||
agent = PromptExpanderAgent()
|
||||
|
||||
# Mock the internal agent call
|
||||
with patch.object(agent, '_call_agent') as mock_call:
|
||||
mock_call.return_value = "Write a compelling three-line movie script that captures a dramatic moment with vivid imagery and emotional depth."
|
||||
|
||||
result = agent.expand("write a movie script in 3 lines")
|
||||
|
||||
assert result.original_prompt == "write a movie script in 3 lines"
|
||||
assert "Write" in result.expanded_prompt # Should start with action verb
|
||||
assert "?" not in result.expanded_prompt # Should NOT be a question
|
||||
|
||||
def test_expand_adds_detail(self):
|
||||
"""Test that expansion adds meaningful detail."""
|
||||
from praisonaiagents import PromptExpanderAgent
|
||||
|
||||
agent = PromptExpanderAgent()
|
||||
|
||||
with patch.object(agent, '_call_agent') as mock_call:
|
||||
mock_call.return_value = "Create a detailed blog post about artificial intelligence trends in 2025, covering key developments in large language models, multimodal AI, and autonomous agents. Include specific examples and expert predictions."
|
||||
|
||||
result = agent.expand("write about AI")
|
||||
|
||||
# Expanded should be longer than original
|
||||
assert len(result.expanded_prompt) > len(result.original_prompt)
|
||||
|
||||
|
||||
class TestExpandStrategies:
|
||||
"""Test different expansion strategies."""
|
||||
|
||||
def test_basic_strategy(self):
|
||||
"""Test BASIC expansion strategy."""
|
||||
from praisonaiagents import PromptExpanderAgent, ExpandStrategy
|
||||
|
||||
agent = PromptExpanderAgent()
|
||||
|
||||
with patch.object(agent, '_call_agent') as mock_call:
|
||||
mock_call.return_value = "Write a movie script in three lines."
|
||||
|
||||
result = agent.expand("movie script 3 lines", strategy=ExpandStrategy.BASIC)
|
||||
|
||||
assert result.strategy_used == ExpandStrategy.BASIC
|
||||
mock_call.assert_called_once()
|
||||
|
||||
def test_detailed_strategy(self):
|
||||
"""Test DETAILED expansion strategy."""
|
||||
from praisonaiagents import PromptExpanderAgent, ExpandStrategy
|
||||
|
||||
agent = PromptExpanderAgent()
|
||||
|
||||
with patch.object(agent, '_call_agent') as mock_call:
|
||||
mock_call.return_value = """Write a compelling three-line movie script that:
|
||||
- Opens with a hook that immediately grabs attention
|
||||
- Builds tension or emotion in the middle line
|
||||
- Delivers a powerful, memorable conclusion
|
||||
Include vivid imagery, strong verbs, and emotional resonance."""
|
||||
|
||||
result = agent.expand("movie script 3 lines", strategy=ExpandStrategy.DETAILED)
|
||||
|
||||
assert result.strategy_used == ExpandStrategy.DETAILED
|
||||
|
||||
def test_structured_strategy(self):
|
||||
"""Test STRUCTURED expansion strategy."""
|
||||
from praisonaiagents import PromptExpanderAgent, ExpandStrategy
|
||||
|
||||
agent = PromptExpanderAgent()
|
||||
|
||||
with patch.object(agent, '_call_agent') as mock_call:
|
||||
mock_call.return_value = """Task: Write a three-line movie script
|
||||
Format: Three lines, each serving a narrative purpose
|
||||
Requirements:
|
||||
- Line 1: Setup/Hook
|
||||
- Line 2: Conflict/Development
|
||||
- Line 3: Resolution/Twist
|
||||
Style: Cinematic, impactful"""
|
||||
|
||||
result = agent.expand("movie script 3 lines", strategy=ExpandStrategy.STRUCTURED)
|
||||
|
||||
assert result.strategy_used == ExpandStrategy.STRUCTURED
|
||||
|
||||
def test_creative_strategy(self):
|
||||
"""Test CREATIVE expansion strategy."""
|
||||
from praisonaiagents import PromptExpanderAgent, ExpandStrategy
|
||||
|
||||
agent = PromptExpanderAgent()
|
||||
|
||||
with patch.object(agent, '_call_agent') as mock_call:
|
||||
mock_call.return_value = "Craft an unforgettable three-line movie script that transports the reader into a vivid world. Let each line breathe with cinematic tension - a hook that grabs, a twist that surprises, and a finale that lingers in the mind long after reading."
|
||||
|
||||
result = agent.expand("movie script 3 lines", strategy=ExpandStrategy.CREATIVE)
|
||||
|
||||
assert result.strategy_used == ExpandStrategy.CREATIVE
|
||||
|
||||
def test_auto_strategy_detection(self):
|
||||
"""Test AUTO strategy detects appropriate expansion type."""
|
||||
from praisonaiagents import PromptExpanderAgent, ExpandStrategy
|
||||
|
||||
agent = PromptExpanderAgent()
|
||||
|
||||
# Short prompts should get DETAILED expansion
|
||||
strategy = agent._detect_strategy("AI")
|
||||
assert strategy in [ExpandStrategy.BASIC, ExpandStrategy.DETAILED]
|
||||
|
||||
# Task prompts should preserve task nature
|
||||
strategy = agent._detect_strategy("write a poem about love")
|
||||
assert strategy is not None
|
||||
|
||||
|
||||
class TestToolsIntegration:
|
||||
"""Test tools integration with PromptExpanderAgent."""
|
||||
|
||||
def test_expand_with_tools(self):
|
||||
"""Test expansion with tools for context gathering."""
|
||||
from praisonaiagents import PromptExpanderAgent
|
||||
|
||||
def search_tool(query: str) -> str:
|
||||
return "Latest AI trends: LLMs, multimodal, agents"
|
||||
|
||||
agent = PromptExpanderAgent(tools=[search_tool])
|
||||
|
||||
assert len(agent.tools) == 1
|
||||
|
||||
def test_tools_passed_to_internal_agent(self):
|
||||
"""Test that tools are passed to the internal agent."""
|
||||
from praisonaiagents import PromptExpanderAgent
|
||||
|
||||
def mock_tool(query: str) -> str:
|
||||
return "result"
|
||||
|
||||
agent = PromptExpanderAgent(tools=[mock_tool])
|
||||
|
||||
# Access the lazy-initialized agent
|
||||
internal_agent = agent.agent
|
||||
|
||||
# The internal agent should have the tools
|
||||
assert internal_agent.tools is not None
|
||||
|
||||
|
||||
class TestConvenienceMethods:
|
||||
"""Test convenience methods."""
|
||||
|
||||
def test_convenience_methods_exist(self):
|
||||
"""Test all convenience methods exist."""
|
||||
from praisonaiagents import PromptExpanderAgent
|
||||
|
||||
agent = PromptExpanderAgent()
|
||||
|
||||
assert hasattr(agent, 'expand')
|
||||
assert hasattr(agent, 'expand_basic')
|
||||
assert hasattr(agent, 'expand_detailed')
|
||||
assert hasattr(agent, 'expand_structured')
|
||||
assert hasattr(agent, 'expand_creative')
|
||||
|
||||
|
||||
class TestEdgeCases:
|
||||
"""Test edge cases."""
|
||||
|
||||
def test_empty_prompt(self):
|
||||
"""Test handling of empty prompt."""
|
||||
from praisonaiagents import PromptExpanderAgent
|
||||
|
||||
agent = PromptExpanderAgent()
|
||||
|
||||
with patch.object(agent, '_call_agent') as mock_call:
|
||||
mock_call.return_value = ""
|
||||
|
||||
result = agent.expand("")
|
||||
|
||||
# Should return original if expansion fails
|
||||
assert result.expanded_prompt == "" or result.original_prompt == ""
|
||||
|
||||
def test_already_detailed_prompt(self):
|
||||
"""Test that already detailed prompts are minimally modified."""
|
||||
from praisonaiagents import PromptExpanderAgent
|
||||
|
||||
agent = PromptExpanderAgent()
|
||||
|
||||
detailed_prompt = """Write a comprehensive blog post about machine learning,
|
||||
covering supervised learning, unsupervised learning, and reinforcement learning.
|
||||
Include code examples in Python and real-world applications."""
|
||||
|
||||
with patch.object(agent, '_call_agent') as mock_call:
|
||||
# For detailed prompts, minimal expansion expected
|
||||
mock_call.return_value = detailed_prompt + " Ensure clarity and technical accuracy."
|
||||
|
||||
result = agent.expand(detailed_prompt)
|
||||
|
||||
# Should not drastically change already detailed prompts
|
||||
assert result.original_prompt == detailed_prompt
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -0,0 +1,284 @@
|
||||
"""
|
||||
Test suite for web_fetch feature.
|
||||
|
||||
This module tests the web fetch functionality that allows LLMs to retrieve
|
||||
full content from specific URLs (web pages and PDF documents).
|
||||
Currently only supported by Anthropic Claude models.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import os
|
||||
|
||||
|
||||
class TestSupportsWebFetch:
|
||||
"""Test the supports_web_fetch function in model_capabilities."""
|
||||
|
||||
def test_anthropic_claude_opus_4_1_supports_web_fetch(self):
|
||||
"""Test that Claude Opus 4.1 supports web fetch."""
|
||||
from praisonaiagents.llm.model_capabilities import supports_web_fetch
|
||||
assert supports_web_fetch("anthropic/claude-opus-4-1-20250805")
|
||||
assert supports_web_fetch("claude-opus-4-1")
|
||||
|
||||
def test_anthropic_claude_opus_4_supports_web_fetch(self):
|
||||
"""Test that Claude Opus 4 supports web fetch."""
|
||||
from praisonaiagents.llm.model_capabilities import supports_web_fetch
|
||||
assert supports_web_fetch("anthropic/claude-opus-4-20250514")
|
||||
assert supports_web_fetch("claude-opus-4")
|
||||
|
||||
def test_anthropic_claude_sonnet_4_supports_web_fetch(self):
|
||||
"""Test that Claude Sonnet 4 supports web fetch."""
|
||||
from praisonaiagents.llm.model_capabilities import supports_web_fetch
|
||||
assert supports_web_fetch("anthropic/claude-sonnet-4-20250514")
|
||||
assert supports_web_fetch("claude-sonnet-4")
|
||||
|
||||
def test_anthropic_claude_3_7_sonnet_supports_web_fetch(self):
|
||||
"""Test that Claude 3.7 Sonnet supports web fetch."""
|
||||
from praisonaiagents.llm.model_capabilities import supports_web_fetch
|
||||
assert supports_web_fetch("anthropic/claude-3-7-sonnet-20250219")
|
||||
assert supports_web_fetch("claude-3-7-sonnet-latest")
|
||||
|
||||
def test_anthropic_claude_3_5_sonnet_supports_web_fetch(self):
|
||||
"""Test that Claude 3.5 Sonnet supports web fetch."""
|
||||
from praisonaiagents.llm.model_capabilities import supports_web_fetch
|
||||
assert supports_web_fetch("anthropic/claude-3-5-sonnet-latest")
|
||||
assert supports_web_fetch("claude-3-5-sonnet-20241022")
|
||||
|
||||
def test_anthropic_claude_3_5_haiku_supports_web_fetch(self):
|
||||
"""Test that Claude 3.5 Haiku supports web fetch."""
|
||||
from praisonaiagents.llm.model_capabilities import supports_web_fetch
|
||||
assert supports_web_fetch("anthropic/claude-3-5-haiku-latest")
|
||||
assert supports_web_fetch("claude-3-5-haiku-20241022")
|
||||
|
||||
def test_claude_5_auto_supports_web_fetch(self):
|
||||
"""Test that Claude 5+ models auto-support web fetch."""
|
||||
from praisonaiagents.llm.model_capabilities import supports_web_fetch
|
||||
assert supports_web_fetch("claude-5")
|
||||
assert supports_web_fetch("claude-sonnet-5")
|
||||
assert supports_web_fetch("claude-opus-5")
|
||||
|
||||
def test_openai_models_do_not_support_web_fetch(self):
|
||||
"""Test that OpenAI models do not support web fetch."""
|
||||
from praisonaiagents.llm.model_capabilities import supports_web_fetch
|
||||
assert not supports_web_fetch("openai/gpt-4o")
|
||||
assert not supports_web_fetch("gpt-4o-mini")
|
||||
assert not supports_web_fetch("gpt-4o-search-preview")
|
||||
|
||||
def test_gemini_models_do_not_support_web_fetch(self):
|
||||
"""Test that Gemini models do not support web fetch."""
|
||||
from praisonaiagents.llm.model_capabilities import supports_web_fetch
|
||||
assert not supports_web_fetch("gemini/gemini-2.0-flash")
|
||||
assert not supports_web_fetch("gemini-2.5-pro")
|
||||
|
||||
def test_unknown_models_do_not_support_web_fetch(self):
|
||||
"""Test that unknown models do not support web fetch."""
|
||||
from praisonaiagents.llm.model_capabilities import supports_web_fetch
|
||||
assert not supports_web_fetch("ollama/llama3")
|
||||
assert not supports_web_fetch("unknown/model")
|
||||
|
||||
|
||||
class TestLLMWebFetchParameter:
|
||||
"""Test the web_fetch parameter in LLM class."""
|
||||
|
||||
def test_llm_init_with_web_fetch_true(self):
|
||||
"""Test LLM initialization with web_fetch=True."""
|
||||
from praisonaiagents.llm.llm import LLM
|
||||
llm = LLM(model="anthropic/claude-3-5-sonnet-latest", web_fetch=True)
|
||||
assert llm.web_fetch is True
|
||||
|
||||
def test_llm_init_with_web_fetch_dict(self):
|
||||
"""Test LLM initialization with web_fetch as dict."""
|
||||
from praisonaiagents.llm.llm import LLM
|
||||
web_fetch_config = {
|
||||
"max_uses": 10,
|
||||
"allowed_domains": ["example.com"],
|
||||
"citations": {"enabled": True}
|
||||
}
|
||||
llm = LLM(model="anthropic/claude-3-5-sonnet-latest", web_fetch=web_fetch_config)
|
||||
assert llm.web_fetch == web_fetch_config
|
||||
|
||||
def test_llm_init_without_web_fetch(self):
|
||||
"""Test LLM initialization without web_fetch."""
|
||||
from praisonaiagents.llm.llm import LLM
|
||||
llm = LLM(model="gpt-4o-mini")
|
||||
assert llm.web_fetch is None
|
||||
|
||||
def test_llm_supports_web_fetch_method(self):
|
||||
"""Test LLM._supports_web_fetch() method."""
|
||||
from praisonaiagents.llm.llm import LLM
|
||||
|
||||
# Claude model should support web fetch
|
||||
llm_claude = LLM(model="anthropic/claude-3-5-sonnet-latest")
|
||||
assert llm_claude._supports_web_fetch()
|
||||
|
||||
# OpenAI model should not support web fetch
|
||||
llm_openai = LLM(model="gpt-4o-mini")
|
||||
assert not llm_openai._supports_web_fetch()
|
||||
|
||||
|
||||
class TestLLMBuildCompletionParamsWebFetch:
|
||||
"""Test _build_completion_params with web_fetch."""
|
||||
|
||||
def test_build_params_with_web_fetch_true(self):
|
||||
"""Test that web_fetch=True adds web_fetch tool to params."""
|
||||
from praisonaiagents.llm.llm import LLM
|
||||
llm = LLM(model="anthropic/claude-3-5-sonnet-latest", web_fetch=True)
|
||||
params = llm._build_completion_params()
|
||||
|
||||
assert 'tools' in params
|
||||
# Find the web_fetch tool
|
||||
web_fetch_tool = None
|
||||
for tool in params['tools']:
|
||||
if isinstance(tool, dict) and tool.get('type') == 'web_fetch_20250910':
|
||||
web_fetch_tool = tool
|
||||
break
|
||||
|
||||
assert web_fetch_tool is not None
|
||||
assert web_fetch_tool['name'] == 'web_fetch'
|
||||
assert web_fetch_tool['max_uses'] == 5 # default
|
||||
|
||||
def test_build_params_with_web_fetch_dict(self):
|
||||
"""Test that web_fetch dict adds customized web_fetch tool."""
|
||||
from praisonaiagents.llm.llm import LLM
|
||||
web_fetch_config = {
|
||||
"max_uses": 10,
|
||||
"allowed_domains": ["example.com", "docs.example.com"],
|
||||
"citations": {"enabled": True},
|
||||
"max_content_tokens": 50000
|
||||
}
|
||||
llm = LLM(model="anthropic/claude-3-5-sonnet-latest", web_fetch=web_fetch_config)
|
||||
params = llm._build_completion_params()
|
||||
|
||||
assert 'tools' in params
|
||||
# Find the web_fetch tool
|
||||
web_fetch_tool = None
|
||||
for tool in params['tools']:
|
||||
if isinstance(tool, dict) and tool.get('type') == 'web_fetch_20250910':
|
||||
web_fetch_tool = tool
|
||||
break
|
||||
|
||||
assert web_fetch_tool is not None
|
||||
assert web_fetch_tool['max_uses'] == 10
|
||||
assert web_fetch_tool['allowed_domains'] == ["example.com", "docs.example.com"]
|
||||
assert web_fetch_tool['citations'] == {"enabled": True}
|
||||
assert web_fetch_tool['max_content_tokens'] == 50000
|
||||
|
||||
def test_build_params_web_fetch_unsupported_model(self):
|
||||
"""Test that web_fetch is ignored for unsupported models."""
|
||||
from praisonaiagents.llm.llm import LLM
|
||||
llm = LLM(model="gpt-4o-mini", web_fetch=True)
|
||||
params = llm._build_completion_params()
|
||||
|
||||
# Should not have web_fetch tool
|
||||
if 'tools' in params:
|
||||
for tool in params['tools']:
|
||||
if isinstance(tool, dict):
|
||||
assert tool.get('type') != 'web_fetch_20250910'
|
||||
|
||||
|
||||
class TestAgentWebFetchParameter:
|
||||
"""Test the web_fetch parameter in Agent class."""
|
||||
|
||||
def test_agent_init_with_web_fetch_true(self):
|
||||
"""Test Agent initialization with web_fetch=True."""
|
||||
from praisonaiagents.agent.agent import Agent
|
||||
agent = Agent(
|
||||
name="Test Agent",
|
||||
llm="anthropic/claude-3-5-sonnet-latest",
|
||||
web_fetch=True
|
||||
)
|
||||
assert agent.web_fetch is True
|
||||
|
||||
def test_agent_init_with_web_fetch_dict(self):
|
||||
"""Test Agent initialization with web_fetch as dict."""
|
||||
from praisonaiagents.agent.agent import Agent
|
||||
web_fetch_config = {"max_uses": 10, "allowed_domains": ["example.com"]}
|
||||
agent = Agent(
|
||||
name="Test Agent",
|
||||
llm="anthropic/claude-3-5-sonnet-latest",
|
||||
web_fetch=web_fetch_config
|
||||
)
|
||||
assert agent.web_fetch == web_fetch_config
|
||||
|
||||
def test_agent_init_without_web_fetch(self):
|
||||
"""Test Agent initialization without web_fetch."""
|
||||
from praisonaiagents.agent.agent import Agent
|
||||
agent = Agent(name="Test Agent", llm="gpt-4o-mini")
|
||||
assert agent.web_fetch is None
|
||||
|
||||
def test_agent_passes_web_fetch_to_llm(self):
|
||||
"""Test that Agent passes web_fetch to LLM instance."""
|
||||
from praisonaiagents.agent.agent import Agent
|
||||
agent = Agent(
|
||||
name="Test Agent",
|
||||
llm="anthropic/claude-3-5-sonnet-latest",
|
||||
web_fetch=True
|
||||
)
|
||||
assert agent.llm_instance.web_fetch is True
|
||||
|
||||
def test_agent_model_supports_web_fetch_method(self):
|
||||
"""Test Agent._model_supports_web_fetch() method."""
|
||||
from praisonaiagents.agent.agent import Agent
|
||||
|
||||
# Claude model should support web fetch
|
||||
agent_claude = Agent(
|
||||
name="Claude Agent",
|
||||
llm="anthropic/claude-3-5-sonnet-latest"
|
||||
)
|
||||
assert agent_claude._model_supports_web_fetch()
|
||||
|
||||
# OpenAI model should not support web fetch
|
||||
agent_openai = Agent(
|
||||
name="OpenAI Agent",
|
||||
llm="gpt-4o-mini"
|
||||
)
|
||||
assert not agent_openai._model_supports_web_fetch()
|
||||
|
||||
|
||||
class TestWebFetchWithWebSearch:
|
||||
"""Test that web_fetch and web_search can be used together."""
|
||||
|
||||
def test_both_web_fetch_and_web_search(self):
|
||||
"""Test using both web_fetch and web_search on supported model."""
|
||||
from praisonaiagents.llm.llm import LLM
|
||||
llm = LLM(
|
||||
model="anthropic/claude-3-5-sonnet-latest",
|
||||
web_search=True,
|
||||
web_fetch=True
|
||||
)
|
||||
params = llm._build_completion_params()
|
||||
|
||||
# Should have web_search_options
|
||||
assert 'web_search_options' in params
|
||||
|
||||
# Should also have web_fetch tool
|
||||
assert 'tools' in params
|
||||
web_fetch_found = False
|
||||
for tool in params['tools']:
|
||||
if isinstance(tool, dict) and tool.get('type') == 'web_fetch_20250910':
|
||||
web_fetch_found = True
|
||||
break
|
||||
assert web_fetch_found
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not os.getenv('ANTHROPIC_API_KEY'),
|
||||
reason="ANTHROPIC_API_KEY not set"
|
||||
)
|
||||
class TestWebFetchIntegration:
|
||||
"""Integration tests for web_fetch (requires API key)."""
|
||||
|
||||
def test_web_fetch_with_anthropic(self):
|
||||
"""Test actual web fetch with Anthropic API."""
|
||||
from praisonaiagents.agent.agent import Agent
|
||||
|
||||
agent = Agent(
|
||||
name="Web Fetch Agent",
|
||||
instructions="You are a helpful assistant that can fetch web content.",
|
||||
llm="anthropic/claude-3-5-sonnet-latest",
|
||||
web_fetch=True
|
||||
)
|
||||
|
||||
# This would make an actual API call
|
||||
# response = agent.chat("Fetch and summarize https://example.com")
|
||||
# assert response is not None
|
||||
pass # Skip actual API call in tests
|
||||
@@ -0,0 +1,255 @@
|
||||
"""
|
||||
Test file for web_search feature (TDD approach).
|
||||
|
||||
Tests the LiteLLM native web search integration with fallback to DuckDuckGo.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import os
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
class TestWebSearchSupport:
|
||||
"""Test web search capability detection."""
|
||||
|
||||
def test_supports_web_search_openai_search_preview(self):
|
||||
"""Test that gpt-4o-search-preview is detected as supporting web search."""
|
||||
from praisonaiagents.llm.model_capabilities import supports_web_search
|
||||
assert supports_web_search("openai/gpt-4o-search-preview") == True
|
||||
|
||||
def test_supports_web_search_xai_grok(self):
|
||||
"""Test that xai/grok-3 is detected as supporting web search."""
|
||||
from praisonaiagents.llm.model_capabilities import supports_web_search
|
||||
assert supports_web_search("xai/grok-3") == True
|
||||
|
||||
def test_supports_web_search_anthropic_claude(self):
|
||||
"""Test that claude-3-5-sonnet-latest is detected as supporting web search."""
|
||||
from praisonaiagents.llm.model_capabilities import supports_web_search
|
||||
assert supports_web_search("anthropic/claude-3-5-sonnet-latest") == True
|
||||
|
||||
def test_supports_web_search_gemini(self):
|
||||
"""Test that gemini-2.0-flash is detected as supporting web search."""
|
||||
from praisonaiagents.llm.model_capabilities import supports_web_search
|
||||
assert supports_web_search("gemini-2.0-flash") == True
|
||||
|
||||
def test_supports_web_search_ollama_false(self):
|
||||
"""Test that ollama models don't support native web search."""
|
||||
from praisonaiagents.llm.model_capabilities import supports_web_search
|
||||
assert supports_web_search("ollama/llama3") == False
|
||||
|
||||
def test_supports_web_search_unknown_model(self):
|
||||
"""Test that unknown models default to False."""
|
||||
from praisonaiagents.llm.model_capabilities import supports_web_search
|
||||
assert supports_web_search("unknown/model") == False
|
||||
|
||||
|
||||
class TestLLMWebSearchParameter:
|
||||
"""Test LLM class web_search parameter."""
|
||||
|
||||
def test_llm_init_with_web_search_true(self):
|
||||
"""Test LLM initialization with web_search=True."""
|
||||
from praisonaiagents.llm.llm import LLM
|
||||
llm = LLM(model="gpt-4o-mini", web_search=True)
|
||||
assert llm.web_search == True
|
||||
|
||||
def test_llm_init_with_web_search_dict(self):
|
||||
"""Test LLM initialization with web_search as dict."""
|
||||
from praisonaiagents.llm.llm import LLM
|
||||
web_search_options = {"search_context_size": "high"}
|
||||
llm = LLM(model="gpt-4o-mini", web_search=web_search_options)
|
||||
assert llm.web_search == web_search_options
|
||||
|
||||
def test_llm_init_with_web_search_false(self):
|
||||
"""Test LLM initialization with web_search=False."""
|
||||
from praisonaiagents.llm.llm import LLM
|
||||
llm = LLM(model="gpt-4o-mini", web_search=False)
|
||||
assert llm.web_search == False
|
||||
|
||||
def test_llm_init_without_web_search(self):
|
||||
"""Test LLM initialization without web_search (default None)."""
|
||||
from praisonaiagents.llm.llm import LLM
|
||||
llm = LLM(model="gpt-4o-mini")
|
||||
assert llm.web_search is None
|
||||
|
||||
def test_llm_supports_web_search_method(self):
|
||||
"""Test LLM._supports_web_search() method."""
|
||||
from praisonaiagents.llm.llm import LLM
|
||||
llm = LLM(model="openai/gpt-4o-search-preview")
|
||||
assert llm._supports_web_search() == True
|
||||
|
||||
def test_llm_supports_web_search_method_ollama(self):
|
||||
"""Test LLM._supports_web_search() returns False for Ollama."""
|
||||
from praisonaiagents.llm.llm import LLM
|
||||
llm = LLM(model="ollama/llama3")
|
||||
assert llm._supports_web_search() == False
|
||||
|
||||
|
||||
class TestLLMBuildCompletionParams:
|
||||
"""Test _build_completion_params includes web_search_options."""
|
||||
|
||||
def test_build_params_with_web_search_true(self):
|
||||
"""Test that web_search=True adds web_search_options to params."""
|
||||
from praisonaiagents.llm.llm import LLM
|
||||
llm = LLM(model="openai/gpt-4o-search-preview", web_search=True)
|
||||
|
||||
with patch.object(llm, '_supports_web_search', return_value=True):
|
||||
params = llm._build_completion_params(messages=[{"role": "user", "content": "test"}])
|
||||
assert "web_search_options" in params
|
||||
assert params["web_search_options"] == {"search_context_size": "medium"}
|
||||
|
||||
def test_build_params_with_web_search_dict(self):
|
||||
"""Test that web_search dict is passed correctly."""
|
||||
from praisonaiagents.llm.llm import LLM
|
||||
web_search_options = {"search_context_size": "high"}
|
||||
llm = LLM(model="openai/gpt-4o-search-preview", web_search=web_search_options)
|
||||
|
||||
with patch.object(llm, '_supports_web_search', return_value=True):
|
||||
params = llm._build_completion_params(messages=[{"role": "user", "content": "test"}])
|
||||
assert "web_search_options" in params
|
||||
assert params["web_search_options"] == {"search_context_size": "high"}
|
||||
|
||||
def test_build_params_without_web_search(self):
|
||||
"""Test that web_search_options is not added when web_search is None."""
|
||||
from praisonaiagents.llm.llm import LLM
|
||||
llm = LLM(model="gpt-4o-mini")
|
||||
|
||||
params = llm._build_completion_params(messages=[{"role": "user", "content": "test"}])
|
||||
assert "web_search_options" not in params
|
||||
|
||||
def test_build_params_web_search_unsupported_model(self):
|
||||
"""Test that web_search_options is not added for unsupported models."""
|
||||
from praisonaiagents.llm.llm import LLM
|
||||
llm = LLM(model="ollama/llama3", web_search=True)
|
||||
|
||||
with patch.object(llm, '_supports_web_search', return_value=False):
|
||||
params = llm._build_completion_params(messages=[{"role": "user", "content": "test"}])
|
||||
assert "web_search_options" not in params
|
||||
|
||||
|
||||
class TestAgentWebSearchParameter:
|
||||
"""Test Agent class web_search parameter."""
|
||||
|
||||
def test_agent_init_with_web_search_true(self):
|
||||
"""Test Agent initialization with web_search=True."""
|
||||
from praisonaiagents import Agent
|
||||
agent = Agent(
|
||||
name="Test Agent",
|
||||
instructions="Test",
|
||||
web_search=True
|
||||
)
|
||||
assert agent.web_search == True
|
||||
|
||||
def test_agent_init_with_web_search_dict(self):
|
||||
"""Test Agent initialization with web_search as dict."""
|
||||
from praisonaiagents import Agent
|
||||
web_search_options = {"search_context_size": "high"}
|
||||
agent = Agent(
|
||||
name="Test Agent",
|
||||
instructions="Test",
|
||||
web_search=web_search_options
|
||||
)
|
||||
assert agent.web_search == web_search_options
|
||||
|
||||
def test_agent_init_without_web_search(self):
|
||||
"""Test Agent initialization without web_search (default None)."""
|
||||
from praisonaiagents import Agent
|
||||
agent = Agent(
|
||||
name="Test Agent",
|
||||
instructions="Test"
|
||||
)
|
||||
assert agent.web_search is None
|
||||
|
||||
def test_agent_with_custom_llm_passes_web_search(self):
|
||||
"""Test that Agent passes web_search to custom LLM instance."""
|
||||
from praisonaiagents import Agent
|
||||
agent = Agent(
|
||||
name="Test Agent",
|
||||
instructions="Test",
|
||||
llm="openai/gpt-4o-search-preview",
|
||||
web_search=True
|
||||
)
|
||||
# When using custom LLM (with /), it creates llm_instance
|
||||
assert hasattr(agent, 'llm_instance')
|
||||
assert agent.llm_instance.web_search == True
|
||||
|
||||
|
||||
class TestWebSearchFallback:
|
||||
"""Test fallback to DuckDuckGo when native web search not supported."""
|
||||
|
||||
def test_fallback_tool_injection_for_unsupported_model(self):
|
||||
"""Test that DuckDuckGo tool is injected for unsupported models."""
|
||||
from praisonaiagents import Agent
|
||||
agent = Agent(
|
||||
name="Test Agent",
|
||||
instructions="Test",
|
||||
llm="ollama/llama3",
|
||||
web_search=True
|
||||
)
|
||||
# Check that internet_search tool was added to tools
|
||||
tool_names = [getattr(t, '__name__', str(t)) for t in agent.tools]
|
||||
assert 'internet_search' in tool_names or 'duckduckgo' in tool_names
|
||||
|
||||
def test_no_fallback_for_supported_model(self):
|
||||
"""Test that no fallback tool is added for supported models."""
|
||||
from praisonaiagents import Agent
|
||||
agent = Agent(
|
||||
name="Test Agent",
|
||||
instructions="Test",
|
||||
llm="openai/gpt-4o-search-preview",
|
||||
web_search=True,
|
||||
tools=[] # Explicitly empty tools
|
||||
)
|
||||
# For supported models, tools should remain empty (native web search used)
|
||||
# Only check if no DuckDuckGo tool was auto-added
|
||||
tool_names = [getattr(t, '__name__', str(t)) for t in agent.tools]
|
||||
# internet_search should NOT be auto-added for supported models
|
||||
assert 'internet_search' not in tool_names
|
||||
|
||||
|
||||
class TestDuckDuckGoToolPreservation:
|
||||
"""Test that DuckDuckGo tools still work as before."""
|
||||
|
||||
def test_duckduckgo_import(self):
|
||||
"""Test that DuckDuckGo tools can still be imported."""
|
||||
from praisonaiagents.tools import internet_search, duckduckgo
|
||||
assert callable(internet_search)
|
||||
assert callable(duckduckgo)
|
||||
|
||||
def test_explicit_duckduckgo_tool_usage(self):
|
||||
"""Test that explicit DuckDuckGo tool usage still works."""
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.tools import internet_search
|
||||
|
||||
agent = Agent(
|
||||
name="Test Agent",
|
||||
instructions="Test",
|
||||
tools=[internet_search]
|
||||
)
|
||||
assert internet_search in agent.tools
|
||||
|
||||
|
||||
class TestWebSearchIntegration:
|
||||
"""Integration tests for web search (requires API keys)."""
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not os.getenv("OPENAI_API_KEY"),
|
||||
reason="OPENAI_API_KEY not set"
|
||||
)
|
||||
def test_web_search_with_openai(self):
|
||||
"""Test actual web search with OpenAI (integration test)."""
|
||||
from praisonaiagents import Agent
|
||||
|
||||
agent = Agent(
|
||||
name="Researcher",
|
||||
instructions="You are a helpful research assistant.",
|
||||
llm="openai/gpt-4o-search-preview",
|
||||
web_search=True
|
||||
)
|
||||
|
||||
result = agent.start("What is the current weather in San Francisco?")
|
||||
assert result is not None
|
||||
assert len(result) > 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
Generated
+1
-1
@@ -2422,7 +2422,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "praisonaiagents"
|
||||
version = "0.0.169"
|
||||
version = "0.0.184"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
|
||||
+1065
-21
File diff suppressed because it is too large.
Load diff
@@ -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.2.98.tar.gz"
|
||||
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v2.2.98.tar.gz | shasum -a 256`.split.first
|
||||
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v2.3.6.tar.gz"
|
||||
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v2.3.6.tar.gz | shasum -a 256`.split.first
|
||||
license "MIT"
|
||||
|
||||
depends_on "[email protected]"
|
||||
|
||||
@@ -579,9 +579,14 @@ class PraisonAI:
|
||||
parser.add_argument("--research", action="store_true", help="Run deep research on a topic")
|
||||
parser.add_argument("--query-rewrite", action="store_true", help="Rewrite query for better results (works with any command)")
|
||||
parser.add_argument("--rewrite-tools", type=str, help="Tools for query rewriter (e.g., 'internet_search' or path to tools.py)")
|
||||
parser.add_argument("--expand-prompt", action="store_true", help="Expand short prompt into detailed prompt (works with any command)")
|
||||
parser.add_argument("--expand-tools", type=str, help="Tools for prompt expander (e.g., 'internet_search' or path to tools.py)")
|
||||
parser.add_argument("--tools", "-t", type=str, help="Path to tools.py file for research agent")
|
||||
parser.add_argument("--save", "-s", action="store_true", help="Save research output to file (output/research/)")
|
||||
parser.add_argument("--verbose", "-v", action="store_true", help="Enable verbose output for research")
|
||||
parser.add_argument("--web-search", action="store_true", help="Enable native web search (OpenAI, Gemini, Anthropic, xAI, Perplexity)")
|
||||
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)")
|
||||
|
||||
# If we're in a test environment, parse with empty args to avoid pytest interference
|
||||
if in_test_env:
|
||||
@@ -839,12 +844,135 @@ class PraisonAI:
|
||||
verbose = getattr(self.args, 'verbose', False)
|
||||
return self._rewrite_query(query, rewrite_tools, verbose)
|
||||
|
||||
def _expand_prompt(self, prompt: str, expand_tools: str = None, verbose: bool = False) -> str:
|
||||
"""
|
||||
Expand prompt using PromptExpanderAgent.
|
||||
|
||||
Args:
|
||||
prompt: The prompt to expand
|
||||
expand_tools: Tool names (comma-separated) or path to tools.py
|
||||
verbose: Enable verbose output
|
||||
|
||||
Returns:
|
||||
Expanded prompt or original if expansion fails
|
||||
"""
|
||||
try:
|
||||
from praisonaiagents import PromptExpanderAgent, ExpandStrategy
|
||||
from rich import print
|
||||
|
||||
print("[bold cyan]Expanding prompt for detailed execution...[/bold cyan]")
|
||||
|
||||
# Load expand tools if specified
|
||||
expand_tools_list = []
|
||||
if expand_tools:
|
||||
if os.path.isfile(expand_tools):
|
||||
# Load from file
|
||||
try:
|
||||
import inspect
|
||||
import importlib.util
|
||||
spec = importlib.util.spec_from_file_location("expand_tools_module", expand_tools)
|
||||
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('_'):
|
||||
expand_tools_list.append(obj)
|
||||
if expand_tools_list:
|
||||
print(f"[cyan]Loaded {len(expand_tools_list)} tools for prompt expander[/cyan]")
|
||||
except Exception as e:
|
||||
print(f"[yellow]Warning: Failed to load expand tools: {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 expand_tools.split(',')]
|
||||
for tool_name in tool_names:
|
||||
if tool_name in TOOL_MAPPINGS:
|
||||
try:
|
||||
tool = getattr(tools_module, tool_name)
|
||||
expand_tools_list.append(tool)
|
||||
except Exception as e:
|
||||
print(f"[yellow]Warning: Failed to load expand tool '{tool_name}': {e}[/yellow]")
|
||||
else:
|
||||
print(f"[yellow]Warning: Unknown expand tool '{tool_name}'[/yellow]")
|
||||
if expand_tools_list:
|
||||
print(f"[cyan]Using expand tools: {', '.join(tool_names)}[/cyan]")
|
||||
except ImportError:
|
||||
print("[yellow]Warning: Could not import tools module[/yellow]")
|
||||
|
||||
expander = PromptExpanderAgent(
|
||||
model="gpt-4o-mini",
|
||||
verbose=verbose,
|
||||
tools=expand_tools_list if expand_tools_list else None
|
||||
)
|
||||
result = expander.expand(prompt, strategy=ExpandStrategy.AUTO)
|
||||
expanded = result.expanded_prompt
|
||||
|
||||
print(f"[cyan]Original:[/cyan] {prompt}")
|
||||
print(f"[cyan]Expanded:[/cyan] {expanded}")
|
||||
|
||||
return expanded
|
||||
|
||||
except ImportError:
|
||||
from rich import print
|
||||
print("[yellow]Warning: PromptExpanderAgent not available, using original prompt[/yellow]")
|
||||
return prompt
|
||||
except Exception as e:
|
||||
from rich import print
|
||||
print(f"[yellow]Warning: Prompt expansion failed ({e}), using original prompt[/yellow]")
|
||||
return prompt
|
||||
|
||||
def _expand_prompt_if_enabled(self, prompt: str) -> str:
|
||||
"""
|
||||
Expand prompt using PromptExpanderAgent if --expand-prompt is enabled.
|
||||
Returns the expanded prompt or original if expansion is disabled/fails.
|
||||
"""
|
||||
if not hasattr(self, 'args') or not getattr(self.args, 'expand_prompt', False):
|
||||
return prompt
|
||||
|
||||
expand_tools = getattr(self.args, 'expand_tools', None)
|
||||
verbose = getattr(self.args, 'verbose', False)
|
||||
return self._expand_prompt(prompt, expand_tools, verbose)
|
||||
|
||||
def _save_output(self, prompt: str, result: str):
|
||||
"""
|
||||
Save output to output/prompts/ folder.
|
||||
|
||||
Args:
|
||||
prompt: The original prompt
|
||||
result: The output result to save
|
||||
"""
|
||||
from datetime import datetime
|
||||
from rich import print
|
||||
|
||||
# Create output directory
|
||||
output_dir = os.path.join(os.getcwd(), "output", "prompts")
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
|
||||
# Generate filename with timestamp
|
||||
timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
|
||||
# Create a safe filename from prompt (first 30 chars)
|
||||
safe_prompt = "".join(c if c.isalnum() or c in " -_" else "" for c in prompt[:30]).strip().replace(" ", "_")
|
||||
filename = f"{timestamp}_{safe_prompt}.md"
|
||||
filepath = os.path.join(output_dir, filename)
|
||||
|
||||
# Write output
|
||||
with open(filepath, "w", encoding="utf-8") as f:
|
||||
f.write(f"# Prompt\n\n{prompt}\n\n")
|
||||
f.write(f"# Output\n\n{result}\n")
|
||||
|
||||
print(f"[green]✅ Output saved to: {filepath}[/green]")
|
||||
|
||||
def handle_direct_prompt(self, prompt):
|
||||
"""
|
||||
Handle direct prompt by creating a single agent and running it.
|
||||
"""
|
||||
# Apply query rewriting if enabled
|
||||
prompt = self._rewrite_query_if_enabled(prompt)
|
||||
# Apply prompt expansion if enabled
|
||||
prompt = self._expand_prompt_if_enabled(prompt)
|
||||
|
||||
if PRAISONAI_AVAILABLE:
|
||||
agent_config = {
|
||||
@@ -858,8 +986,22 @@ class PraisonAI:
|
||||
if hasattr(self, 'args') and self.args.llm:
|
||||
agent_config["llm"] = self.args.llm
|
||||
|
||||
# Add feature flags if enabled
|
||||
if hasattr(self, 'args'):
|
||||
if getattr(self.args, 'web_search', False):
|
||||
agent_config["web_search"] = True
|
||||
if getattr(self.args, 'web_fetch', False):
|
||||
agent_config["web_fetch"] = True
|
||||
if getattr(self.args, 'prompt_caching', False):
|
||||
agent_config["prompt_caching"] = True
|
||||
|
||||
agent = PraisonAgent(**agent_config)
|
||||
result = agent.start(prompt)
|
||||
|
||||
# Save output if --save is enabled
|
||||
if hasattr(self, 'args') and getattr(self.args, 'save', False):
|
||||
self._save_output(prompt, result)
|
||||
|
||||
return result
|
||||
elif CREWAI_AVAILABLE:
|
||||
from crewai import Agent, Task, Crew
|
||||
|
||||
@@ -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.2.98 gunicorn markdown\n")
|
||||
file.write("RUN pip install flask praisonai==2.3.6 gunicorn markdown\n")
|
||||
file.write("EXPOSE 8080\n")
|
||||
file.write('CMD ["gunicorn", "-b", "0.0.0.0:8080", "api:app"]\n')
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "2.2.98"
|
||||
__version__ = "2.3.6"
|
||||
@@ -12,7 +12,7 @@ dependencies = [
|
||||
"rich>=13.7",
|
||||
"markdown>=3.5",
|
||||
"pyparsing>=3.0.0",
|
||||
"praisonaiagents>=0.0.169",
|
||||
"praisonaiagents>=0.0.184",
|
||||
"python-dotenv>=0.19.0",
|
||||
"instructor>=1.3.3",
|
||||
"PyYAML>=6.0",
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Combined bump version and release script for PraisonAI package.
|
||||
|
||||
Usage:
|
||||
python src/praisonai/scripts/bump_and_release.py 2.2.99
|
||||
|
||||
Or with optional praisonaiagents version:
|
||||
python src/praisonai/scripts/bump_and_release.py 2.2.99 --agents 0.0.169
|
||||
|
||||
This script:
|
||||
1. Bumps version in all required files
|
||||
2. Copies root README.md to package dir
|
||||
3. Runs uv lock & uv build
|
||||
4. Commits changes
|
||||
5. Creates git tag
|
||||
6. Pushes to GitHub
|
||||
7. Creates GitHub release (latest)
|
||||
"""
|
||||
|
||||
import re
|
||||
import sys
|
||||
import argparse
|
||||
import subprocess
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def get_project_root() -> Path:
|
||||
"""Get the project root directory (praisonai-package)."""
|
||||
return Path(__file__).parent.parent.parent.parent
|
||||
|
||||
|
||||
def get_praisonai_dir() -> Path:
|
||||
"""Get the praisonai package directory."""
|
||||
return get_project_root() / "src/praisonai"
|
||||
|
||||
|
||||
def run(cmd: list[str], cwd: Path | None = None, check: bool = True) -> subprocess.CompletedProcess:
|
||||
"""Run a command and print it."""
|
||||
print(f"$ {' '.join(cmd)}")
|
||||
result = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True)
|
||||
if result.stdout:
|
||||
print(result.stdout)
|
||||
if result.stderr:
|
||||
print(result.stderr, file=sys.stderr)
|
||||
if check and result.returncode != 0:
|
||||
print(f"❌ Command failed with exit code {result.returncode}")
|
||||
sys.exit(1)
|
||||
return result
|
||||
|
||||
|
||||
def update_file(filepath: Path, patterns: list[tuple[str, str]], root: Path) -> bool:
|
||||
"""Update a file with the given regex patterns and replacements."""
|
||||
if not filepath.exists():
|
||||
print(f" ⚠️ File not found: {filepath}")
|
||||
return False
|
||||
|
||||
content = filepath.read_text()
|
||||
original = content
|
||||
|
||||
for pattern, replacement in patterns:
|
||||
content = re.sub(pattern, replacement, content)
|
||||
|
||||
if content != original:
|
||||
filepath.write_text(content)
|
||||
print(f" ✅ Updated: {filepath.relative_to(root)}")
|
||||
return True
|
||||
else:
|
||||
print(f" ⏭️ No changes: {filepath.relative_to(root)}")
|
||||
return False
|
||||
|
||||
|
||||
def bump_version(new_version: str, agents_version: str | None = None):
|
||||
"""Bump version in all required files."""
|
||||
root = get_project_root()
|
||||
praisonai_dir = get_praisonai_dir()
|
||||
|
||||
print(f"\n🚀 Bumping PraisonAI version to {new_version}\n")
|
||||
|
||||
# 1. Update version.py (single source of truth)
|
||||
print("📦 Python Package:")
|
||||
update_file(
|
||||
praisonai_dir / "praisonai/version.py",
|
||||
[(r'__version__ = "[^"]+"', f'__version__ = "{new_version}"')],
|
||||
root
|
||||
)
|
||||
|
||||
# 2. Update deploy.py (Dockerfile template)
|
||||
print("\n🐳 Deploy Script:")
|
||||
update_file(
|
||||
praisonai_dir / "praisonai/deploy.py",
|
||||
[(r'praisonai==[0-9.]+', f'praisonai=={new_version}')],
|
||||
root
|
||||
)
|
||||
|
||||
# 3. Update Dockerfiles
|
||||
print("\n🐳 Dockerfiles:")
|
||||
dockerfiles = [
|
||||
"docker/Dockerfile",
|
||||
"docker/Dockerfile.chat",
|
||||
"docker/Dockerfile.dev",
|
||||
"docker/Dockerfile.ui",
|
||||
]
|
||||
for dockerfile in dockerfiles:
|
||||
update_file(
|
||||
root / dockerfile,
|
||||
[(r'"praisonai>=[0-9.]+"', f'"praisonai>={new_version}"')],
|
||||
root
|
||||
)
|
||||
|
||||
# 4. Update Homebrew formula
|
||||
print("\n🍺 Homebrew Formula:")
|
||||
update_file(
|
||||
praisonai_dir / "praisonai.rb",
|
||||
[(r'v[0-9]+\.[0-9]+\.[0-9]+', f'v{new_version}')],
|
||||
root
|
||||
)
|
||||
|
||||
# 5. Update praisonaiagents dependency if specified
|
||||
if agents_version:
|
||||
print(f"\n📦 Updating praisonaiagents dependency to {agents_version}:")
|
||||
update_file(
|
||||
praisonai_dir / "pyproject.toml",
|
||||
[(r'praisonaiagents>=[0-9.]+', f'praisonaiagents>={agents_version}')],
|
||||
root
|
||||
)
|
||||
|
||||
print("\n✨ Version bump complete!")
|
||||
|
||||
|
||||
def release(version: str):
|
||||
"""Run the release process."""
|
||||
root = get_project_root()
|
||||
praisonai_dir = get_praisonai_dir()
|
||||
tag = f"v{version}"
|
||||
|
||||
print(f"\n🚀 Releasing PraisonAI {tag}\n")
|
||||
|
||||
# 1. Copy root README.md to package dir for PyPI
|
||||
print("📄 Copying README.md...")
|
||||
root_readme = root / "README.md"
|
||||
pkg_readme = praisonai_dir / "README.md"
|
||||
if root_readme.exists():
|
||||
shutil.copy(root_readme, pkg_readme)
|
||||
print(f" ✅ Copied {root_readme} -> {pkg_readme}")
|
||||
|
||||
# 2. Clear uv cache and run uv lock
|
||||
print("\n🧹 Clearing uv cache...")
|
||||
run(["uv", "cache", "clean"], cwd=praisonai_dir)
|
||||
|
||||
print("\n📦 Running uv lock...")
|
||||
run(["uv", "lock"], cwd=praisonai_dir)
|
||||
|
||||
# 3. uv build
|
||||
print("\n🔨 Running uv build...")
|
||||
run(["uv", "build"], cwd=praisonai_dir)
|
||||
|
||||
# 4. Git add and commit
|
||||
print("\n📝 Committing changes...")
|
||||
run(["git", "add", "-A"], cwd=root)
|
||||
run(["git", "commit", "-m", f"Release {tag}"], cwd=root, check=False)
|
||||
|
||||
# 5. Create git tag
|
||||
print(f"\n🏷️ Creating tag {tag}...")
|
||||
run(["git", "tag", "-f", tag], cwd=root)
|
||||
|
||||
# 6. Push to GitHub
|
||||
print("\n⬆️ Pushing to GitHub...")
|
||||
run(["git", "push"], cwd=root)
|
||||
run(["git", "push", "--tags", "-f"], cwd=root)
|
||||
|
||||
# 7. Create GitHub release
|
||||
print(f"\n🎉 Creating GitHub release {tag}...")
|
||||
run([
|
||||
"gh", "release", "create", tag,
|
||||
"--title", f"PraisonAI {tag}",
|
||||
"--notes", f"Release {tag}",
|
||||
"--latest"
|
||||
], cwd=root)
|
||||
|
||||
print(f"\n✅ Released PraisonAI {tag}")
|
||||
print("\nNext step:")
|
||||
print(" cd src/praisonai && uv publish")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Bump version and release PraisonAI package"
|
||||
)
|
||||
parser.add_argument(
|
||||
"version",
|
||||
help="New version number (e.g., 2.2.99)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--agents", "-a",
|
||||
help="Optional: Update praisonaiagents dependency version (e.g., 0.0.169)",
|
||||
default=None
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Validate version format
|
||||
if not re.match(r'^\d+\.\d+\.\d+$', args.version):
|
||||
print(f"❌ Invalid version format: {args.version}")
|
||||
print(" Expected format: X.Y.Z (e.g., 2.2.99)")
|
||||
sys.exit(1)
|
||||
|
||||
if args.agents and not re.match(r'^\d+\.\d+\.\d+$', args.agents):
|
||||
print(f"❌ Invalid agents version format: {args.agents}")
|
||||
print(" Expected format: X.Y.Z (e.g., 0.0.169)")
|
||||
sys.exit(1)
|
||||
|
||||
# Run bump version
|
||||
bump_version(args.version, args.agents)
|
||||
|
||||
# Run release
|
||||
release(args.version)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -62,11 +62,23 @@ def main():
|
||||
|
||||
print(f"\n🚀 Releasing PraisonAI {tag}\n")
|
||||
|
||||
# 1. uv lock
|
||||
print("📦 Running uv lock...")
|
||||
# 1. Copy root README.md to package dir for PyPI
|
||||
print("📄 Copying README.md...")
|
||||
root_readme = root / "README.md"
|
||||
pkg_readme = praisonai_dir / "README.md"
|
||||
if root_readme.exists():
|
||||
import shutil
|
||||
shutil.copy(root_readme, pkg_readme)
|
||||
print(f" ✅ Copied {root_readme} -> {pkg_readme}")
|
||||
|
||||
# 2. Clear uv cache and run uv lock
|
||||
print("\n🧹 Clearing uv cache...")
|
||||
run(["uv", "cache", "clean"], cwd=praisonai_dir)
|
||||
|
||||
print("\n📦 Running uv lock...")
|
||||
run(["uv", "lock"], cwd=praisonai_dir)
|
||||
|
||||
# 2. uv build
|
||||
# 3. uv build
|
||||
print("\n🔨 Running uv build...")
|
||||
run(["uv", "build"], cwd=praisonai_dir)
|
||||
|
||||
|
||||
@@ -72,10 +72,10 @@ roles:
|
||||
except Exception as e:
|
||||
pytest.fail(f"CrewAI real test failed: {e}")
|
||||
|
||||
@pytest.mark.skipif(not os.getenv("OPENAI_API_KEY"), reason="OPENAI_API_KEY not set")
|
||||
def test_crewai_environment_check(self):
|
||||
"""Verify CrewAI environment is properly configured"""
|
||||
# Check API key is available
|
||||
assert os.getenv("OPENAI_API_KEY"), "OPENAI_API_KEY required for real tests"
|
||||
# Check API key is available (already checked by skipif)
|
||||
|
||||
# Check CrewAI can be imported
|
||||
try:
|
||||
|
||||
@@ -59,8 +59,11 @@ class TestPraisonAICommandLine(unittest.TestCase):
|
||||
# In CI, it should be set by the workflow
|
||||
print("Warning: OPENAI_API_KEY not found in CLI test environment. API calls might fail if not mocked.")
|
||||
|
||||
result = subprocess.run(command, shell=True, capture_output=True, text=True, env=env)
|
||||
return result.stdout + result.stderr
|
||||
try:
|
||||
result = subprocess.run(command, shell=True, capture_output=True, text=True, env=env, timeout=120)
|
||||
return result.stdout + result.stderr
|
||||
except subprocess.TimeoutExpired:
|
||||
return "TIMEOUT: Command exceeded 120 seconds"
|
||||
|
||||
@pytest.mark.real
|
||||
def test_praisonai_command(self):
|
||||
|
||||
Generated
+4
-4
@@ -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.169" },
|
||||
{ name = "praisonaiagents", specifier = ">=0.0.184" },
|
||||
{ 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.169"
|
||||
version = "0.0.184"
|
||||
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/4e/0c/94820a01306d53ce99f2b8d0f424566e74fe883aaca0e1a5c81f8364dddf/praisonaiagents-0.0.169.tar.gz", hash = "sha256:0774dd1fd8a5066e4ab2d612153d07bf1ac275105c10396730a9b542d4060ff3", size = 319791, upload-time = "2025-12-15T10:09:24.429Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/98/56/219e1f1eca6f0d521822f83fda970b44decba97dd49346c75cba00684bce/praisonaiagents-0.0.184.tar.gz", hash = "sha256:c09d036a35c5e34a3a40ffee01b94fa50ca6f6cc07f4a67a52ea1cbba42c139e", size = 412203, upload-time = "2025-12-15T22:13:47.78Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/f4/9342ee5340a2fdf1e3b9b8c1d10d9f2962c4fc577925c41b81db7b9beea1/praisonaiagents-0.0.169-py3-none-any.whl", hash = "sha256:ea491a2097224544f7ebb19b19daaabfc814c22bf92fb2adeb8e7d3f33d1233c", size = 333017, upload-time = "2025-12-15T10:09:23.016Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/a5/ffa998f4b99b763f9ff1d2ecc0a96b9669747b36563d9e453be3d4f9df3a/praisonaiagents-0.0.184-py3-none-any.whl", hash = "sha256:6aaa099cf181a9b7ac2ff446a6658ae8e2b687d55dc68a89a8eaa3f8ff1a0705", size = 398575, upload-time = "2025-12-15T22:13:46.253Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Reference in new issue
Block a user