mirror of
https://github.com/MervinPraison/PraisonAI.git
synced 2026-11-07 17:33:33 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
69740baa7c | ||
|
|
5035978a29 | ||
|
|
4ddb043324 | ||
|
|
337014f4db | ||
|
|
35c371b25e | ||
|
|
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 | ||
|
|
3f0eaccbcb | ||
|
|
47713d933a | ||
|
|
c82bfccd1d | ||
|
|
ef7d1264e2 | ||
|
|
2799304270 | ||
|
|
4a85c135a6 | ||
|
|
cdb42a3f08 | ||
|
|
c8378118e7 | ||
|
|
0213764057 | ||
|
|
2220114809 | ||
|
|
9fe4dbc172 | ||
|
|
180503fcd5 | ||
|
|
0d8f936b19 |
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
|
||||
|
||||
@@ -54,6 +54,16 @@ PraisonAI is a production-ready Multi-AI Agents framework with self-reflection,
|
||||
- 📄 YAML Configuration
|
||||
- 💯 100+ LLM Support
|
||||
- 🔬 **Deep Research Agents** (OpenAI & Gemini)
|
||||
- 🔄 **Query Rewriter Agent** (HyDE, Step-back, Multi-query)
|
||||
- 🌐 **Native Web Search** (OpenAI, Gemini, Anthropic, xAI, Perplexity)
|
||||
- 📥 **Web Fetch** (Retrieve full content from URLs - Anthropic)
|
||||
- 💾 **Prompt Caching** (Reduce costs & latency - OpenAI, Anthropic, Bedrock, Deepseek)
|
||||
- 🧠 **Claude Memory Tool** (Persistent cross-conversation memory - Anthropic Beta)
|
||||
- 💾 **File-Based Memory** (Zero-dependency persistent memory for all agents)
|
||||
- 🔍 **Built-in Search Tools** (Tavily, You.com, Exa - web search, news, content extraction)
|
||||
- 📋 **Planning Mode** (Plan before execution - Agent & Multi-Agent)
|
||||
- 🔧 **Planning Tools** (Research with tools during planning)
|
||||
- 🧠 **Planning Reasoning** (Chain-of-thought planning)
|
||||
|
||||
## Using Python Code
|
||||
|
||||
@@ -97,7 +107,34 @@ Run:
|
||||
python app.py
|
||||
```
|
||||
|
||||
### 3. Deep Research Agent
|
||||
### 3. Agent with Planning Mode
|
||||
|
||||
Enable planning for any agent - the agent creates a plan, then executes step by step:
|
||||
|
||||
```python
|
||||
from praisonaiagents import Agent
|
||||
|
||||
def search_web(query: str) -> str:
|
||||
return f"Search results for: {query}"
|
||||
|
||||
agent = Agent(
|
||||
name="AI Assistant",
|
||||
instructions="Research and write about topics",
|
||||
planning=True, # Enable planning mode
|
||||
planning_tools=[search_web], # Tools for planning research
|
||||
planning_reasoning=True # Chain-of-thought reasoning
|
||||
)
|
||||
|
||||
result = agent.start("Research AI trends in 2025 and write a summary")
|
||||
```
|
||||
|
||||
**What happens:**
|
||||
1. 📋 Agent creates a multi-step plan
|
||||
2. 🚀 Executes each step sequentially
|
||||
3. 📊 Shows progress with context passing
|
||||
4. ✅ Returns final result
|
||||
|
||||
### 4. Deep Research Agent
|
||||
|
||||
Automated research with real-time streaming, web search, and citations using OpenAI or Gemini Deep Research APIs.
|
||||
|
||||
@@ -135,6 +172,195 @@ print(result.report)
|
||||
- 🛠️ Built-in tools: web search, code interpreter, MCP, file search
|
||||
- 🔄 Automatic provider detection from model name
|
||||
|
||||
### 5. Query Rewriter Agent
|
||||
|
||||
Transform user queries to improve RAG retrieval quality using multiple strategies.
|
||||
|
||||
```python
|
||||
from praisonaiagents import QueryRewriterAgent, RewriteStrategy
|
||||
|
||||
agent = QueryRewriterAgent(model="gpt-4o-mini")
|
||||
|
||||
# Basic - expands abbreviations, adds context
|
||||
result = agent.rewrite("AI trends")
|
||||
print(result.primary_query) # "What are the current trends in Artificial Intelligence?"
|
||||
|
||||
# HyDE - generates hypothetical document for semantic matching
|
||||
result = agent.rewrite("What is quantum computing?", strategy=RewriteStrategy.HYDE)
|
||||
|
||||
# Step-back - generates broader context question
|
||||
result = agent.rewrite("GPT-4 vs Claude 3?", strategy=RewriteStrategy.STEP_BACK)
|
||||
|
||||
# Sub-queries - decomposes complex questions
|
||||
result = agent.rewrite("RAG setup and best embedding models?", strategy=RewriteStrategy.SUB_QUERIES)
|
||||
|
||||
# Contextual - resolves references using chat history
|
||||
result = agent.rewrite("What about cost?", chat_history=[...])
|
||||
```
|
||||
|
||||
**Strategies:**
|
||||
- **BASIC**: Expand abbreviations, fix typos, add context
|
||||
- **HYDE**: Generate hypothetical document for semantic matching
|
||||
- **STEP_BACK**: Generate higher-level concept questions
|
||||
- **SUB_QUERIES**: Decompose multi-part questions
|
||||
- **MULTI_QUERY**: Generate multiple paraphrased versions
|
||||
- **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:
|
||||
@@ -144,6 +370,18 @@ export OPENAI_API_KEY=xxxxxxxxxxxxxxxxxxxxxx
|
||||
praisonai --auto create a movie script about Robots in Mars
|
||||
```
|
||||
|
||||
### Query Rewriting (works with any command):
|
||||
```bash
|
||||
# Rewrite query for better results (uses QueryRewriterAgent)
|
||||
praisonai "AI trends" --query-rewrite
|
||||
|
||||
# Rewrite with search tools (agent decides when to search)
|
||||
praisonai "latest developments" --query-rewrite --rewrite-tools "internet_search"
|
||||
|
||||
# Works with any prompt
|
||||
praisonai "explain quantum computing" --query-rewrite -v
|
||||
```
|
||||
|
||||
### Deep Research CLI:
|
||||
```bash
|
||||
# Default: OpenAI (o4-mini-deep-research)
|
||||
@@ -152,10 +390,126 @@ praisonai research "What are the latest AI trends in 2025?"
|
||||
# Use Gemini
|
||||
praisonai research --model deep-research-pro "Your research query"
|
||||
|
||||
# Rewrite query before research
|
||||
praisonai research --query-rewrite "AI trends"
|
||||
|
||||
# Rewrite with search tools
|
||||
praisonai research --query-rewrite --rewrite-tools "internet_search" "AI trends"
|
||||
|
||||
# Use custom tools from file (gathers context before deep research)
|
||||
praisonai research --tools tools.py "Your research query"
|
||||
praisonai research -t my_tools.py "Your research query"
|
||||
|
||||
# Use built-in tools by name (comma-separated)
|
||||
praisonai research --tools "internet_search,wiki_search" "Your query"
|
||||
praisonai research -t "yfinance,calculator_tools" "Stock analysis query"
|
||||
|
||||
# Save output to file (output/research/{query}.md)
|
||||
praisonai research --save "Your research query"
|
||||
praisonai research -s "Your research query"
|
||||
|
||||
# Combine options
|
||||
praisonai research --query-rewrite --tools tools.py --save "Your research query"
|
||||
|
||||
# Verbose mode (show debug logs)
|
||||
praisonai research -v "Your research query"
|
||||
```
|
||||
|
||||
### Planning Mode CLI:
|
||||
```bash
|
||||
# Enable planning mode - agent creates a plan before execution
|
||||
praisonai "Research AI trends and write a summary" --planning
|
||||
|
||||
# Planning with tools for research
|
||||
praisonai "Analyze market trends" --planning --planning-tools tools.py
|
||||
|
||||
# Planning with chain-of-thought reasoning
|
||||
praisonai "Complex analysis task" --planning --planning-reasoning
|
||||
|
||||
# Auto-approve plans without confirmation
|
||||
praisonai "Task" --planning --auto-approve-plan
|
||||
```
|
||||
|
||||
### Memory CLI:
|
||||
```bash
|
||||
# Enable memory for agent (persists across sessions)
|
||||
praisonai "My name is John" --memory
|
||||
|
||||
# Memory with user isolation
|
||||
praisonai "Remember my preferences" --memory --user-id user123
|
||||
|
||||
# Memory management commands
|
||||
praisonai memory show # Show memory statistics
|
||||
praisonai memory add "User prefers Python" # Add to long-term memory
|
||||
praisonai memory search "Python" # Search memories
|
||||
praisonai memory clear # Clear short-term memory
|
||||
praisonai memory clear all # Clear all memory
|
||||
praisonai memory save my_session # Save session
|
||||
praisonai memory resume my_session # Resume session
|
||||
praisonai memory sessions # List saved sessions
|
||||
praisonai memory checkpoint # Create checkpoint
|
||||
praisonai memory restore <checkpoint_id> # Restore checkpoint
|
||||
praisonai memory checkpoints # List checkpoints
|
||||
praisonai memory help # Show all commands
|
||||
```
|
||||
|
||||
### Rules CLI:
|
||||
```bash
|
||||
# List all loaded rules (from PRAISON.md, CLAUDE.md, etc.)
|
||||
praisonai rules list
|
||||
|
||||
# Show specific rule details
|
||||
praisonai rules show <rule_name>
|
||||
|
||||
# Create a new rule
|
||||
praisonai rules create my_rule "Always use type hints"
|
||||
|
||||
# Delete a rule
|
||||
praisonai rules delete my_rule
|
||||
|
||||
# Show rules statistics
|
||||
praisonai rules stats
|
||||
|
||||
# Include manual rules with prompts
|
||||
praisonai "Task" --include-rules security,testing
|
||||
```
|
||||
|
||||
### Workflow CLI:
|
||||
```bash
|
||||
# List available workflows
|
||||
praisonai workflow list
|
||||
|
||||
# Execute a workflow
|
||||
praisonai workflow run deploy
|
||||
|
||||
# Execute with variables
|
||||
praisonai workflow run deploy --workflow-var environment=staging --workflow-var branch=main
|
||||
|
||||
# Show workflow details
|
||||
praisonai workflow show deploy
|
||||
|
||||
# Create a new workflow template
|
||||
praisonai workflow create my_workflow
|
||||
```
|
||||
|
||||
### Hooks CLI:
|
||||
```bash
|
||||
# List configured hooks
|
||||
praisonai hooks list
|
||||
|
||||
# Show hooks statistics
|
||||
praisonai hooks stats
|
||||
|
||||
# Create hooks.json template
|
||||
praisonai hooks init
|
||||
```
|
||||
|
||||
### Claude Memory Tool CLI:
|
||||
```bash
|
||||
# Enable Claude Memory Tool (Anthropic models only)
|
||||
praisonai "Research and remember findings" --claude-memory --llm anthropic/claude-sonnet-4-20250514
|
||||
```
|
||||
|
||||
## Using JavaScript Code
|
||||
|
||||
```bash
|
||||
@@ -574,6 +928,172 @@ praisonai agents.yaml
|
||||
</a>
|
||||
</div>
|
||||
|
||||
|
||||
## Custom Tools
|
||||
|
||||
### Using `@tool` Decorator
|
||||
|
||||
```python
|
||||
from praisonaiagents import Agent, tool
|
||||
|
||||
@tool
|
||||
def search(query: str) -> str:
|
||||
"""Search the web for information."""
|
||||
return f"Results for: {query}"
|
||||
|
||||
@tool
|
||||
def calculate(expression: str) -> float:
|
||||
"""Evaluate a math expression."""
|
||||
return eval(expression)
|
||||
|
||||
agent = Agent(
|
||||
instructions="You are a helpful assistant",
|
||||
tools=[search, calculate]
|
||||
)
|
||||
agent.start("Search for AI news and calculate 15*4")
|
||||
```
|
||||
|
||||
### Using `BaseTool` Class
|
||||
|
||||
```python
|
||||
from praisonaiagents import Agent, BaseTool
|
||||
|
||||
class WeatherTool(BaseTool):
|
||||
name = "weather"
|
||||
description = "Get current weather for a location"
|
||||
|
||||
def run(self, location: str) -> str:
|
||||
return f"Weather in {location}: 72°F, Sunny"
|
||||
|
||||
agent = Agent(
|
||||
instructions="You are a weather assistant",
|
||||
tools=[WeatherTool()]
|
||||
)
|
||||
agent.start("What's the weather in Paris?")
|
||||
```
|
||||
|
||||
### Creating a Tool Package (pip installable)
|
||||
|
||||
```toml
|
||||
# pyproject.toml
|
||||
[project]
|
||||
name = "my-praisonai-tools"
|
||||
version = "1.0.0"
|
||||
dependencies = ["praisonaiagents"]
|
||||
|
||||
[project.entry-points."praisonaiagents.tools"]
|
||||
my_tool = "my_package:MyTool"
|
||||
```
|
||||
|
||||
```python
|
||||
# my_package/__init__.py
|
||||
from praisonaiagents import BaseTool
|
||||
|
||||
class MyTool(BaseTool):
|
||||
name = "my_tool"
|
||||
description = "My custom tool"
|
||||
|
||||
def run(self, param: str) -> str:
|
||||
return f"Result: {param}"
|
||||
```
|
||||
|
||||
After `pip install`, tools are auto-discovered:
|
||||
```python
|
||||
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.
|
||||
@@ -591,6 +1111,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.
|
||||
@@ -608,7 +1141,7 @@ uv pip install -r pyproject.toml --extra "crewai,autogen"
|
||||
- 🎨 Interactive UIs
|
||||
- 📄 YAML-based Configuration
|
||||
- 🛠️ Custom Tool Integration
|
||||
- 🔍 Internet Search Capability (using Crawl4AI and Tavily)
|
||||
- 🔍 Internet Search Capability (Tavily, You.com, Exa, DuckDuckGo, Crawl4AI)
|
||||
- 🖼️ Vision Language Model (VLM) Support
|
||||
- 🎙️ Real-time Voice Interaction
|
||||
|
||||
|
||||
+1
-1
@@ -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.90" \
|
||||
"praisonai>=2.3.7" \
|
||||
"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.90" \
|
||||
"praisonai>=2.3.7" \
|
||||
"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.90" \
|
||||
"praisonai>=2.3.7" \
|
||||
"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.90" \
|
||||
"praisonai>=2.3.7" \
|
||||
"praisonai[ui]" \
|
||||
"praisonai[crewai]"
|
||||
|
||||
|
||||
+2
-2
@@ -121,7 +121,7 @@ healthcheck:
|
||||
## 📦 Package Versions
|
||||
|
||||
All Docker images use consistent, up-to-date versions:
|
||||
- PraisonAI: `>=2.2.90`
|
||||
- PraisonAI: `>=2.2.95`
|
||||
- PraisonAI Agents: `>=0.0.92`
|
||||
- Python: `3.11-slim`
|
||||
|
||||
@@ -218,7 +218,7 @@ docker-compose up -d
|
||||
### Version Pinning
|
||||
To use specific versions, update the Dockerfile:
|
||||
```dockerfile
|
||||
RUN pip install "praisonai==2.2.90" "praisonaiagents==0.0.92"
|
||||
RUN pip install "praisonai==2.2.95" "praisonaiagents==0.0.92"
|
||||
```
|
||||
|
||||
## 🌐 Production Deployment
|
||||
|
||||
@@ -137,22 +137,11 @@ services:
|
||||
POSTGRES_USER: praisonai
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-praisonai_dev_pass}
|
||||
volumes:
|
||||
database:
|
||||
image: postgres:15-alpine
|
||||
environment:
|
||||
POSTGRES_DB: praisonai_dev
|
||||
POSTGRES_USER: praisonai
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-praisonai_dev_pass}
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp
|
||||
- /var/tmp
|
||||
ports:
|
||||
- "5432:5432"
|
||||
networks:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp
|
||||
- /var/tmp
|
||||
ports:
|
||||
- "5432:5432"
|
||||
networks:
|
||||
|
||||
@@ -86,3 +86,4 @@ build
|
||||
graph.py
|
||||
chroma_db/
|
||||
.qodo
|
||||
*_COMPARISON_ANALYSIS.md
|
||||
+542
-12
@@ -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
|
||||
@@ -136,7 +194,45 @@ result = agent.research("Research quantum computing advances")
|
||||
- **Citations**: Structured citations with URLs
|
||||
- **Tools**: Web search, code interpreter, MCP, file search
|
||||
|
||||
### 3. ImageAgent
|
||||
### 3. QueryRewriterAgent 🆕
|
||||
Transform user queries to improve RAG retrieval quality using multiple strategies.
|
||||
|
||||
```python
|
||||
from praisonaiagents import QueryRewriterAgent, RewriteStrategy
|
||||
|
||||
agent = QueryRewriterAgent(model="gpt-4o-mini", verbose=True)
|
||||
|
||||
# Basic rewriting - expands abbreviations, adds context
|
||||
result = agent.rewrite("AI trends")
|
||||
print(result.primary_query)
|
||||
# Output: "What are the current trends in Artificial Intelligence (AI)?"
|
||||
|
||||
# HyDE - generates hypothetical document for semantic matching
|
||||
result = agent.rewrite("What is quantum computing?", strategy=RewriteStrategy.HYDE)
|
||||
print(result.hypothetical_document)
|
||||
|
||||
# Step-back - generates broader context question
|
||||
result = agent.rewrite("Difference between GPT-4 and Claude 3?", strategy=RewriteStrategy.STEP_BACK)
|
||||
print(result.step_back_question)
|
||||
|
||||
# Sub-queries - decomposes complex questions
|
||||
result = agent.rewrite("How to set up RAG and what embedding models to use?", strategy=RewriteStrategy.SUB_QUERIES)
|
||||
print(result.sub_queries)
|
||||
|
||||
# Contextual - uses chat history to resolve references
|
||||
result = agent.rewrite("What about its cost?", strategy=RewriteStrategy.CONTEXTUAL, chat_history=[...])
|
||||
```
|
||||
|
||||
**Strategies:**
|
||||
- **BASIC**: Expand abbreviations, fix typos, add context
|
||||
- **HYDE**: Generate hypothetical document for better semantic matching
|
||||
- **STEP_BACK**: Generate higher-level concept questions
|
||||
- **SUB_QUERIES**: Decompose multi-part questions
|
||||
- **MULTI_QUERY**: Generate multiple paraphrased versions
|
||||
- **CONTEXTUAL**: Resolve references using conversation history
|
||||
- **AUTO**: Automatically detect best strategy
|
||||
|
||||
### 4. ImageAgent
|
||||
Agent specialized for image generation and analysis.
|
||||
|
||||
```python
|
||||
@@ -150,7 +246,7 @@ agent = ImageAgent(
|
||||
result = agent.chat("Create an image of a sunset over mountains")
|
||||
```
|
||||
|
||||
### 4. ContextAgent
|
||||
### 5. ContextAgent
|
||||
Agent with persistent context across conversations.
|
||||
|
||||
```python
|
||||
@@ -162,7 +258,7 @@ agent = ContextAgent(
|
||||
)
|
||||
```
|
||||
|
||||
### 5. RouterAgent
|
||||
### 6. RouterAgent
|
||||
Routes requests to appropriate specialized agents.
|
||||
|
||||
```python
|
||||
@@ -178,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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -309,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,94 @@
|
||||
# PraisonAI Example Plugin
|
||||
|
||||
This is a template for creating tool plugins for PraisonAI Agents.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
# Install in development mode
|
||||
pip install -e .
|
||||
|
||||
# Or install from PyPI (after publishing)
|
||||
pip install praisonai-example-plugin
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
After installation, tools are automatically discovered:
|
||||
|
||||
```python
|
||||
from praisonaiagents import Agent
|
||||
|
||||
# Use tools by name - they're auto-discovered!
|
||||
agent = Agent(
|
||||
name="Assistant",
|
||||
tools=["example_greet", "example_math", "example_reverse"]
|
||||
)
|
||||
|
||||
# Or import and use directly
|
||||
from praisonai_example_plugin import GreetTool, MathTool, reverse_text
|
||||
|
||||
agent = Agent(
|
||||
name="Assistant",
|
||||
tools=[GreetTool(), MathTool(), reverse_text]
|
||||
)
|
||||
```
|
||||
|
||||
## Creating Your Own Plugin
|
||||
|
||||
1. **Copy this template**
|
||||
2. **Rename the package** in `pyproject.toml` and `src/` directory
|
||||
3. **Add your tools** following the patterns in `__init__.py`
|
||||
4. **Update entry_points** in `pyproject.toml` to register your tools
|
||||
|
||||
### Tool Patterns
|
||||
|
||||
**Class-based tool:**
|
||||
```python
|
||||
from praisonaiagents import BaseTool
|
||||
|
||||
class MyTool(BaseTool):
|
||||
name = "my_tool"
|
||||
description = "What this tool does"
|
||||
|
||||
def run(self, param: str) -> str:
|
||||
return f"Result: {param}"
|
||||
```
|
||||
|
||||
**Decorator-based tool:**
|
||||
```python
|
||||
from praisonaiagents import tool
|
||||
|
||||
@tool
|
||||
def my_function(param: str) -> str:
|
||||
"""What this function does."""
|
||||
return f"Result: {param}"
|
||||
```
|
||||
|
||||
### Registering Tools
|
||||
|
||||
In `pyproject.toml`:
|
||||
```toml
|
||||
[project.entry-points."praisonaiagents.tools"]
|
||||
my_tool = "my_package:MyTool"
|
||||
my_function = "my_package:my_function"
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
pip install -e ".[dev]"
|
||||
pytest
|
||||
```
|
||||
|
||||
## Publishing
|
||||
|
||||
```bash
|
||||
pip install build twine
|
||||
python -m build
|
||||
twine upload dist/*
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
@@ -0,0 +1,49 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=61.0", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "praisonai-example-plugin"
|
||||
version = "1.0.0"
|
||||
description = "Example plugin for PraisonAI Agents - demonstrates tool plugin development"
|
||||
readme = "README.md"
|
||||
license = {text = "MIT"}
|
||||
requires-python = ">=3.10"
|
||||
authors = [
|
||||
{name = "Your Name", email = "[email protected]"}
|
||||
]
|
||||
keywords = ["praisonai", "agents", "tools", "plugin"]
|
||||
classifiers = [
|
||||
"Development Status :: 4 - Beta",
|
||||
"Intended Audience :: Developers",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
]
|
||||
|
||||
dependencies = [
|
||||
"praisonaiagents>=0.0.168",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=7.0.0",
|
||||
"pytest-mock>=3.10.0",
|
||||
]
|
||||
|
||||
# This is the key part - register your tools as entry points
|
||||
# The format is: tool_name = "module.path:ClassName" or "module.path:function_name"
|
||||
[project.entry-points."praisonaiagents.tools"]
|
||||
example_greet = "praisonai_example_plugin:GreetTool"
|
||||
example_math = "praisonai_example_plugin:MathTool"
|
||||
example_reverse = "praisonai_example_plugin:reverse_text"
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/yourusername/praisonai-example-plugin"
|
||||
Documentation = "https://github.com/yourusername/praisonai-example-plugin#readme"
|
||||
Repository = "https://github.com/yourusername/praisonai-example-plugin"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
"""PraisonAI Example Plugin - Demonstrates how to create tool plugins.
|
||||
|
||||
This package shows how external developers can create pip-installable
|
||||
tool plugins for PraisonAI Agents.
|
||||
|
||||
Usage after installation:
|
||||
from praisonaiagents import Agent
|
||||
|
||||
# Tools are auto-discovered via entry_points!
|
||||
agent = Agent(tools=["example_greet", "example_math"])
|
||||
|
||||
# Or import directly
|
||||
from praisonai_example_plugin import GreetTool, MathTool
|
||||
agent = Agent(tools=[GreetTool(), MathTool()])
|
||||
"""
|
||||
|
||||
from typing import Dict, Any
|
||||
from praisonaiagents import BaseTool, tool
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Class-Based Tools
|
||||
# =============================================================================
|
||||
|
||||
class GreetTool(BaseTool):
|
||||
"""A tool that generates personalized greetings.
|
||||
|
||||
This demonstrates a simple class-based tool with multiple parameters.
|
||||
"""
|
||||
name = "example_greet"
|
||||
description = "Generate a personalized greeting message"
|
||||
version = "1.0.0"
|
||||
|
||||
def run(self, name: str, greeting: str = "Hello", punctuation: str = "!") -> str:
|
||||
"""Generate a greeting.
|
||||
|
||||
Args:
|
||||
name: The name of the person to greet
|
||||
greeting: The greeting word (default: "Hello")
|
||||
punctuation: End punctuation (default: "!")
|
||||
|
||||
Returns:
|
||||
A formatted greeting string
|
||||
"""
|
||||
return f"{greeting}, {name}{punctuation}"
|
||||
|
||||
|
||||
class MathTool(BaseTool):
|
||||
"""A tool for basic mathematical operations.
|
||||
|
||||
Demonstrates returning structured data from a tool.
|
||||
"""
|
||||
name = "example_math"
|
||||
description = "Perform basic math operations: add, subtract, multiply, divide"
|
||||
version = "1.0.0"
|
||||
|
||||
def run(
|
||||
self,
|
||||
a: float,
|
||||
b: float,
|
||||
operation: str = "add"
|
||||
) -> Dict[str, Any]:
|
||||
"""Perform a math operation.
|
||||
|
||||
Args:
|
||||
a: First number
|
||||
b: Second number
|
||||
operation: One of 'add', 'subtract', 'multiply', 'divide'
|
||||
|
||||
Returns:
|
||||
Dict containing result and operation details
|
||||
"""
|
||||
ops = {
|
||||
"add": ("+", lambda x, y: x + y),
|
||||
"subtract": ("-", lambda x, y: x - y),
|
||||
"multiply": ("*", lambda x, y: x * y),
|
||||
"divide": ("/", lambda x, y: x / y if y != 0 else float('inf')),
|
||||
}
|
||||
|
||||
if operation not in ops:
|
||||
return {"error": f"Unknown operation: {operation}. Use: {list(ops.keys())}"}
|
||||
|
||||
symbol, func = ops[operation]
|
||||
result = func(a, b)
|
||||
|
||||
return {
|
||||
"result": result,
|
||||
"expression": f"{a} {symbol} {b} = {result}",
|
||||
"operation": operation
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Decorator-Based Tools
|
||||
# =============================================================================
|
||||
|
||||
@tool(name="example_reverse", description="Reverse a string of text")
|
||||
def reverse_text(text: str) -> str:
|
||||
"""Reverse the input text.
|
||||
|
||||
Args:
|
||||
text: The text to reverse
|
||||
|
||||
Returns:
|
||||
The reversed text
|
||||
"""
|
||||
return text[::-1]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Package Exports
|
||||
# =============================================================================
|
||||
|
||||
__version__ = "1.0.0"
|
||||
__all__ = [
|
||||
"GreetTool",
|
||||
"MathTool",
|
||||
"reverse_text",
|
||||
]
|
||||
@@ -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()
|
||||
@@ -26,9 +26,22 @@ from .agent.deep_research_agent import (
|
||||
FileSearchCall,
|
||||
Provider
|
||||
)
|
||||
from .agent.query_rewriter_agent import (
|
||||
QueryRewriterAgent,
|
||||
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
|
||||
from .tools.base import BaseTool, ToolResult, ToolValidationError, validate_tool
|
||||
from .tools.decorator import tool, FunctionTool
|
||||
from .tools.registry import get_registry, register_tool, get_tool, ToolRegistry
|
||||
from .agents.autoagents import AutoAgents
|
||||
from .knowledge.knowledge import Knowledge
|
||||
from .knowledge.chunking import Chunking
|
||||
@@ -42,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:
|
||||
@@ -165,6 +204,17 @@ __all__ = [
|
||||
'Agents',
|
||||
'Tools',
|
||||
'Task',
|
||||
# Plugin system
|
||||
'BaseTool',
|
||||
'ToolResult',
|
||||
'ToolValidationError',
|
||||
'validate_tool',
|
||||
'tool',
|
||||
'FunctionTool',
|
||||
'ToolRegistry',
|
||||
'get_registry',
|
||||
'register_tool',
|
||||
'get_tool',
|
||||
'TaskOutput',
|
||||
'ReflectionOutput',
|
||||
'AutoAgents',
|
||||
@@ -206,7 +256,13 @@ __all__ = [
|
||||
'CodeExecutionStep',
|
||||
'MCPCall',
|
||||
'FileSearchCall',
|
||||
'Provider'
|
||||
'Provider',
|
||||
'QueryRewriterAgent',
|
||||
'RewriteStrategy',
|
||||
'RewriteResult',
|
||||
'PromptExpanderAgent',
|
||||
'ExpandStrategy',
|
||||
'ExpandResult'
|
||||
]
|
||||
|
||||
# Add MCP to __all__ if available
|
||||
|
||||
@@ -15,6 +15,16 @@ from .deep_research_agent import (
|
||||
FileSearchCall,
|
||||
Provider
|
||||
)
|
||||
from .query_rewriter_agent import (
|
||||
QueryRewriterAgent,
|
||||
RewriteStrategy,
|
||||
RewriteResult
|
||||
)
|
||||
from .prompt_expander_agent import (
|
||||
PromptExpanderAgent,
|
||||
ExpandStrategy,
|
||||
ExpandResult
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
'Agent',
|
||||
@@ -35,5 +45,11 @@ __all__ = [
|
||||
'CodeExecutionStep',
|
||||
'MCPCall',
|
||||
'FileSearchCall',
|
||||
'Provider'
|
||||
'Provider',
|
||||
'QueryRewriterAgent',
|
||||
'RewriteStrategy',
|
||||
'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
|
||||
|
||||
@@ -455,6 +471,15 @@ class Agent:
|
||||
if callable(tools):
|
||||
# If a single function/callable is passed, wrap it in a list
|
||||
self.tools = [tools]
|
||||
elif isinstance(tools, str):
|
||||
# Single tool name string - resolve from registry
|
||||
self.tools = self._resolve_tool_names([tools])
|
||||
elif isinstance(tools, (list, tuple)):
|
||||
# Check if list contains strings (tool names) that need resolution
|
||||
if tools and all(isinstance(t, str) for t in tools):
|
||||
self.tools = self._resolve_tool_names(tools)
|
||||
else:
|
||||
self.tools = list(tools)
|
||||
else:
|
||||
# Handle all falsy values (None, False, 0, "", etc.) by defaulting to empty list
|
||||
self.tools = tools or []
|
||||
@@ -462,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
|
||||
@@ -502,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
|
||||
@@ -567,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.
|
||||
@@ -813,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
|
||||
@@ -853,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
|
||||
|
||||
@@ -1008,6 +1366,31 @@ Your Goal: {self.goal}"""
|
||||
tools=self.tools
|
||||
)
|
||||
|
||||
def _resolve_tool_names(self, tool_names):
|
||||
"""Resolve tool names to actual tool instances from registry.
|
||||
|
||||
Args:
|
||||
tool_names: List of tool name strings
|
||||
|
||||
Returns:
|
||||
List of resolved tool instances
|
||||
"""
|
||||
resolved = []
|
||||
try:
|
||||
from ..tools.registry import get_registry
|
||||
registry = get_registry()
|
||||
|
||||
for name in tool_names:
|
||||
tool = registry.get(name)
|
||||
if tool is not None:
|
||||
resolved.append(tool)
|
||||
else:
|
||||
logging.warning(f"Tool '{name}' not found in registry")
|
||||
except ImportError:
|
||||
logging.warning("Tool registry not available, cannot resolve tool names")
|
||||
|
||||
return resolved
|
||||
|
||||
def _cast_arguments(self, func, arguments):
|
||||
"""Cast arguments to their expected types based on function signature."""
|
||||
if not callable(func) or not arguments:
|
||||
@@ -1105,13 +1488,31 @@ Your Goal: {self.goal}"""
|
||||
# Try to find the function in the agent's tools list first
|
||||
func = None
|
||||
for tool in self.tools if isinstance(self.tools, (list, tuple)) else []:
|
||||
# Check for BaseTool instances (plugin system)
|
||||
from ..tools.base import BaseTool
|
||||
if isinstance(tool, BaseTool) and tool.name == function_name:
|
||||
func = tool
|
||||
break
|
||||
# Check for FunctionTool (decorated functions)
|
||||
if hasattr(tool, 'name') and getattr(tool, 'name', None) == function_name:
|
||||
func = tool
|
||||
break
|
||||
if (callable(tool) and getattr(tool, '__name__', '') == function_name) or \
|
||||
(inspect.isclass(tool) and tool.__name__ == function_name):
|
||||
func = tool
|
||||
break
|
||||
|
||||
if func is None:
|
||||
# If not found in tools, try globals and main
|
||||
# Check the global tool registry for plugins
|
||||
try:
|
||||
from ..tools.registry import get_registry
|
||||
registry = get_registry()
|
||||
func = registry.get(function_name)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
if func is None:
|
||||
# If not found in tools or registry, try globals and main
|
||||
func = globals().get(function_name)
|
||||
if not func:
|
||||
import __main__
|
||||
@@ -1119,6 +1520,12 @@ Your Goal: {self.goal}"""
|
||||
|
||||
if func:
|
||||
try:
|
||||
# BaseTool instances (plugin system) - call run() method
|
||||
from ..tools.base import BaseTool
|
||||
if isinstance(func, BaseTool):
|
||||
casted_arguments = self._cast_arguments(func.run, arguments)
|
||||
return func.run(**casted_arguments)
|
||||
|
||||
# Langchain: If it's a class with run but not _run, instantiate and call run
|
||||
if inspect.isclass(func) and hasattr(func, 'run') and not hasattr(func, '_run'):
|
||||
instance = func()
|
||||
@@ -2092,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))
|
||||
|
||||
|
||||
@@ -254,7 +254,17 @@ class DeepResearchAgent:
|
||||
max_wait_time: Maximum seconds to wait for research completion
|
||||
"""
|
||||
self.name = name or "DeepResearchAgent"
|
||||
self.instructions = instructions
|
||||
# Default instructions for comprehensive research
|
||||
default_instructions = """
|
||||
You are a professional research analyst. When conducting research:
|
||||
- Include specific figures, trends, statistics, and measurable outcomes
|
||||
- Prioritize reliable, up-to-date sources: peer-reviewed research, official organizations, regulatory agencies
|
||||
- Include inline citations and return all source metadata
|
||||
- Be analytical, avoid generalities, and ensure data-backed reasoning
|
||||
- Structure your response with clear headers and formatting
|
||||
- If creating comparisons, use tables for clarity
|
||||
"""
|
||||
self.instructions = instructions or default_instructions
|
||||
self.verbose = verbose
|
||||
self.summary_mode = summary_mode
|
||||
self.enable_web_search = enable_web_search
|
||||
@@ -288,7 +298,7 @@ class DeepResearchAgent:
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
if self.verbose:
|
||||
self.logger.info(f"DeepResearchAgent initialized: provider={self.provider.value}, model={self.model}")
|
||||
self.logger.debug(f"DeepResearchAgent initialized: provider={self.provider.value}, model={self.model}")
|
||||
|
||||
def _detect_provider(
|
||||
self,
|
||||
@@ -766,7 +776,7 @@ class DeepResearchAgent:
|
||||
interaction = self.gemini_client.interactions.create(**create_params)
|
||||
|
||||
if self.verbose:
|
||||
self.logger.info(f"Gemini research started: {interaction.id}")
|
||||
self.logger.debug(f"Gemini research started: {interaction.id}")
|
||||
|
||||
start_time = time.time()
|
||||
while True:
|
||||
@@ -774,7 +784,7 @@ class DeepResearchAgent:
|
||||
|
||||
if interaction.status == "completed":
|
||||
if self.verbose:
|
||||
self.logger.info("Gemini research completed")
|
||||
self.logger.debug("Gemini research completed")
|
||||
break
|
||||
elif interaction.status == "failed":
|
||||
error = getattr(interaction, 'error', 'Unknown error')
|
||||
@@ -789,7 +799,7 @@ class DeepResearchAgent:
|
||||
)
|
||||
|
||||
if self.verbose:
|
||||
self.logger.info(f"Research in progress... ({elapsed:.0f}s)")
|
||||
self.logger.debug(f"Research in progress... ({elapsed:.0f}s)")
|
||||
|
||||
time.sleep(self.poll_interval)
|
||||
|
||||
@@ -1008,7 +1018,7 @@ class DeepResearchAgent:
|
||||
start_time = time.time()
|
||||
|
||||
if self.verbose and not stream:
|
||||
self.logger.info(f"Starting {self.provider.value} deep research: {query[:100]}...")
|
||||
self.logger.debug(f"Starting {self.provider.value} deep research: {query[:100]}...")
|
||||
|
||||
try:
|
||||
if self.provider == Provider.GEMINI:
|
||||
@@ -1049,8 +1059,8 @@ class DeepResearchAgent:
|
||||
|
||||
elapsed = time.time() - start_time
|
||||
if self.verbose:
|
||||
self.logger.info(f"Deep research completed in {elapsed:.1f}s")
|
||||
self.logger.info(f"Report length: {len(result.report)} chars, Citations: {len(result.citations)}")
|
||||
self.logger.debug(f"Deep research completed in {elapsed:.1f}s")
|
||||
self.logger.debug(f"Report length: {len(result.report)} chars, Citations: {len(result.citations)}")
|
||||
|
||||
return result
|
||||
|
||||
@@ -1076,7 +1086,7 @@ class DeepResearchAgent:
|
||||
start_time = time.time()
|
||||
|
||||
if self.verbose:
|
||||
self.logger.info(f"Starting async {self.provider.value} deep research: {query[:100]}...")
|
||||
self.logger.debug(f"Starting async {self.provider.value} deep research: {query[:100]}...")
|
||||
|
||||
try:
|
||||
if self.provider == Provider.GEMINI:
|
||||
@@ -1151,7 +1161,7 @@ class DeepResearchAgent:
|
||||
|
||||
elapsed = time.time() - start_time
|
||||
if self.verbose:
|
||||
self.logger.info(f"Async deep research completed in {elapsed:.1f}s")
|
||||
self.logger.debug(f"Async deep research completed in {elapsed:.1f}s")
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1,632 @@
|
||||
"""
|
||||
Query Rewriter Agent Module
|
||||
|
||||
This module provides the QueryRewriterAgent class for transforming user queries
|
||||
to improve retrieval quality in RAG applications.
|
||||
|
||||
Supported Rewriting Strategies:
|
||||
- **BASIC**: Simple rephrasing for clarity and keyword optimization
|
||||
- **HYDE**: Hypothetical Document Embeddings - generates a hypothetical answer
|
||||
- **STEP_BACK**: Generates higher-level concept questions for complex queries
|
||||
- **SUB_QUERIES**: Decomposes multi-part questions into focused sub-queries
|
||||
- **MULTI_QUERY**: Generates multiple paraphrased versions for ensemble retrieval
|
||||
- **CONTEXTUAL**: Uses conversation history to resolve references and context
|
||||
|
||||
Example:
|
||||
from praisonaiagents import QueryRewriterAgent, RewriteStrategy
|
||||
|
||||
agent = QueryRewriterAgent(verbose=True)
|
||||
|
||||
# Basic rewriting
|
||||
result = agent.rewrite("AI trends")
|
||||
print(result.rewritten_queries)
|
||||
|
||||
# HyDE for better semantic matching
|
||||
result = agent.rewrite("What is quantum computing?", strategy=RewriteStrategy.HYDE)
|
||||
print(result.hypothetical_document)
|
||||
|
||||
# Contextual with chat history
|
||||
chat_history = [
|
||||
{"role": "user", "content": "Tell me about Python"},
|
||||
{"role": "assistant", "content": "Python is a programming language..."}
|
||||
]
|
||||
result = agent.rewrite("What about its performance?", chat_history=chat_history)
|
||||
|
||||
# With search tools for context-aware rewriting
|
||||
from praisonaiagents.tools import internet_search
|
||||
agent = QueryRewriterAgent(tools=[internet_search], verbose=True)
|
||||
result = agent.rewrite("latest AI developments") # Searches first, then rewrites
|
||||
"""
|
||||
|
||||
import logging
|
||||
import json
|
||||
from typing import List, Optional, Any, Dict
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class RewriteStrategy(Enum):
|
||||
"""Enumeration of available query rewriting strategies."""
|
||||
BASIC = "basic"
|
||||
HYDE = "hyde"
|
||||
STEP_BACK = "step_back"
|
||||
SUB_QUERIES = "sub_queries"
|
||||
MULTI_QUERY = "multi_query"
|
||||
CONTEXTUAL = "contextual"
|
||||
AUTO = "auto"
|
||||
|
||||
|
||||
@dataclass
|
||||
class RewriteResult:
|
||||
"""Result of a query rewriting operation."""
|
||||
original_query: str
|
||||
rewritten_queries: List[str]
|
||||
strategy_used: RewriteStrategy
|
||||
hypothetical_document: Optional[str] = None
|
||||
step_back_question: Optional[str] = None
|
||||
sub_queries: Optional[List[str]] = None
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def __repr__(self):
|
||||
return f"RewriteResult(strategy={self.strategy_used.value}, queries={len(self.rewritten_queries)})"
|
||||
|
||||
@property
|
||||
def primary_query(self) -> str:
|
||||
"""Returns the primary rewritten query."""
|
||||
return self.rewritten_queries[0] if self.rewritten_queries else self.original_query
|
||||
|
||||
@property
|
||||
def all_queries(self) -> List[str]:
|
||||
"""Returns all queries including original and rewritten."""
|
||||
queries = [self.original_query]
|
||||
queries.extend(self.rewritten_queries)
|
||||
if self.step_back_question:
|
||||
queries.append(self.step_back_question)
|
||||
if self.sub_queries:
|
||||
queries.extend(self.sub_queries)
|
||||
return list(set(queries)) # Remove duplicates
|
||||
|
||||
|
||||
class QueryRewriterAgent:
|
||||
"""
|
||||
Agent for rewriting queries to improve retrieval quality in RAG applications.
|
||||
|
||||
This agent transforms user queries using various strategies to bridge the gap
|
||||
between how users ask questions and how information is stored in knowledge bases.
|
||||
|
||||
Attributes:
|
||||
name: Name of the agent
|
||||
model: LLM model to use for rewriting
|
||||
verbose: Whether to print detailed logs
|
||||
max_queries: Maximum number of queries to generate for multi-query strategy
|
||||
abbreviations: Dictionary of common abbreviations to expand
|
||||
|
||||
Example:
|
||||
agent = QueryRewriterAgent(model="gpt-4o-mini", verbose=True)
|
||||
result = agent.rewrite("ML best practices", strategy=RewriteStrategy.MULTI_QUERY)
|
||||
for query in result.rewritten_queries:
|
||||
print(query)
|
||||
"""
|
||||
|
||||
# Default prompts for each strategy
|
||||
PROMPTS = {
|
||||
"basic": """You are an expert at improving prompts and queries.
|
||||
|
||||
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 too short
|
||||
- Keep the original intent and action intact
|
||||
|
||||
IMPORTANT: If the input is a command/task to DO something, the output must also be a command/task to DO that thing.
|
||||
|
||||
Input: {query}
|
||||
|
||||
Improved version:""",
|
||||
|
||||
"hyde": """You are an expert at generating hypothetical documents.
|
||||
|
||||
Given the following query, write a detailed hypothetical document that would perfectly answer this query. This document will be used to find similar real documents in a knowledge base.
|
||||
|
||||
Guidelines:
|
||||
- Write as if you are answering the query directly
|
||||
- Include specific details, facts, and terminology
|
||||
- Use the same style and vocabulary as the target documents
|
||||
- Be comprehensive but focused on the query topic
|
||||
|
||||
Query: {query}
|
||||
|
||||
Hypothetical document:""",
|
||||
|
||||
"step_back": """You are an expert at generating step-back questions.
|
||||
|
||||
Given the following query, generate a more general, higher-level question that captures the broader concept. This helps retrieve background information needed to answer the specific query.
|
||||
|
||||
Guidelines:
|
||||
- Identify the underlying concept or principle
|
||||
- Create a question about fundamentals or general knowledge
|
||||
- The step-back question should provide context for the original query
|
||||
|
||||
Query: {query}
|
||||
|
||||
Step-back question:""",
|
||||
|
||||
"sub_queries": """You are an expert at decomposing complex queries.
|
||||
|
||||
Given the following query, break it down into simpler, focused sub-queries. Each sub-query should address one specific aspect of the original question.
|
||||
|
||||
Guidelines:
|
||||
- Identify distinct aspects or components of the query
|
||||
- Create independent, self-contained sub-queries
|
||||
- Each sub-query should be answerable on its own
|
||||
- Return as a JSON array of strings
|
||||
|
||||
Query: {query}
|
||||
|
||||
Sub-queries (JSON array):""",
|
||||
|
||||
"multi_query": """You are an expert at query expansion and paraphrasing.
|
||||
|
||||
Given the following query, generate {num_queries} different versions that capture the same intent but use different words, phrases, or perspectives.
|
||||
|
||||
Guidelines:
|
||||
- Use synonyms and alternative phrasings
|
||||
- Try different question formats (what, how, why, etc.)
|
||||
- Include both formal and informal versions
|
||||
- Add relevant context or specificity
|
||||
- Return as a JSON array of strings
|
||||
|
||||
Query: {query}
|
||||
|
||||
Alternative queries (JSON array):""",
|
||||
|
||||
"contextual": """You are an expert at understanding conversational context.
|
||||
|
||||
Given the conversation history and a follow-up query, rewrite the query as a standalone question that captures all relevant context.
|
||||
|
||||
Guidelines:
|
||||
- Resolve pronouns and references (it, this, that, etc.)
|
||||
- Include relevant context from the conversation
|
||||
- Keep entity names and important details
|
||||
- Make the query self-contained and clear
|
||||
|
||||
Conversation history:
|
||||
{chat_history}
|
||||
|
||||
Follow-up query: {query}
|
||||
|
||||
Standalone question:""",
|
||||
|
||||
"auto": """You are an expert at analyzing and improving prompts and queries.
|
||||
|
||||
Analyze the following input and improve it while preserving its original intent and type.
|
||||
|
||||
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
|
||||
|
||||
IMPORTANT: Never convert a task into a question. "Write X" should remain "Write X", not "How to write X?"
|
||||
|
||||
Input: {query}
|
||||
{context}
|
||||
|
||||
Improved version:"""
|
||||
}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str = "QueryRewriterAgent",
|
||||
model: str = "gpt-4o-mini",
|
||||
instructions: Optional[str] = None,
|
||||
verbose: bool = False,
|
||||
max_queries: int = 5,
|
||||
abbreviations: Optional[Dict[str, str]] = None,
|
||||
temperature: float = 0.3,
|
||||
max_tokens: int = 500,
|
||||
tools: Optional[List[Any]] = None
|
||||
):
|
||||
"""
|
||||
Initialize the QueryRewriterAgent.
|
||||
|
||||
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
|
||||
max_queries: Maximum queries for multi-query strategy
|
||||
abbreviations: Custom abbreviation expansions
|
||||
temperature: Temperature for LLM generation
|
||||
max_tokens: Maximum tokens for LLM response
|
||||
tools: Optional list of tools (agent decides when to use them)
|
||||
"""
|
||||
self.name = name
|
||||
self.model = model
|
||||
self.instructions = instructions
|
||||
self.verbose = verbose
|
||||
self.max_queries = max_queries
|
||||
self.temperature = temperature
|
||||
self.max_tokens = max_tokens
|
||||
self.tools = tools or []
|
||||
self._agent = None # Lazy initialized
|
||||
|
||||
# Default abbreviations (can be extended)
|
||||
self.abbreviations = abbreviations or {
|
||||
"AI": "Artificial Intelligence",
|
||||
"ML": "Machine Learning",
|
||||
"NLP": "Natural Language Processing",
|
||||
"LLM": "Large Language Model",
|
||||
"RAG": "Retrieval Augmented Generation",
|
||||
"API": "Application Programming Interface",
|
||||
"DB": "Database",
|
||||
"UI": "User Interface",
|
||||
"UX": "User Experience",
|
||||
"SDK": "Software Development Kit",
|
||||
"CLI": "Command Line Interface",
|
||||
}
|
||||
|
||||
# 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="Query Rewriting Specialist",
|
||||
goal="Rewrite queries for better search and retrieval results. Use available tools when needed to gather context.",
|
||||
backstory="You are an expert at understanding user intent and transforming queries for optimal information retrieval.",
|
||||
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 _expand_abbreviations(self, query: str) -> str:
|
||||
"""Expand known abbreviations in the query."""
|
||||
words = query.split()
|
||||
expanded = []
|
||||
for word in words:
|
||||
clean_word = word.strip(".,!?;:")
|
||||
if clean_word.upper() in self.abbreviations:
|
||||
expanded.append(f"{clean_word} ({self.abbreviations[clean_word.upper()]})")
|
||||
else:
|
||||
expanded.append(word)
|
||||
return " ".join(expanded)
|
||||
|
||||
def _is_short_query(self, query: str, threshold: int = 5) -> bool:
|
||||
"""Check if query is considered short (keyword-style)."""
|
||||
return len(query.split()) < threshold
|
||||
|
||||
def _parse_json_array(self, text: str) -> List[str]:
|
||||
"""Parse a JSON array from LLM response."""
|
||||
try:
|
||||
# Try direct JSON parsing
|
||||
result = json.loads(text)
|
||||
if isinstance(result, list):
|
||||
return [str(item) for item in result]
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
# Try to extract JSON array from text
|
||||
import re
|
||||
match = re.search(r'\[.*?\]', text, re.DOTALL)
|
||||
if match:
|
||||
try:
|
||||
result = json.loads(match.group())
|
||||
if isinstance(result, list):
|
||||
return [str(item) for item in result]
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
# Fallback: split by newlines and clean up
|
||||
lines = [line.strip() for line in text.split('\n') if line.strip()]
|
||||
lines = [line.lstrip('0123456789.-) ').strip('"\'') for line in lines]
|
||||
return [line for line in lines if line and len(line) > 3]
|
||||
|
||||
def rewrite(
|
||||
self,
|
||||
query: str,
|
||||
strategy: RewriteStrategy = RewriteStrategy.AUTO,
|
||||
chat_history: Optional[List[Dict[str, str]]] = None,
|
||||
context: Optional[str] = None,
|
||||
num_queries: int = None
|
||||
) -> RewriteResult:
|
||||
"""
|
||||
Rewrite a query 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 query context.
|
||||
|
||||
Args:
|
||||
query: The original user query
|
||||
strategy: Rewriting strategy to use
|
||||
chat_history: Previous conversation messages for contextual rewriting
|
||||
context: Additional context about the knowledge base
|
||||
num_queries: Number of queries for multi-query strategy
|
||||
|
||||
Returns:
|
||||
RewriteResult containing rewritten queries
|
||||
"""
|
||||
if self.verbose:
|
||||
self.logger.debug(f"Rewriting query: '{query}' 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 == RewriteStrategy.AUTO:
|
||||
strategy = self._detect_strategy(query, chat_history)
|
||||
if self.verbose:
|
||||
self.logger.debug(f"Auto-detected strategy: {strategy.value}")
|
||||
|
||||
# All calls go through the internal Agent which handles tool calling
|
||||
combined_context = context or ""
|
||||
|
||||
# Dispatch to appropriate method
|
||||
if strategy == RewriteStrategy.BASIC:
|
||||
return self._rewrite_basic(query, combined_context)
|
||||
elif strategy == RewriteStrategy.HYDE:
|
||||
return self._rewrite_hyde(query, combined_context)
|
||||
elif strategy == RewriteStrategy.STEP_BACK:
|
||||
return self._rewrite_step_back(query, combined_context)
|
||||
elif strategy == RewriteStrategy.SUB_QUERIES:
|
||||
return self._rewrite_sub_queries(query, combined_context)
|
||||
elif strategy == RewriteStrategy.MULTI_QUERY:
|
||||
return self._rewrite_multi_query(query, num_queries or self.max_queries, combined_context)
|
||||
elif strategy == RewriteStrategy.CONTEXTUAL:
|
||||
return self._rewrite_contextual(query, chat_history or [], combined_context)
|
||||
else:
|
||||
# Default to basic
|
||||
return self._rewrite_basic(query, combined_context)
|
||||
|
||||
def _detect_strategy(
|
||||
self,
|
||||
query: str,
|
||||
chat_history: Optional[List[Dict[str, str]]] = None
|
||||
) -> RewriteStrategy:
|
||||
"""Auto-detect the best rewriting strategy for a query."""
|
||||
# If chat history exists and query seems like a follow-up
|
||||
if chat_history and self._is_follow_up(query):
|
||||
return RewriteStrategy.CONTEXTUAL
|
||||
|
||||
# Short keyword queries
|
||||
if self._is_short_query(query):
|
||||
return RewriteStrategy.BASIC
|
||||
|
||||
# Complex multi-part queries
|
||||
if self._is_complex_query(query):
|
||||
return RewriteStrategy.SUB_QUERIES
|
||||
|
||||
# Questions that might benefit from step-back
|
||||
if self._needs_step_back(query):
|
||||
return RewriteStrategy.STEP_BACK
|
||||
|
||||
# Default to basic
|
||||
return RewriteStrategy.BASIC
|
||||
|
||||
def _is_follow_up(self, query: str) -> bool:
|
||||
"""Check if query appears to be a follow-up question."""
|
||||
follow_up_indicators = [
|
||||
"it", "this", "that", "they", "them", "their",
|
||||
"what about", "how about", "and", "also", "more",
|
||||
"same", "similar", "related"
|
||||
]
|
||||
query_lower = query.lower()
|
||||
return any(indicator in query_lower for indicator in follow_up_indicators)
|
||||
|
||||
def _is_complex_query(self, query: str) -> bool:
|
||||
"""Check if query contains multiple distinct questions."""
|
||||
# Check for multiple question marks or conjunctions
|
||||
if query.count('?') > 1:
|
||||
return True
|
||||
complex_indicators = [" and ", " or ", " also ", " as well as ", " plus "]
|
||||
return any(ind in query.lower() for ind in complex_indicators)
|
||||
|
||||
def _needs_step_back(self, query: str) -> bool:
|
||||
"""Check if query would benefit from a step-back question."""
|
||||
# Queries about specific details often benefit from broader context
|
||||
specific_indicators = [
|
||||
"specific", "exactly", "precisely", "particular",
|
||||
"difference between", "compare", "versus", "vs"
|
||||
]
|
||||
return any(ind in query.lower() for ind in specific_indicators)
|
||||
|
||||
def _rewrite_basic(self, query: str, context: str = "") -> RewriteResult:
|
||||
"""Basic query rewriting for clarity and keyword optimization."""
|
||||
# First expand abbreviations
|
||||
expanded_query = self._expand_abbreviations(query)
|
||||
|
||||
# Build prompt with optional context
|
||||
prompt = self.PROMPTS["basic"].format(query=expanded_query)
|
||||
if context:
|
||||
prompt += f"\n\nAdditional context to help with rewriting:\n{context}"
|
||||
|
||||
rewritten = self._call_agent(prompt).strip()
|
||||
|
||||
return RewriteResult(
|
||||
original_query=query,
|
||||
rewritten_queries=[rewritten],
|
||||
strategy_used=RewriteStrategy.BASIC,
|
||||
metadata={"expanded_abbreviations": expanded_query != query, "has_context": bool(context)}
|
||||
)
|
||||
|
||||
def _rewrite_hyde(self, query: str, context: str = "") -> RewriteResult:
|
||||
"""HyDE: Generate hypothetical document for better semantic matching."""
|
||||
prompt = self.PROMPTS["hyde"].format(query=query)
|
||||
if context:
|
||||
prompt += f"\n\nUse this context to make the hypothetical document more accurate:\n{context}"
|
||||
|
||||
hypothetical_doc = self._call_agent(prompt).strip()
|
||||
|
||||
# The hypothetical document itself becomes the search query
|
||||
return RewriteResult(
|
||||
original_query=query,
|
||||
rewritten_queries=[hypothetical_doc[:500]], # Truncate for embedding
|
||||
strategy_used=RewriteStrategy.HYDE,
|
||||
hypothetical_document=hypothetical_doc,
|
||||
metadata={"full_document_length": len(hypothetical_doc), "has_context": bool(context)}
|
||||
)
|
||||
|
||||
def _rewrite_step_back(self, query: str, context: str = "") -> RewriteResult:
|
||||
"""Generate step-back question for broader context retrieval."""
|
||||
# Generate step-back question
|
||||
prompt = self.PROMPTS["step_back"].format(query=query)
|
||||
if context:
|
||||
prompt += f"\n\nContext:\n{context}"
|
||||
step_back = self._call_agent(prompt).strip()
|
||||
|
||||
# Also do basic rewriting of original
|
||||
basic_result = self._rewrite_basic(query, context)
|
||||
|
||||
return RewriteResult(
|
||||
original_query=query,
|
||||
rewritten_queries=basic_result.rewritten_queries,
|
||||
strategy_used=RewriteStrategy.STEP_BACK,
|
||||
step_back_question=step_back,
|
||||
metadata={"includes_step_back": True, "has_context": bool(context)}
|
||||
)
|
||||
|
||||
def _rewrite_sub_queries(self, query: str, context: str = "") -> RewriteResult:
|
||||
"""Decompose complex query into focused sub-queries."""
|
||||
prompt = self.PROMPTS["sub_queries"].format(query=query)
|
||||
if context:
|
||||
prompt += f"\n\nContext to help with decomposition:\n{context}"
|
||||
response = self._call_agent(prompt).strip()
|
||||
|
||||
sub_queries = self._parse_json_array(response)
|
||||
|
||||
# Ensure we have at least one sub-query
|
||||
if not sub_queries:
|
||||
sub_queries = [query]
|
||||
|
||||
return RewriteResult(
|
||||
original_query=query,
|
||||
rewritten_queries=sub_queries,
|
||||
strategy_used=RewriteStrategy.SUB_QUERIES,
|
||||
sub_queries=sub_queries,
|
||||
metadata={"num_sub_queries": len(sub_queries), "has_context": bool(context)}
|
||||
)
|
||||
|
||||
def _rewrite_multi_query(self, query: str, num_queries: int, context: str = "") -> RewriteResult:
|
||||
"""Generate multiple paraphrased versions for ensemble retrieval."""
|
||||
prompt = self.PROMPTS["multi_query"].format(
|
||||
query=query,
|
||||
num_queries=num_queries
|
||||
)
|
||||
if context:
|
||||
prompt += f"\n\nContext to help with paraphrasing:\n{context}"
|
||||
response = self._call_agent(prompt).strip()
|
||||
|
||||
queries = self._parse_json_array(response)
|
||||
|
||||
# Ensure we have queries and limit to max
|
||||
if not queries:
|
||||
queries = [query]
|
||||
queries = queries[:num_queries]
|
||||
|
||||
return RewriteResult(
|
||||
original_query=query,
|
||||
rewritten_queries=queries,
|
||||
strategy_used=RewriteStrategy.MULTI_QUERY,
|
||||
metadata={"num_generated": len(queries), "has_context": bool(context)}
|
||||
)
|
||||
|
||||
def _rewrite_contextual(
|
||||
self,
|
||||
query: str,
|
||||
chat_history: List[Dict[str, str]],
|
||||
context: str = ""
|
||||
) -> RewriteResult:
|
||||
"""Rewrite query using conversation context."""
|
||||
# Format chat history
|
||||
history_str = ""
|
||||
for msg in chat_history[-10:]: # Last 10 messages
|
||||
role = msg.get("role", "user")
|
||||
content = msg.get("content", "")
|
||||
history_str += f"{role.capitalize()}: {content}\n"
|
||||
|
||||
prompt = self.PROMPTS["contextual"].format(
|
||||
chat_history=history_str,
|
||||
query=query
|
||||
)
|
||||
if context:
|
||||
prompt += f"\n\nAdditional context:\n{context}"
|
||||
rewritten = self._call_agent(prompt).strip()
|
||||
|
||||
return RewriteResult(
|
||||
original_query=query,
|
||||
rewritten_queries=[rewritten],
|
||||
strategy_used=RewriteStrategy.CONTEXTUAL,
|
||||
metadata={
|
||||
"history_length": len(chat_history),
|
||||
"history_used": min(10, len(chat_history)),
|
||||
"has_context": bool(context)
|
||||
}
|
||||
)
|
||||
|
||||
# Convenience methods for direct strategy access
|
||||
def rewrite_basic(self, query: str) -> RewriteResult:
|
||||
"""Convenience method for basic rewriting."""
|
||||
return self.rewrite(query, strategy=RewriteStrategy.BASIC)
|
||||
|
||||
def rewrite_hyde(self, query: str) -> RewriteResult:
|
||||
"""Convenience method for HyDE rewriting."""
|
||||
return self.rewrite(query, strategy=RewriteStrategy.HYDE)
|
||||
|
||||
def rewrite_step_back(self, query: str) -> RewriteResult:
|
||||
"""Convenience method for step-back rewriting."""
|
||||
return self.rewrite(query, strategy=RewriteStrategy.STEP_BACK)
|
||||
|
||||
def rewrite_sub_queries(self, query: str) -> RewriteResult:
|
||||
"""Convenience method for sub-query decomposition."""
|
||||
return self.rewrite(query, strategy=RewriteStrategy.SUB_QUERIES)
|
||||
|
||||
def rewrite_multi_query(self, query: str, num_queries: int = None) -> RewriteResult:
|
||||
"""Convenience method for multi-query generation."""
|
||||
return self.rewrite(
|
||||
query,
|
||||
strategy=RewriteStrategy.MULTI_QUERY,
|
||||
num_queries=num_queries
|
||||
)
|
||||
|
||||
def rewrite_contextual(
|
||||
self,
|
||||
query: str,
|
||||
chat_history: List[Dict[str, str]]
|
||||
) -> RewriteResult:
|
||||
"""Convenience method for contextual rewriting."""
|
||||
return self.rewrite(
|
||||
query,
|
||||
strategy=RewriteStrategy.CONTEXTUAL,
|
||||
chat_history=chat_history
|
||||
)
|
||||
|
||||
def add_abbreviation(self, abbrev: str, expansion: str) -> None:
|
||||
"""Add a custom abbreviation expansion."""
|
||||
self.abbreviations[abbrev.upper()] = expansion
|
||||
|
||||
def add_abbreviations(self, abbreviations: Dict[str, str]) -> None:
|
||||
"""Add multiple custom abbreviation expansions."""
|
||||
for abbrev, expansion in abbreviations.items():
|
||||
self.abbreviations[abbrev.upper()] = expansion
|
||||
@@ -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)
|
||||
@@ -9,6 +9,85 @@ A tool is a piece of code that helps our AI agents perform specific tasks. Think
|
||||
- A stock market tool lets agents check stock prices
|
||||
- A weather tool lets agents check the weather
|
||||
|
||||
## Plugin System (NEW!)
|
||||
|
||||
PraisonAI now supports a plugin system for creating and distributing tools. External developers can create pip-installable tool packages!
|
||||
|
||||
### Quick Start
|
||||
|
||||
```python
|
||||
from praisonaiagents import BaseTool, tool, Agent
|
||||
|
||||
# Method 1: Class-based tool
|
||||
class WeatherTool(BaseTool):
|
||||
name = "get_weather"
|
||||
description = "Get weather for a location"
|
||||
|
||||
def run(self, location: str) -> dict:
|
||||
return {"temp": 72, "condition": "sunny"}
|
||||
|
||||
# Method 2: Decorator-based tool
|
||||
@tool
|
||||
def search(query: str) -> list:
|
||||
"""Search the web."""
|
||||
return [{"title": "Result", "url": "https://..."}]
|
||||
|
||||
# Use with Agent
|
||||
agent = Agent(
|
||||
name="Assistant",
|
||||
tools=[WeatherTool(), search]
|
||||
)
|
||||
```
|
||||
|
||||
### Creating a Plugin Package
|
||||
|
||||
External developers can create pip-installable plugins:
|
||||
|
||||
**1. Create your tool package:**
|
||||
```
|
||||
praisonai-weather/
|
||||
├── pyproject.toml
|
||||
├── src/
|
||||
│ └── praisonai_weather/
|
||||
│ ├── __init__.py
|
||||
│ └── tools.py
|
||||
```
|
||||
|
||||
**2. Define your tool in `tools.py`:**
|
||||
```python
|
||||
from praisonaiagents import BaseTool
|
||||
|
||||
class WeatherTool(BaseTool):
|
||||
name = "weather"
|
||||
description = "Get current weather"
|
||||
|
||||
def run(self, location: str) -> dict:
|
||||
# Your implementation
|
||||
return {"temp": 72}
|
||||
```
|
||||
|
||||
**3. Register via entry_points in `pyproject.toml`:**
|
||||
```toml
|
||||
[project]
|
||||
name = "praisonai-weather"
|
||||
version = "1.0.0"
|
||||
|
||||
[project.entry-points."praisonaiagents.tools"]
|
||||
weather = "praisonai_weather.tools:WeatherTool"
|
||||
```
|
||||
|
||||
**4. Users install and use:**
|
||||
```bash
|
||||
pip install praisonai-weather
|
||||
```
|
||||
|
||||
```python
|
||||
from praisonaiagents import Agent
|
||||
|
||||
# Tool is auto-discovered!
|
||||
agent = Agent(tools=["weather"])
|
||||
```
|
||||
|
||||
## Creating New Tools: The Two Approaches
|
||||
|
||||
### 1. Function-Based Approach (Simple Tools)
|
||||
|
||||
@@ -173,6 +173,41 @@ TOOL_MAPPINGS = {
|
||||
'cot_save': ('.train.data.generatecot', 'GenerateCOT'), # Save single QA to file
|
||||
'cot_upload_to_huggingface': ('.train.data.generatecot', 'GenerateCOT'), # Upload dataset to HuggingFace
|
||||
'cot_tools': ('.train.data.generatecot', 'GenerateCOT'), # Full toolkit access
|
||||
|
||||
# Claude Memory Tool (Anthropic Beta)
|
||||
'ClaudeMemoryTool': ('.claude_memory_tool', 'ClaudeMemoryTool'),
|
||||
|
||||
# Tavily Tools (requires TAVILY_API_KEY)
|
||||
'tavily': ('.tavily_tools', None),
|
||||
'tavily_search': ('.tavily_tools', None),
|
||||
'tavily_extract': ('.tavily_tools', None),
|
||||
'tavily_crawl': ('.tavily_tools', None),
|
||||
'tavily_map': ('.tavily_tools', None),
|
||||
'tavily_search_async': ('.tavily_tools', None),
|
||||
'tavily_extract_async': ('.tavily_tools', None),
|
||||
'TavilyTools': ('.tavily_tools', 'TavilyTools'),
|
||||
'tavily_tools': ('.tavily_tools', None),
|
||||
|
||||
# You.com Tools (requires YDC_API_KEY)
|
||||
'ydc': ('.youdotcom_tools', None),
|
||||
'ydc_search': ('.youdotcom_tools', None),
|
||||
'ydc_contents': ('.youdotcom_tools', None),
|
||||
'ydc_news': ('.youdotcom_tools', None),
|
||||
'ydc_images': ('.youdotcom_tools', None),
|
||||
'YouTools': ('.youdotcom_tools', 'YouTools'),
|
||||
'youdotcom_tools': ('.youdotcom_tools', None),
|
||||
|
||||
# Exa Tools (requires EXA_API_KEY)
|
||||
'exa': ('.exa_tools', None),
|
||||
'exa_search': ('.exa_tools', None),
|
||||
'exa_search_contents': ('.exa_tools', None),
|
||||
'exa_find_similar': ('.exa_tools', None),
|
||||
'exa_answer': ('.exa_tools', None),
|
||||
'exa_search_async': ('.exa_tools', None),
|
||||
'exa_search_contents_async': ('.exa_tools', None),
|
||||
'exa_answer_async': ('.exa_tools', None),
|
||||
'ExaTools': ('.exa_tools', 'ExaTools'),
|
||||
'exa_tools': ('.exa_tools', None),
|
||||
}
|
||||
|
||||
_instances = {} # Cache for class instances
|
||||
@@ -184,6 +219,11 @@ def __getattr__(name: str) -> Any:
|
||||
|
||||
module_path, class_name = TOOL_MAPPINGS[name]
|
||||
|
||||
# Return class itself (not instance) for ClaudeMemoryTool, TavilyTools, YouTools, ExaTools
|
||||
if name in ('ClaudeMemoryTool', 'TavilyTools', 'YouTools', 'ExaTools'):
|
||||
module = import_module(module_path, __package__)
|
||||
return getattr(module, class_name)
|
||||
|
||||
if class_name is None:
|
||||
# Direct function import
|
||||
module = import_module(module_path, __package__)
|
||||
@@ -197,11 +237,16 @@ def __getattr__(name: str) -> Any:
|
||||
'insert_document', 'insert_documents', 'find_documents', 'update_document', 'delete_document',
|
||||
'create_vector_index', 'vector_search', 'store_with_embedding', 'text_search', 'get_stats', 'connect_mongodb',
|
||||
'execute_command', 'list_processes', 'kill_process', 'get_system_info',
|
||||
'evaluate', 'solve_equation', 'convert_units', 'calculate_statistics', 'calculate_financial'
|
||||
'evaluate', 'solve_equation', 'convert_units', 'calculate_statistics', 'calculate_financial',
|
||||
'tavily', 'tavily_search', 'tavily_extract', 'tavily_crawl', 'tavily_map',
|
||||
'tavily_search_async', 'tavily_extract_async',
|
||||
'ydc', 'ydc_search', 'ydc_contents', 'ydc_news', 'ydc_images',
|
||||
'exa', 'exa_search', 'exa_search_contents', 'exa_find_similar', 'exa_answer',
|
||||
'exa_search_async', 'exa_search_contents_async', 'exa_answer_async'
|
||||
]:
|
||||
return getattr(module, name)
|
||||
if name in ['file_tools', 'pandas_tools', 'wikipedia_tools',
|
||||
'newspaper_tools', 'arxiv_tools', 'spider_tools', 'duckdb_tools', 'mongodb_tools', 'csv_tools', 'json_tools', 'excel_tools', 'xml_tools', 'yaml_tools', 'calculator_tools', 'python_tools', 'shell_tools', 'cot_tools']:
|
||||
'newspaper_tools', 'arxiv_tools', 'spider_tools', 'duckdb_tools', 'mongodb_tools', 'csv_tools', 'json_tools', 'excel_tools', 'xml_tools', 'yaml_tools', 'calculator_tools', 'python_tools', 'shell_tools', 'cot_tools', 'tavily_tools', 'youdotcom_tools', 'exa_tools']:
|
||||
return module # Returns the callable module
|
||||
return getattr(module, name)
|
||||
else:
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
"""Base classes for PraisonAI Agent tools.
|
||||
|
||||
This module provides the foundation for creating tools that can be used by agents.
|
||||
External developers can create plugins by subclassing BaseTool.
|
||||
|
||||
Usage:
|
||||
from praisonaiagents import BaseTool
|
||||
|
||||
class MyTool(BaseTool):
|
||||
name = "my_tool"
|
||||
description = "Does something useful"
|
||||
|
||||
def run(self, query: str) -> str:
|
||||
return f"Result for {query}"
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any, Dict, Optional, Type, get_type_hints
|
||||
import inspect
|
||||
import logging
|
||||
|
||||
|
||||
class ToolValidationError(Exception):
|
||||
"""Raised when a tool fails validation."""
|
||||
pass
|
||||
|
||||
|
||||
class ToolResult:
|
||||
"""Wrapper for tool execution results."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
output: Any,
|
||||
success: bool = True,
|
||||
error: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None
|
||||
):
|
||||
self.output = output
|
||||
self.success = success
|
||||
self.error = error
|
||||
self.metadata = metadata or {}
|
||||
|
||||
def __str__(self) -> str:
|
||||
if self.success:
|
||||
return str(self.output)
|
||||
return f"Error: {self.error}"
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"ToolResult(success={self.success}, output={self.output!r})"
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"output": self.output,
|
||||
"success": self.success,
|
||||
"error": self.error,
|
||||
"metadata": self.metadata
|
||||
}
|
||||
|
||||
|
||||
class BaseTool(ABC):
|
||||
"""Abstract base class for all PraisonAI tools.
|
||||
|
||||
Subclass this to create custom tools that can be:
|
||||
- Used directly by agents
|
||||
- Distributed as pip-installable plugins
|
||||
- Auto-discovered via entry_points
|
||||
|
||||
Attributes:
|
||||
name: Unique identifier for the tool
|
||||
description: Human-readable description (used by LLM)
|
||||
version: Tool version string (default: "1.0.0")
|
||||
parameters: JSON Schema for parameters (auto-generated if not provided)
|
||||
|
||||
Example:
|
||||
class WeatherTool(BaseTool):
|
||||
name = "get_weather"
|
||||
description = "Get current weather for a location"
|
||||
|
||||
def run(self, location: str, units: str = "celsius") -> dict:
|
||||
# Implementation here
|
||||
return {"temp": 22, "condition": "sunny"}
|
||||
"""
|
||||
|
||||
# Required class attributes (must be overridden)
|
||||
name: str = ""
|
||||
description: str = ""
|
||||
|
||||
# Optional class attributes
|
||||
version: str = "1.0.0"
|
||||
parameters: Optional[Dict[str, Any]] = None # JSON Schema, auto-generated if None
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the tool and validate configuration."""
|
||||
if not self.name:
|
||||
# Use class name as default
|
||||
self.name = self.__class__.__name__.lower().replace("tool", "")
|
||||
|
||||
if not self.description:
|
||||
# Use docstring as default
|
||||
self.description = self.__class__.__doc__ or f"Tool: {self.name}"
|
||||
|
||||
# Auto-generate parameters schema if not provided
|
||||
if self.parameters is None:
|
||||
self.parameters = self._generate_parameters_schema()
|
||||
|
||||
def _generate_parameters_schema(self) -> Dict[str, Any]:
|
||||
"""Generate JSON Schema from run() method signature."""
|
||||
schema = {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"required": []
|
||||
}
|
||||
|
||||
try:
|
||||
sig = inspect.signature(self.run)
|
||||
hints = get_type_hints(self.run) if hasattr(self.run, '__annotations__') else {}
|
||||
|
||||
for param_name, param in sig.parameters.items():
|
||||
if param_name == 'self':
|
||||
continue
|
||||
|
||||
# Get type hint
|
||||
param_type = hints.get(param_name, Any)
|
||||
json_type = self._python_type_to_json(param_type)
|
||||
|
||||
# Build property schema
|
||||
prop_schema = {"type": json_type}
|
||||
|
||||
# Add description from docstring if available
|
||||
# (Could parse docstring for param descriptions)
|
||||
|
||||
schema["properties"][param_name] = prop_schema
|
||||
|
||||
# Check if required (no default value)
|
||||
if param.default is inspect.Parameter.empty:
|
||||
schema["required"].append(param_name)
|
||||
except Exception as e:
|
||||
logging.debug(f"Could not generate schema for {self.name}: {e}")
|
||||
|
||||
return schema
|
||||
|
||||
@staticmethod
|
||||
def _python_type_to_json(python_type: Type) -> str:
|
||||
"""Convert Python type to JSON Schema type."""
|
||||
type_map = {
|
||||
str: "string",
|
||||
int: "integer",
|
||||
float: "number",
|
||||
bool: "boolean",
|
||||
list: "array",
|
||||
dict: "object",
|
||||
type(None): "null"
|
||||
}
|
||||
|
||||
# Handle Optional, Union, etc.
|
||||
origin = getattr(python_type, '__origin__', None)
|
||||
if origin is not None:
|
||||
# For List[X], return "array"
|
||||
if origin is list:
|
||||
return "array"
|
||||
# For Dict[X, Y], return "object"
|
||||
if origin is dict:
|
||||
return "object"
|
||||
|
||||
return type_map.get(python_type, "string")
|
||||
|
||||
@abstractmethod
|
||||
def run(self, **kwargs) -> Any:
|
||||
"""Execute the tool with given arguments.
|
||||
|
||||
This method must be implemented by subclasses.
|
||||
|
||||
Args:
|
||||
**kwargs: Tool-specific arguments
|
||||
|
||||
Returns:
|
||||
Tool output (any type, will be converted to string for LLM)
|
||||
"""
|
||||
pass
|
||||
|
||||
def __call__(self, **kwargs) -> Any:
|
||||
"""Allow tool to be called directly like a function."""
|
||||
return self.run(**kwargs)
|
||||
|
||||
def safe_run(self, **kwargs) -> ToolResult:
|
||||
"""Execute tool with error handling, returning ToolResult."""
|
||||
try:
|
||||
output = self.run(**kwargs)
|
||||
return ToolResult(output=output, success=True)
|
||||
except Exception as e:
|
||||
logging.error(f"Tool {self.name} failed: {e}")
|
||||
return ToolResult(
|
||||
output=None,
|
||||
success=False,
|
||||
error=str(e)
|
||||
)
|
||||
|
||||
def get_schema(self) -> Dict[str, Any]:
|
||||
"""Get OpenAI-compatible function schema for this tool."""
|
||||
return {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"parameters": self.parameters
|
||||
}
|
||||
}
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.__class__.__name__}(name='{self.name}')"
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"{self.__class__.__name__}(name='{self.name}', description='{self.description[:50]}...')"
|
||||
|
||||
def validate(self) -> bool:
|
||||
"""Validate the tool configuration.
|
||||
|
||||
Raises:
|
||||
ToolValidationError: If validation fails
|
||||
|
||||
Returns:
|
||||
True if validation passes
|
||||
"""
|
||||
errors = []
|
||||
|
||||
# Check required fields
|
||||
if not self.name or not isinstance(self.name, str):
|
||||
errors.append("Tool must have a non-empty string 'name'")
|
||||
|
||||
if not self.description or not isinstance(self.description, str):
|
||||
errors.append("Tool must have a non-empty string 'description'")
|
||||
|
||||
# Check that run() is implemented (not abstract)
|
||||
if getattr(self.run, '__isabstractmethod__', False):
|
||||
errors.append("Tool must implement the 'run()' method")
|
||||
|
||||
# Check parameters schema is valid
|
||||
if self.parameters:
|
||||
if not isinstance(self.parameters, dict):
|
||||
errors.append("'parameters' must be a dictionary")
|
||||
elif "type" not in self.parameters:
|
||||
errors.append("'parameters' must have a 'type' field")
|
||||
|
||||
if errors:
|
||||
raise ToolValidationError(f"Tool '{self.name}' validation failed: {'; '.join(errors)}")
|
||||
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
def validate_class(cls) -> bool:
|
||||
"""Validate a tool class before instantiation.
|
||||
|
||||
This can be used to check if a class is a valid tool without creating an instance.
|
||||
|
||||
Returns:
|
||||
True if the class appears to be a valid tool
|
||||
"""
|
||||
# Check it's a subclass of BaseTool
|
||||
if not issubclass(cls, BaseTool):
|
||||
return False
|
||||
|
||||
# Check it has required class attributes or will get them from __init__
|
||||
# (name and description can be set in __init__ so we can't strictly require them)
|
||||
|
||||
# Check run() is defined (not just inherited abstract)
|
||||
if 'run' not in cls.__dict__:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def validate_tool(tool: Any) -> bool:
|
||||
"""Validate any tool-like object.
|
||||
|
||||
Args:
|
||||
tool: Object to validate (BaseTool, callable, etc.)
|
||||
|
||||
Returns:
|
||||
True if valid
|
||||
|
||||
Raises:
|
||||
ToolValidationError: If validation fails
|
||||
"""
|
||||
if isinstance(tool, BaseTool):
|
||||
return tool.validate()
|
||||
|
||||
if callable(tool):
|
||||
# For plain functions, check they have a name
|
||||
name = getattr(tool, '__name__', None) or getattr(tool, 'name', None)
|
||||
if not name:
|
||||
raise ToolValidationError("Callable tool must have a __name__ or name attribute")
|
||||
return True
|
||||
|
||||
raise ToolValidationError(f"Invalid tool type: {type(tool)}")
|
||||
|
||||
|
||||
# For backward compatibility - tools can also just be functions
|
||||
# The @tool decorator (in decorator.py) wraps functions into BaseTool instances
|
||||
@@ -0,0 +1,529 @@
|
||||
"""
|
||||
Claude Memory Tool - File-based persistent memory for Claude models.
|
||||
|
||||
This module implements Anthropic's Memory Tool API (beta) which enables Claude
|
||||
to store and retrieve information across conversations through a memory file directory.
|
||||
|
||||
The memory tool operates client-side - you control where and how the data is stored.
|
||||
|
||||
Beta Header: context-management-2025-06-27
|
||||
Tool Type: memory_20250818
|
||||
|
||||
Supported Models:
|
||||
- Claude Sonnet 4.5 (claude-sonnet-4-5-20250929)
|
||||
- Claude Sonnet 4 (claude-sonnet-4-20250514)
|
||||
- Claude Haiku 4.5 (claude-haiku-4-5-20251001)
|
||||
- Claude Opus 4.5 (claude-opus-4-5-20251101)
|
||||
- Claude Opus 4.1 (claude-opus-4-1-20250805)
|
||||
- Claude Opus 4 (claude-opus-4-20250514)
|
||||
|
||||
Reference: https://docs.anthropic.com/en/docs/agents-and-tools/tool-use/memory-tool
|
||||
"""
|
||||
|
||||
import shutil
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Memory system prompt that Claude uses to manage memory
|
||||
MEMORY_SYSTEM_PROMPT = """
|
||||
IMPORTANT: ALWAYS VIEW YOUR MEMORY DIRECTORY BEFORE DOING ANYTHING ELSE.
|
||||
|
||||
MEMORY PROTOCOL:
|
||||
1. Use the `view` command of your `memory` tool to check for earlier progress.
|
||||
2. ... (work on the task) ...
|
||||
- As you make progress, record status / progress / thoughts etc in your memory.
|
||||
|
||||
ASSUME INTERRUPTION: Your context window might be reset at any moment, so you risk losing any progress that is not recorded in your memory directory.
|
||||
|
||||
When editing your memory folder, always try to keep its content up-to-date, coherent and organized. You can rename or delete files that are no longer relevant. Do not create new files unless necessary.
|
||||
"""
|
||||
|
||||
|
||||
class ClaudeMemoryTool:
|
||||
"""
|
||||
File-based memory tool for Claude models.
|
||||
|
||||
Implements Anthropic's Memory Tool API (beta). Claude autonomously decides
|
||||
what to store and retrieve from the memory directory.
|
||||
|
||||
Storage: {base_path}/memories/
|
||||
|
||||
Commands:
|
||||
- view: Show directory contents or file contents with optional line ranges
|
||||
- create: Create or overwrite a file
|
||||
- str_replace: Replace text in a file
|
||||
- insert: Insert text at a specific line
|
||||
- delete: Delete a file or directory
|
||||
- rename: Rename or move a file/directory
|
||||
|
||||
Example:
|
||||
```python
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.tools import ClaudeMemoryTool
|
||||
|
||||
# Use default storage
|
||||
agent = Agent(
|
||||
name="Assistant",
|
||||
llm="anthropic/claude-sonnet-4-20250514",
|
||||
claude_memory=True
|
||||
)
|
||||
|
||||
# Or with custom storage path
|
||||
memory = ClaudeMemoryTool(base_path="./my_project/memory")
|
||||
agent = Agent(
|
||||
name="Assistant",
|
||||
llm="anthropic/claude-sonnet-4-20250514",
|
||||
claude_memory=memory
|
||||
)
|
||||
```
|
||||
|
||||
Security:
|
||||
- All paths are validated to prevent directory traversal attacks
|
||||
- Paths must start with /memories
|
||||
- Symlinks are not followed outside the memory directory
|
||||
"""
|
||||
|
||||
# Beta header required for memory tool
|
||||
BETA_HEADER = "context-management-2025-06-27"
|
||||
|
||||
# Tool definition for API requests
|
||||
TOOL_DEFINITION = {
|
||||
"type": "memory_20250818",
|
||||
"name": "memory"
|
||||
}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_path: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
max_file_size: int = 1024 * 1024, # 1MB default
|
||||
max_files: int = 100
|
||||
):
|
||||
"""
|
||||
Initialize the Claude Memory Tool.
|
||||
|
||||
Args:
|
||||
base_path: Base directory for memory storage.
|
||||
Defaults to .praison/claude_memory/{user_id}
|
||||
user_id: User identifier for user-specific memory.
|
||||
Defaults to "default"
|
||||
max_file_size: Maximum size per file in bytes (default 1MB)
|
||||
max_files: Maximum number of files allowed (default 100)
|
||||
"""
|
||||
self.user_id = user_id or "default"
|
||||
|
||||
if base_path:
|
||||
self.base_path = Path(base_path)
|
||||
else:
|
||||
self.base_path = Path(f".praison/claude_memory/{self.user_id}")
|
||||
|
||||
self.memory_root = self.base_path / "memories"
|
||||
self.max_file_size = max_file_size
|
||||
self.max_files = max_files
|
||||
|
||||
# Create memory directory
|
||||
self.memory_root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
logger.debug(f"ClaudeMemoryTool initialized at {self.memory_root}")
|
||||
|
||||
def _validate_path(self, path: str) -> Path:
|
||||
"""
|
||||
Validate and resolve memory paths securely.
|
||||
|
||||
Args:
|
||||
path: Path string starting with /memories
|
||||
|
||||
Returns:
|
||||
Resolved Path object within memory directory
|
||||
|
||||
Raises:
|
||||
ValueError: If path is invalid or attempts directory traversal
|
||||
"""
|
||||
if not path:
|
||||
raise ValueError("Path cannot be empty")
|
||||
|
||||
if not path.startswith("/memories"):
|
||||
raise ValueError(f"Path must start with /memories, got: {path}")
|
||||
|
||||
# Remove /memories prefix and leading slashes
|
||||
relative_path = path[len("/memories"):].lstrip("/")
|
||||
|
||||
# Resolve the full path
|
||||
if relative_path:
|
||||
full_path = self.memory_root / relative_path
|
||||
else:
|
||||
full_path = self.memory_root
|
||||
|
||||
# Security check: ensure resolved path is within memory_root
|
||||
try:
|
||||
resolved = full_path.resolve()
|
||||
resolved.relative_to(self.memory_root.resolve())
|
||||
except ValueError as e:
|
||||
raise ValueError(f"Path {path} would escape /memories directory") from e
|
||||
|
||||
# Check for suspicious patterns
|
||||
if ".." in path or "~" in path:
|
||||
raise ValueError(f"Invalid path pattern in: {path}")
|
||||
|
||||
return full_path
|
||||
|
||||
def execute(self, command: str, **kwargs) -> str:
|
||||
"""
|
||||
Execute a memory command.
|
||||
|
||||
Args:
|
||||
command: Command name (view, create, str_replace, insert, delete, rename)
|
||||
**kwargs: Command-specific arguments
|
||||
|
||||
Returns:
|
||||
Result string from the command
|
||||
|
||||
Raises:
|
||||
ValueError: If command is unknown
|
||||
"""
|
||||
commands = {
|
||||
"view": self.view,
|
||||
"create": self.create,
|
||||
"str_replace": self.str_replace,
|
||||
"insert": self.insert,
|
||||
"delete": self.delete,
|
||||
"rename": self.rename
|
||||
}
|
||||
|
||||
if command not in commands:
|
||||
raise ValueError(f"Unknown command: {command}. Valid commands: {list(commands.keys())}")
|
||||
|
||||
return commands[command](**kwargs)
|
||||
|
||||
def view(self, path: str, view_range: Optional[List[int]] = None) -> str:
|
||||
"""
|
||||
Show directory contents or file contents with optional line ranges.
|
||||
|
||||
Args:
|
||||
path: Path to view (e.g., "/memories" or "/memories/notes.txt")
|
||||
view_range: Optional [start_line, end_line] for file viewing.
|
||||
Use -1 for end_line to read to end of file.
|
||||
|
||||
Returns:
|
||||
Directory listing or file contents with line numbers
|
||||
|
||||
Raises:
|
||||
RuntimeError: If path cannot be read
|
||||
"""
|
||||
full_path = self._validate_path(path)
|
||||
|
||||
if full_path.is_dir():
|
||||
# List directory contents
|
||||
items: List[str] = []
|
||||
try:
|
||||
for item in sorted(full_path.iterdir()):
|
||||
# Skip hidden files
|
||||
if item.name.startswith("."):
|
||||
continue
|
||||
# Add trailing slash for directories
|
||||
items.append(f"{item.name}/" if item.is_dir() else item.name)
|
||||
|
||||
result = f"Directory: {path}\n"
|
||||
if items:
|
||||
result += "\n".join([f"- {item}" for item in items])
|
||||
else:
|
||||
result += "(empty directory)"
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Cannot read directory {path}: {e}") from e
|
||||
|
||||
elif full_path.is_file():
|
||||
# Read file contents
|
||||
try:
|
||||
content = full_path.read_text(encoding="utf-8")
|
||||
lines = content.splitlines()
|
||||
|
||||
# Apply view range if specified
|
||||
if view_range:
|
||||
start_line = max(1, view_range[0]) - 1 # Convert to 0-indexed
|
||||
end_line = len(lines) if view_range[1] == -1 else view_range[1]
|
||||
lines = lines[start_line:end_line]
|
||||
start_num = start_line + 1
|
||||
else:
|
||||
start_num = 1
|
||||
|
||||
# Add line numbers
|
||||
numbered_lines = [
|
||||
f"{i + start_num:4d}: {line}"
|
||||
for i, line in enumerate(lines)
|
||||
]
|
||||
return "\n".join(numbered_lines) if numbered_lines else "(empty file)"
|
||||
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Cannot read file {path}: {e}") from e
|
||||
|
||||
else:
|
||||
raise RuntimeError(f"Path not found: {path}")
|
||||
|
||||
def create(self, path: str, file_text: str) -> str:
|
||||
"""
|
||||
Create or overwrite a file.
|
||||
|
||||
Args:
|
||||
path: Path for the new file (e.g., "/memories/notes.txt")
|
||||
file_text: Content to write to the file
|
||||
|
||||
Returns:
|
||||
Success message
|
||||
|
||||
Raises:
|
||||
ValueError: If file would exceed size limit or max files reached
|
||||
"""
|
||||
full_path = self._validate_path(path)
|
||||
|
||||
# Check file size limit
|
||||
if len(file_text.encode('utf-8')) > self.max_file_size:
|
||||
raise ValueError(
|
||||
f"File content exceeds maximum size of {self.max_file_size} bytes"
|
||||
)
|
||||
|
||||
# Check max files limit (only for new files)
|
||||
if not full_path.exists():
|
||||
current_files = sum(1 for _ in self.memory_root.rglob("*") if _.is_file())
|
||||
if current_files >= self.max_files:
|
||||
raise ValueError(
|
||||
f"Maximum number of files ({self.max_files}) reached. "
|
||||
"Delete some files before creating new ones."
|
||||
)
|
||||
|
||||
# Create parent directories if needed
|
||||
full_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Write file
|
||||
full_path.write_text(file_text, encoding="utf-8")
|
||||
|
||||
return f"File created successfully at {path}"
|
||||
|
||||
def str_replace(self, path: str, old_str: str, new_str: str) -> str:
|
||||
"""
|
||||
Replace text in a file.
|
||||
|
||||
Args:
|
||||
path: Path to the file
|
||||
old_str: Text to find (must be unique in file)
|
||||
new_str: Replacement text
|
||||
|
||||
Returns:
|
||||
Success message
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If file doesn't exist
|
||||
ValueError: If old_str not found or appears multiple times
|
||||
"""
|
||||
full_path = self._validate_path(path)
|
||||
|
||||
if not full_path.is_file():
|
||||
raise FileNotFoundError(f"File not found: {path}")
|
||||
|
||||
content = full_path.read_text(encoding="utf-8")
|
||||
|
||||
# Count occurrences
|
||||
count = content.count(old_str)
|
||||
|
||||
if count == 0:
|
||||
raise ValueError(f"Text not found in {path}")
|
||||
elif count > 1:
|
||||
raise ValueError(
|
||||
f"Text appears {count} times in {path}. Must be unique for replacement."
|
||||
)
|
||||
|
||||
# Replace and write
|
||||
new_content = content.replace(old_str, new_str)
|
||||
|
||||
# Check size limit
|
||||
if len(new_content.encode('utf-8')) > self.max_file_size:
|
||||
raise ValueError(
|
||||
f"Resulting file would exceed maximum size of {self.max_file_size} bytes"
|
||||
)
|
||||
|
||||
full_path.write_text(new_content, encoding="utf-8")
|
||||
|
||||
return f"File {path} has been edited"
|
||||
|
||||
def insert(self, path: str, insert_line: int, insert_text: str) -> str:
|
||||
"""
|
||||
Insert text at a specific line.
|
||||
|
||||
Args:
|
||||
path: Path to the file
|
||||
insert_line: Line number to insert at (0-indexed)
|
||||
insert_text: Text to insert
|
||||
|
||||
Returns:
|
||||
Success message
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If file doesn't exist
|
||||
ValueError: If line number is invalid
|
||||
"""
|
||||
full_path = self._validate_path(path)
|
||||
|
||||
if not full_path.is_file():
|
||||
raise FileNotFoundError(f"File not found: {path}")
|
||||
|
||||
lines = full_path.read_text(encoding="utf-8").splitlines()
|
||||
|
||||
# Validate line number
|
||||
if insert_line < 0 or insert_line > len(lines):
|
||||
raise ValueError(
|
||||
f"Invalid insert_line {insert_line}. Must be 0-{len(lines)}"
|
||||
)
|
||||
|
||||
# Insert the text (strip trailing newline to avoid double newlines)
|
||||
lines.insert(insert_line, insert_text.rstrip("\n"))
|
||||
|
||||
# Reconstruct content
|
||||
new_content = "\n".join(lines) + "\n"
|
||||
|
||||
# Check size limit
|
||||
if len(new_content.encode('utf-8')) > self.max_file_size:
|
||||
raise ValueError(
|
||||
f"Resulting file would exceed maximum size of {self.max_file_size} bytes"
|
||||
)
|
||||
|
||||
full_path.write_text(new_content, encoding="utf-8")
|
||||
|
||||
return f"Text inserted at line {insert_line} in {path}"
|
||||
|
||||
def delete(self, path: str) -> str:
|
||||
"""
|
||||
Delete a file or directory.
|
||||
|
||||
Args:
|
||||
path: Path to delete
|
||||
|
||||
Returns:
|
||||
Success message
|
||||
|
||||
Raises:
|
||||
ValueError: If trying to delete /memories root
|
||||
FileNotFoundError: If path doesn't exist
|
||||
"""
|
||||
# Prevent deleting the root memories directory
|
||||
if path == "/memories" or path == "/memories/":
|
||||
raise ValueError("Cannot delete the /memories directory itself")
|
||||
|
||||
full_path = self._validate_path(path)
|
||||
|
||||
if full_path.is_file():
|
||||
full_path.unlink()
|
||||
return f"File deleted: {path}"
|
||||
elif full_path.is_dir():
|
||||
shutil.rmtree(full_path)
|
||||
return f"Directory deleted: {path}"
|
||||
else:
|
||||
raise FileNotFoundError(f"Path not found: {path}")
|
||||
|
||||
def rename(self, old_path: str, new_path: str) -> str:
|
||||
"""
|
||||
Rename or move a file/directory.
|
||||
|
||||
Args:
|
||||
old_path: Current path
|
||||
new_path: New path
|
||||
|
||||
Returns:
|
||||
Success message
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If source doesn't exist
|
||||
ValueError: If destination already exists
|
||||
"""
|
||||
old_full_path = self._validate_path(old_path)
|
||||
new_full_path = self._validate_path(new_path)
|
||||
|
||||
if not old_full_path.exists():
|
||||
raise FileNotFoundError(f"Source path not found: {old_path}")
|
||||
|
||||
if new_full_path.exists():
|
||||
raise ValueError(f"Destination already exists: {new_path}")
|
||||
|
||||
# Create parent directories if needed
|
||||
new_full_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Rename/move
|
||||
old_full_path.rename(new_full_path)
|
||||
|
||||
return f"Renamed {old_path} to {new_path}"
|
||||
|
||||
def clear_all(self) -> str:
|
||||
"""
|
||||
Clear all memory files.
|
||||
|
||||
Returns:
|
||||
Success message
|
||||
"""
|
||||
if self.memory_root.exists():
|
||||
shutil.rmtree(self.memory_root)
|
||||
self.memory_root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
return "All memory cleared"
|
||||
|
||||
def get_tool_definition(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Get the tool definition for API requests.
|
||||
|
||||
Returns:
|
||||
Tool definition dict for Anthropic API
|
||||
"""
|
||||
return self.TOOL_DEFINITION.copy()
|
||||
|
||||
def get_beta_header(self) -> str:
|
||||
"""
|
||||
Get the beta header value for API requests.
|
||||
|
||||
Returns:
|
||||
Beta header string
|
||||
"""
|
||||
return self.BETA_HEADER
|
||||
|
||||
def get_system_prompt(self) -> str:
|
||||
"""
|
||||
Get the memory system prompt to append to agent instructions.
|
||||
|
||||
Returns:
|
||||
Memory protocol system prompt
|
||||
"""
|
||||
return MEMORY_SYSTEM_PROMPT
|
||||
|
||||
def process_tool_call(self, tool_input: Dict[str, Any]) -> str:
|
||||
"""
|
||||
Process a memory tool call from Claude.
|
||||
|
||||
Args:
|
||||
tool_input: The input dict from Claude's tool_use block
|
||||
Contains 'command' and command-specific parameters
|
||||
|
||||
Returns:
|
||||
Result string to return to Claude
|
||||
|
||||
Example:
|
||||
```python
|
||||
# Claude sends:
|
||||
# {"command": "view", "path": "/memories"}
|
||||
result = memory_tool.process_tool_call(tool_input)
|
||||
```
|
||||
"""
|
||||
command = tool_input.get("command")
|
||||
if not command:
|
||||
return "Error: No command specified in tool input"
|
||||
|
||||
try:
|
||||
# Extract command-specific parameters
|
||||
params = {k: v for k, v in tool_input.items() if k != "command"}
|
||||
return self.execute(command, **params)
|
||||
except Exception as e:
|
||||
return f"Error: {str(e)}"
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"ClaudeMemoryTool(base_path='{self.base_path}', user_id='{self.user_id}')"
|
||||
@@ -0,0 +1,225 @@
|
||||
"""Tool decorator for converting functions into tools.
|
||||
|
||||
This module provides the @tool decorator for easily creating tools from functions.
|
||||
|
||||
Usage:
|
||||
from praisonaiagents import tool
|
||||
|
||||
@tool
|
||||
def search(query: str) -> list:
|
||||
'''Search the web for information.'''
|
||||
return [...]
|
||||
|
||||
# Or with explicit parameters:
|
||||
@tool(name="web_search", description="Search the internet")
|
||||
def search(query: str, max_results: int = 5) -> list:
|
||||
return [...]
|
||||
"""
|
||||
|
||||
import inspect
|
||||
import functools
|
||||
import logging
|
||||
from typing import Any, Callable, Dict, Optional, Union, get_type_hints
|
||||
|
||||
from .base import BaseTool
|
||||
|
||||
|
||||
class FunctionTool(BaseTool):
|
||||
"""A BaseTool wrapper for plain functions.
|
||||
|
||||
Created automatically by the @tool decorator.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
func: Callable,
|
||||
name: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
version: str = "1.0.0"
|
||||
):
|
||||
self._func = func
|
||||
self.name = name or func.__name__
|
||||
self.description = description or func.__doc__ or f"Tool: {self.name}"
|
||||
self.version = version
|
||||
|
||||
# Generate schema from the original function, not run()
|
||||
self.parameters = self._generate_schema_from_func(func)
|
||||
|
||||
# Copy function metadata
|
||||
functools.update_wrapper(self, func)
|
||||
|
||||
# Skip parent's schema generation since we already did it
|
||||
# Just set defaults that parent would set
|
||||
if not self.name:
|
||||
self.name = self.__class__.__name__.lower().replace("tool", "")
|
||||
if not self.description:
|
||||
self.description = self.__class__.__doc__ or f"Tool: {self.name}"
|
||||
|
||||
def _generate_schema_from_func(self, func: Callable) -> Dict[str, Any]:
|
||||
"""Generate JSON Schema from the wrapped function's signature."""
|
||||
schema = {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"required": []
|
||||
}
|
||||
|
||||
try:
|
||||
sig = inspect.signature(func)
|
||||
hints = get_type_hints(func) if hasattr(func, '__annotations__') else {}
|
||||
|
||||
for param_name, param in sig.parameters.items():
|
||||
if param_name in ('self', 'cls'):
|
||||
continue
|
||||
|
||||
# Get type hint
|
||||
param_type = hints.get(param_name, Any)
|
||||
json_type = BaseTool._python_type_to_json(param_type)
|
||||
|
||||
schema["properties"][param_name] = {"type": json_type}
|
||||
|
||||
# Check if required (no default value)
|
||||
if param.default is inspect.Parameter.empty:
|
||||
schema["required"].append(param_name)
|
||||
except Exception as e:
|
||||
logging.debug(f"Could not generate schema for {func.__name__}: {e}")
|
||||
|
||||
return schema
|
||||
|
||||
def run(self, **kwargs) -> Any:
|
||||
"""Execute the wrapped function."""
|
||||
return self._func(**kwargs)
|
||||
|
||||
def __call__(self, *args, **kwargs) -> Any:
|
||||
"""Allow calling with positional args like the original function."""
|
||||
return self._func(*args, **kwargs)
|
||||
|
||||
|
||||
def tool(
|
||||
func: Optional[Callable] = None,
|
||||
*,
|
||||
name: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
version: str = "1.0.0"
|
||||
) -> Union[FunctionTool, Callable[[Callable], FunctionTool]]:
|
||||
"""Decorator to convert a function into a tool.
|
||||
|
||||
Can be used with or without arguments:
|
||||
|
||||
@tool
|
||||
def my_func(x: str) -> str:
|
||||
'''Does something.'''
|
||||
return x
|
||||
|
||||
@tool(name="custom_name", description="Custom description")
|
||||
def my_func(x: str) -> str:
|
||||
return x
|
||||
|
||||
Args:
|
||||
func: The function to wrap (when used without parentheses)
|
||||
name: Override the tool name (default: function name)
|
||||
description: Override description (default: function docstring)
|
||||
version: Tool version (default: "1.0.0")
|
||||
|
||||
Returns:
|
||||
FunctionTool instance that wraps the function
|
||||
"""
|
||||
def decorator(fn: Callable) -> FunctionTool:
|
||||
tool_instance = FunctionTool(
|
||||
func=fn,
|
||||
name=name,
|
||||
description=description,
|
||||
version=version
|
||||
)
|
||||
|
||||
# Register with global registry if available
|
||||
try:
|
||||
from .registry import get_registry
|
||||
registry = get_registry()
|
||||
if registry:
|
||||
registry.register(tool_instance)
|
||||
except ImportError:
|
||||
pass # Registry not yet available
|
||||
|
||||
return tool_instance
|
||||
|
||||
# Handle both @tool and @tool(...) syntax
|
||||
if func is not None:
|
||||
# Called without parentheses: @tool
|
||||
return decorator(func)
|
||||
else:
|
||||
# Called with parentheses: @tool(...)
|
||||
return decorator
|
||||
|
||||
|
||||
def is_tool(obj: Any) -> bool:
|
||||
"""Check if an object is a tool (BaseTool instance or decorated function)."""
|
||||
if isinstance(obj, BaseTool):
|
||||
return True
|
||||
if isinstance(obj, FunctionTool):
|
||||
return True
|
||||
# Check for tools created by other frameworks
|
||||
if hasattr(obj, 'run') and hasattr(obj, 'name'):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def get_tool_schema(obj: Any) -> Optional[Dict[str, Any]]:
|
||||
"""Get OpenAI-compatible schema for any tool-like object.
|
||||
|
||||
Supports:
|
||||
- BaseTool instances
|
||||
- FunctionTool instances
|
||||
- Plain functions (generates schema from signature)
|
||||
- LangChain tools
|
||||
- CrewAI tools
|
||||
"""
|
||||
# BaseTool or FunctionTool
|
||||
if isinstance(obj, BaseTool):
|
||||
return obj.get_schema()
|
||||
|
||||
# Plain callable
|
||||
if callable(obj):
|
||||
return _schema_from_function(obj)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _schema_from_function(func: Callable) -> Dict[str, Any]:
|
||||
"""Generate OpenAI function schema from a plain function."""
|
||||
name = getattr(func, '__name__', 'unknown')
|
||||
description = func.__doc__ or f"Function: {name}"
|
||||
|
||||
# Build parameters schema
|
||||
properties = {}
|
||||
required = []
|
||||
|
||||
try:
|
||||
sig = inspect.signature(func)
|
||||
hints = get_type_hints(func) if hasattr(func, '__annotations__') else {}
|
||||
|
||||
for param_name, param in sig.parameters.items():
|
||||
if param_name == 'self':
|
||||
continue
|
||||
|
||||
param_type = hints.get(param_name, Any)
|
||||
json_type = BaseTool._python_type_to_json(param_type)
|
||||
|
||||
properties[param_name] = {"type": json_type}
|
||||
|
||||
if param.default is inspect.Parameter.empty:
|
||||
required.append(param_name)
|
||||
except Exception as e:
|
||||
logging.debug(f"Could not generate schema for {name}: {e}")
|
||||
|
||||
return {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": name,
|
||||
"description": description.strip(),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": properties,
|
||||
"required": required
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,741 @@
|
||||
"""Exa search and research tools.
|
||||
|
||||
Exa provides AI-powered web search, content retrieval, and research capabilities.
|
||||
This module requires the EXA_API_KEY environment variable to be set.
|
||||
|
||||
Usage:
|
||||
from praisonaiagents.tools import exa_search, exa_search_contents
|
||||
|
||||
# Search the web
|
||||
results = exa_search("AI startups")
|
||||
|
||||
# Search with contents
|
||||
results = exa_search_contents("AI in healthcare", text=True)
|
||||
|
||||
# Or use the class directly
|
||||
from praisonaiagents.tools import ExaTools
|
||||
exa = ExaTools()
|
||||
results = exa.search("AI news")
|
||||
"""
|
||||
|
||||
from typing import List, Dict, Any, Optional, Union
|
||||
import logging
|
||||
import os
|
||||
from importlib import util
|
||||
|
||||
|
||||
def _check_exa_available() -> tuple[bool, Optional[str]]:
|
||||
"""Check if Exa is available and API key is set.
|
||||
|
||||
Returns:
|
||||
Tuple of (is_available, error_message)
|
||||
"""
|
||||
# Check if exa_py package is installed
|
||||
if util.find_spec("exa_py") is None:
|
||||
return False, "exa_py package is not installed. Install it with: pip install exa_py"
|
||||
|
||||
# Check if API key is set
|
||||
if not os.environ.get("EXA_API_KEY"):
|
||||
return False, "EXA_API_KEY environment variable is not set. Please set it to use Exa tools."
|
||||
|
||||
return True, None
|
||||
|
||||
|
||||
class ExaTools:
|
||||
"""Exa search and research tools.
|
||||
|
||||
Provides methods for:
|
||||
- search: Basic web search
|
||||
- search_and_contents: Search with full text/highlights
|
||||
- find_similar: Find similar pages to a URL
|
||||
- find_similar_and_contents: Find similar with content
|
||||
- answer: Get AI-generated answers with citations
|
||||
|
||||
Example:
|
||||
from praisonaiagents.tools import ExaTools
|
||||
|
||||
exa = ExaTools()
|
||||
results = exa.search("AI startups", num_results=5)
|
||||
"""
|
||||
|
||||
def __init__(self, api_key: Optional[str] = None):
|
||||
"""Initialize ExaTools.
|
||||
|
||||
Args:
|
||||
api_key: Exa API key. If not provided, uses EXA_API_KEY env var.
|
||||
"""
|
||||
self.api_key = api_key or os.environ.get("EXA_API_KEY")
|
||||
self._client = None
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
def _get_client(self):
|
||||
"""Get or create Exa client."""
|
||||
if self._client is None:
|
||||
is_available, error = _check_exa_available()
|
||||
if not is_available:
|
||||
raise ImportError(error)
|
||||
|
||||
from exa_py import Exa
|
||||
self._client = Exa(self.api_key)
|
||||
return self._client
|
||||
|
||||
def search(
|
||||
self,
|
||||
query: str,
|
||||
num_results: int = 10,
|
||||
type: Optional[str] = None,
|
||||
category: Optional[str] = None,
|
||||
include_domains: Optional[List[str]] = None,
|
||||
exclude_domains: Optional[List[str]] = None,
|
||||
start_crawl_date: Optional[str] = None,
|
||||
end_crawl_date: Optional[str] = None,
|
||||
start_published_date: Optional[str] = None,
|
||||
end_published_date: Optional[str] = None,
|
||||
include_text: Optional[List[str]] = None,
|
||||
exclude_text: Optional[List[str]] = None,
|
||||
additional_queries: Optional[List[str]] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""Perform an Exa search.
|
||||
|
||||
Args:
|
||||
query: The search query
|
||||
num_results: Number of results to return (default 10, max 100)
|
||||
type: Search type - "auto", "neural", "fast", or "deep"
|
||||
category: Data category - company, research paper, news, linkedin profile,
|
||||
github, tweet, movie, song, personal site, pdf, financial report
|
||||
include_domains: List of domains to include
|
||||
exclude_domains: List of domains to exclude
|
||||
start_crawl_date: Only include links crawled after this date
|
||||
end_crawl_date: Only include links crawled before this date
|
||||
start_published_date: Only include links published after this date
|
||||
end_published_date: Only include links published before this date
|
||||
include_text: Strings that must be present in results
|
||||
exclude_text: Strings that must not be present in results
|
||||
additional_queries: Additional query variations for deep search
|
||||
|
||||
Returns:
|
||||
Dict with 'results' list and optional 'autopromptString'
|
||||
"""
|
||||
try:
|
||||
client = self._get_client()
|
||||
|
||||
kwargs = {
|
||||
"query": query,
|
||||
"num_results": num_results
|
||||
}
|
||||
|
||||
if type:
|
||||
kwargs["type"] = type
|
||||
if category:
|
||||
kwargs["category"] = category
|
||||
if include_domains:
|
||||
kwargs["include_domains"] = include_domains
|
||||
if exclude_domains:
|
||||
kwargs["exclude_domains"] = exclude_domains
|
||||
if start_crawl_date:
|
||||
kwargs["start_crawl_date"] = start_crawl_date
|
||||
if end_crawl_date:
|
||||
kwargs["end_crawl_date"] = end_crawl_date
|
||||
if start_published_date:
|
||||
kwargs["start_published_date"] = start_published_date
|
||||
if end_published_date:
|
||||
kwargs["end_published_date"] = end_published_date
|
||||
if include_text:
|
||||
kwargs["include_text"] = include_text
|
||||
if exclude_text:
|
||||
kwargs["exclude_text"] = exclude_text
|
||||
if additional_queries:
|
||||
kwargs["additional_queries"] = additional_queries
|
||||
|
||||
response = client.search(**kwargs)
|
||||
|
||||
return {
|
||||
"results": [
|
||||
{
|
||||
"url": r.url,
|
||||
"id": r.id,
|
||||
"title": getattr(r, "title", None),
|
||||
"published_date": getattr(r, "published_date", None),
|
||||
"author": getattr(r, "author", None)
|
||||
}
|
||||
for r in response.results
|
||||
],
|
||||
"autopromptString": getattr(response, "autoprompt_string", None),
|
||||
"requestId": getattr(response, "request_id", None)
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Exa search error: {e}")
|
||||
return {"error": f"Exa search error: {str(e)}"}
|
||||
|
||||
def search_and_contents(
|
||||
self,
|
||||
query: str,
|
||||
text: Union[bool, Dict] = True,
|
||||
highlights: Union[bool, Dict] = False,
|
||||
summary: Optional[Dict] = None,
|
||||
num_results: int = 10,
|
||||
type: Optional[str] = None,
|
||||
category: Optional[str] = None,
|
||||
include_domains: Optional[List[str]] = None,
|
||||
exclude_domains: Optional[List[str]] = None,
|
||||
start_crawl_date: Optional[str] = None,
|
||||
end_crawl_date: Optional[str] = None,
|
||||
start_published_date: Optional[str] = None,
|
||||
end_published_date: Optional[str] = None,
|
||||
include_text: Optional[List[str]] = None,
|
||||
exclude_text: Optional[List[str]] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""Search and retrieve content from results.
|
||||
|
||||
Args:
|
||||
query: The search query
|
||||
text: Include full text content (True or TextContentsOptions dict)
|
||||
highlights: Include highlights (True or HighlightsContentsOptions dict)
|
||||
summary: Summary options with optional schema for structured output
|
||||
num_results: Number of results to return
|
||||
type: Search type - "auto", "neural", "fast", or "deep"
|
||||
category: Data category filter
|
||||
include_domains: List of domains to include
|
||||
exclude_domains: List of domains to exclude
|
||||
start_crawl_date: Only include links crawled after this date
|
||||
end_crawl_date: Only include links crawled before this date
|
||||
start_published_date: Only include links published after this date
|
||||
end_published_date: Only include links published before this date
|
||||
include_text: Strings that must be present in results
|
||||
exclude_text: Strings that must not be present in results
|
||||
|
||||
Returns:
|
||||
Dict with 'results' list containing text/highlights
|
||||
"""
|
||||
try:
|
||||
client = self._get_client()
|
||||
|
||||
kwargs = {
|
||||
"query": query,
|
||||
"num_results": num_results
|
||||
}
|
||||
|
||||
if text:
|
||||
kwargs["text"] = text
|
||||
if highlights:
|
||||
kwargs["highlights"] = highlights
|
||||
if summary:
|
||||
kwargs["summary"] = summary
|
||||
if type:
|
||||
kwargs["type"] = type
|
||||
if category:
|
||||
kwargs["category"] = category
|
||||
if include_domains:
|
||||
kwargs["include_domains"] = include_domains
|
||||
if exclude_domains:
|
||||
kwargs["exclude_domains"] = exclude_domains
|
||||
if start_crawl_date:
|
||||
kwargs["start_crawl_date"] = start_crawl_date
|
||||
if end_crawl_date:
|
||||
kwargs["end_crawl_date"] = end_crawl_date
|
||||
if start_published_date:
|
||||
kwargs["start_published_date"] = start_published_date
|
||||
if end_published_date:
|
||||
kwargs["end_published_date"] = end_published_date
|
||||
if include_text:
|
||||
kwargs["include_text"] = include_text
|
||||
if exclude_text:
|
||||
kwargs["exclude_text"] = exclude_text
|
||||
|
||||
response = client.search_and_contents(**kwargs)
|
||||
|
||||
results = []
|
||||
for r in response.results:
|
||||
result = {
|
||||
"url": r.url,
|
||||
"id": r.id,
|
||||
"title": getattr(r, "title", None),
|
||||
"published_date": getattr(r, "published_date", None),
|
||||
"author": getattr(r, "author", None)
|
||||
}
|
||||
if hasattr(r, "text") and r.text:
|
||||
result["text"] = r.text
|
||||
if hasattr(r, "highlights") and r.highlights:
|
||||
result["highlights"] = r.highlights
|
||||
if hasattr(r, "highlight_scores") and r.highlight_scores:
|
||||
result["highlight_scores"] = r.highlight_scores
|
||||
if hasattr(r, "summary") and r.summary:
|
||||
result["summary"] = r.summary
|
||||
results.append(result)
|
||||
|
||||
return {
|
||||
"results": results,
|
||||
"requestId": getattr(response, "request_id", None)
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Exa search_and_contents error: {e}")
|
||||
return {"error": f"Exa search_and_contents error: {str(e)}"}
|
||||
|
||||
def find_similar(
|
||||
self,
|
||||
url: str,
|
||||
num_results: int = 10,
|
||||
include_domains: Optional[List[str]] = None,
|
||||
exclude_domains: Optional[List[str]] = None,
|
||||
start_crawl_date: Optional[str] = None,
|
||||
end_crawl_date: Optional[str] = None,
|
||||
start_published_date: Optional[str] = None,
|
||||
end_published_date: Optional[str] = None,
|
||||
exclude_source_domain: bool = False,
|
||||
category: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""Find similar pages to a given URL.
|
||||
|
||||
Args:
|
||||
url: The URL to find similar pages for
|
||||
num_results: Number of results to return
|
||||
include_domains: List of domains to include
|
||||
exclude_domains: List of domains to exclude
|
||||
start_crawl_date: Only include links crawled after this date
|
||||
end_crawl_date: Only include links crawled before this date
|
||||
start_published_date: Only include links published after this date
|
||||
end_published_date: Only include links published before this date
|
||||
exclude_source_domain: Exclude results from the same domain
|
||||
category: Data category filter
|
||||
|
||||
Returns:
|
||||
Dict with 'results' list of similar pages
|
||||
"""
|
||||
try:
|
||||
client = self._get_client()
|
||||
|
||||
kwargs = {
|
||||
"url": url,
|
||||
"num_results": num_results
|
||||
}
|
||||
|
||||
if include_domains:
|
||||
kwargs["include_domains"] = include_domains
|
||||
if exclude_domains:
|
||||
kwargs["exclude_domains"] = exclude_domains
|
||||
if start_crawl_date:
|
||||
kwargs["start_crawl_date"] = start_crawl_date
|
||||
if end_crawl_date:
|
||||
kwargs["end_crawl_date"] = end_crawl_date
|
||||
if start_published_date:
|
||||
kwargs["start_published_date"] = start_published_date
|
||||
if end_published_date:
|
||||
kwargs["end_published_date"] = end_published_date
|
||||
if exclude_source_domain:
|
||||
kwargs["exclude_source_domain"] = exclude_source_domain
|
||||
if category:
|
||||
kwargs["category"] = category
|
||||
|
||||
response = client.find_similar(**kwargs)
|
||||
|
||||
return {
|
||||
"results": [
|
||||
{
|
||||
"url": r.url,
|
||||
"id": r.id,
|
||||
"title": getattr(r, "title", None),
|
||||
"published_date": getattr(r, "published_date", None),
|
||||
"author": getattr(r, "author", None)
|
||||
}
|
||||
for r in response.results
|
||||
],
|
||||
"requestId": getattr(response, "request_id", None)
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Exa find_similar error: {e}")
|
||||
return {"error": f"Exa find_similar error: {str(e)}"}
|
||||
|
||||
def find_similar_and_contents(
|
||||
self,
|
||||
url: str,
|
||||
text: Union[bool, Dict] = True,
|
||||
highlights: Union[bool, Dict] = False,
|
||||
num_results: int = 10,
|
||||
include_domains: Optional[List[str]] = None,
|
||||
exclude_domains: Optional[List[str]] = None,
|
||||
start_crawl_date: Optional[str] = None,
|
||||
end_crawl_date: Optional[str] = None,
|
||||
start_published_date: Optional[str] = None,
|
||||
end_published_date: Optional[str] = None,
|
||||
exclude_source_domain: bool = False,
|
||||
category: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""Find similar pages with content.
|
||||
|
||||
Args:
|
||||
url: The URL to find similar pages for
|
||||
text: Include full text content
|
||||
highlights: Include highlights
|
||||
num_results: Number of results to return
|
||||
include_domains: List of domains to include
|
||||
exclude_domains: List of domains to exclude
|
||||
start_crawl_date: Only include links crawled after this date
|
||||
end_crawl_date: Only include links crawled before this date
|
||||
start_published_date: Only include links published after this date
|
||||
end_published_date: Only include links published before this date
|
||||
exclude_source_domain: Exclude results from the same domain
|
||||
category: Data category filter
|
||||
|
||||
Returns:
|
||||
Dict with 'results' list containing text/highlights
|
||||
"""
|
||||
try:
|
||||
client = self._get_client()
|
||||
|
||||
kwargs = {
|
||||
"url": url,
|
||||
"num_results": num_results
|
||||
}
|
||||
|
||||
if text:
|
||||
kwargs["text"] = text
|
||||
if highlights:
|
||||
kwargs["highlights"] = highlights
|
||||
if include_domains:
|
||||
kwargs["include_domains"] = include_domains
|
||||
if exclude_domains:
|
||||
kwargs["exclude_domains"] = exclude_domains
|
||||
if start_crawl_date:
|
||||
kwargs["start_crawl_date"] = start_crawl_date
|
||||
if end_crawl_date:
|
||||
kwargs["end_crawl_date"] = end_crawl_date
|
||||
if start_published_date:
|
||||
kwargs["start_published_date"] = start_published_date
|
||||
if end_published_date:
|
||||
kwargs["end_published_date"] = end_published_date
|
||||
if exclude_source_domain:
|
||||
kwargs["exclude_source_domain"] = exclude_source_domain
|
||||
if category:
|
||||
kwargs["category"] = category
|
||||
|
||||
response = client.find_similar_and_contents(**kwargs)
|
||||
|
||||
results = []
|
||||
for r in response.results:
|
||||
result = {
|
||||
"url": r.url,
|
||||
"id": r.id,
|
||||
"title": getattr(r, "title", None),
|
||||
"published_date": getattr(r, "published_date", None),
|
||||
"author": getattr(r, "author", None)
|
||||
}
|
||||
if hasattr(r, "text") and r.text:
|
||||
result["text"] = r.text
|
||||
if hasattr(r, "highlights") and r.highlights:
|
||||
result["highlights"] = r.highlights
|
||||
if hasattr(r, "highlight_scores") and r.highlight_scores:
|
||||
result["highlight_scores"] = r.highlight_scores
|
||||
results.append(result)
|
||||
|
||||
return {
|
||||
"results": results,
|
||||
"requestId": getattr(response, "request_id", None)
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Exa find_similar_and_contents error: {e}")
|
||||
return {"error": f"Exa find_similar_and_contents error: {str(e)}"}
|
||||
|
||||
def answer(
|
||||
self,
|
||||
query: str,
|
||||
text: bool = False
|
||||
) -> Dict[str, Any]:
|
||||
"""Generate an answer using Exa's search and LLM capabilities.
|
||||
|
||||
Args:
|
||||
query: The question to answer
|
||||
text: If True, include full text of citations
|
||||
|
||||
Returns:
|
||||
Dict with 'answer' and 'citations'
|
||||
"""
|
||||
try:
|
||||
client = self._get_client()
|
||||
|
||||
response = client.answer(query=query, text=text)
|
||||
|
||||
citations = []
|
||||
for c in response.citations:
|
||||
citation = {
|
||||
"id": c.id,
|
||||
"url": c.url,
|
||||
"title": getattr(c, "title", None),
|
||||
"published_date": getattr(c, "published_date", None),
|
||||
"author": getattr(c, "author", None)
|
||||
}
|
||||
if text and hasattr(c, "text") and c.text:
|
||||
citation["text"] = c.text
|
||||
citations.append(citation)
|
||||
|
||||
return {
|
||||
"answer": response.answer,
|
||||
"citations": citations
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Exa answer error: {e}")
|
||||
return {"error": f"Exa answer error: {str(e)}"}
|
||||
|
||||
|
||||
# Standalone functions for easy access
|
||||
|
||||
def exa_search(
|
||||
query: str,
|
||||
num_results: int = 10,
|
||||
type: Optional[str] = None,
|
||||
category: Optional[str] = None,
|
||||
include_domains: Optional[List[str]] = None,
|
||||
exclude_domains: Optional[List[str]] = None,
|
||||
start_published_date: Optional[str] = None,
|
||||
end_published_date: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""Search the web using Exa.
|
||||
|
||||
Args:
|
||||
query: The search query
|
||||
num_results: Number of results (default 10)
|
||||
type: Search type - "auto", "neural", "fast", or "deep"
|
||||
category: Data category filter
|
||||
include_domains: Domains to include
|
||||
exclude_domains: Domains to exclude
|
||||
start_published_date: Only include links published after this date
|
||||
end_published_date: Only include links published before this date
|
||||
|
||||
Returns:
|
||||
Dict with search results or error
|
||||
|
||||
Example:
|
||||
results = exa_search("AI startups", num_results=5)
|
||||
"""
|
||||
is_available, error = _check_exa_available()
|
||||
if not is_available:
|
||||
logging.error(error)
|
||||
return {"error": error}
|
||||
|
||||
tools = ExaTools()
|
||||
return tools.search(
|
||||
query=query,
|
||||
num_results=num_results,
|
||||
type=type,
|
||||
category=category,
|
||||
include_domains=include_domains,
|
||||
exclude_domains=exclude_domains,
|
||||
start_published_date=start_published_date,
|
||||
end_published_date=end_published_date
|
||||
)
|
||||
|
||||
|
||||
def exa_search_contents(
|
||||
query: str,
|
||||
text: bool = True,
|
||||
highlights: bool = False,
|
||||
num_results: int = 10,
|
||||
type: Optional[str] = None,
|
||||
category: Optional[str] = None,
|
||||
include_domains: Optional[List[str]] = None,
|
||||
exclude_domains: Optional[List[str]] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""Search and get content from results.
|
||||
|
||||
Args:
|
||||
query: The search query
|
||||
text: Include full text content
|
||||
highlights: Include highlights
|
||||
num_results: Number of results
|
||||
type: Search type
|
||||
category: Data category filter
|
||||
include_domains: Domains to include
|
||||
exclude_domains: Domains to exclude
|
||||
|
||||
Returns:
|
||||
Dict with results containing text/highlights
|
||||
|
||||
Example:
|
||||
results = exa_search_contents("AI in healthcare", text=True, highlights=True)
|
||||
"""
|
||||
is_available, error = _check_exa_available()
|
||||
if not is_available:
|
||||
logging.error(error)
|
||||
return {"error": error}
|
||||
|
||||
tools = ExaTools()
|
||||
return tools.search_and_contents(
|
||||
query=query,
|
||||
text=text,
|
||||
highlights=highlights,
|
||||
num_results=num_results,
|
||||
type=type,
|
||||
category=category,
|
||||
include_domains=include_domains,
|
||||
exclude_domains=exclude_domains
|
||||
)
|
||||
|
||||
|
||||
def exa_find_similar(
|
||||
url: str,
|
||||
num_results: int = 10,
|
||||
exclude_source_domain: bool = True,
|
||||
category: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""Find pages similar to a given URL.
|
||||
|
||||
Args:
|
||||
url: The URL to find similar pages for
|
||||
num_results: Number of results
|
||||
exclude_source_domain: Exclude same domain results
|
||||
category: Data category filter
|
||||
|
||||
Returns:
|
||||
Dict with similar results
|
||||
|
||||
Example:
|
||||
results = exa_find_similar("https://openai.com", num_results=5)
|
||||
"""
|
||||
is_available, error = _check_exa_available()
|
||||
if not is_available:
|
||||
logging.error(error)
|
||||
return {"error": error}
|
||||
|
||||
tools = ExaTools()
|
||||
return tools.find_similar(
|
||||
url=url,
|
||||
num_results=num_results,
|
||||
exclude_source_domain=exclude_source_domain,
|
||||
category=category
|
||||
)
|
||||
|
||||
|
||||
def exa_answer(
|
||||
query: str,
|
||||
text: bool = False
|
||||
) -> Dict[str, Any]:
|
||||
"""Get an AI-generated answer with citations.
|
||||
|
||||
Args:
|
||||
query: The question to answer
|
||||
text: Include full text of citations
|
||||
|
||||
Returns:
|
||||
Dict with 'answer' and 'citations'
|
||||
|
||||
Example:
|
||||
result = exa_answer("What is the capital of France?")
|
||||
print(result["answer"])
|
||||
"""
|
||||
is_available, error = _check_exa_available()
|
||||
if not is_available:
|
||||
logging.error(error)
|
||||
return {"error": error}
|
||||
|
||||
tools = ExaTools()
|
||||
return tools.answer(query=query, text=text)
|
||||
|
||||
|
||||
# Async versions
|
||||
|
||||
async def exa_search_async(
|
||||
query: str,
|
||||
num_results: int = 10,
|
||||
type: Optional[str] = None,
|
||||
category: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""Async version of exa_search.
|
||||
|
||||
Args:
|
||||
query: The search query
|
||||
num_results: Number of results
|
||||
type: Search type
|
||||
category: Data category filter
|
||||
|
||||
Returns:
|
||||
Dict with search results
|
||||
"""
|
||||
import asyncio
|
||||
return await asyncio.get_event_loop().run_in_executor(
|
||||
None,
|
||||
lambda: exa_search(query, num_results, type, category)
|
||||
)
|
||||
|
||||
|
||||
async def exa_search_contents_async(
|
||||
query: str,
|
||||
text: bool = True,
|
||||
highlights: bool = False,
|
||||
num_results: int = 10
|
||||
) -> Dict[str, Any]:
|
||||
"""Async version of exa_search_contents.
|
||||
|
||||
Args:
|
||||
query: The search query
|
||||
text: Include full text
|
||||
highlights: Include highlights
|
||||
num_results: Number of results
|
||||
|
||||
Returns:
|
||||
Dict with results containing content
|
||||
"""
|
||||
import asyncio
|
||||
return await asyncio.get_event_loop().run_in_executor(
|
||||
None,
|
||||
lambda: exa_search_contents(query, text, highlights, num_results)
|
||||
)
|
||||
|
||||
|
||||
async def exa_answer_async(
|
||||
query: str,
|
||||
text: bool = False
|
||||
) -> Dict[str, Any]:
|
||||
"""Async version of exa_answer.
|
||||
|
||||
Args:
|
||||
query: The question to answer
|
||||
text: Include citation text
|
||||
|
||||
Returns:
|
||||
Dict with answer and citations
|
||||
"""
|
||||
import asyncio
|
||||
return await asyncio.get_event_loop().run_in_executor(
|
||||
None,
|
||||
lambda: exa_answer(query, text)
|
||||
)
|
||||
|
||||
|
||||
# Alias for simple usage
|
||||
exa = exa_search
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Example usage
|
||||
print("=" * 60)
|
||||
print("Exa Tools - Example Usage")
|
||||
print("=" * 60)
|
||||
|
||||
is_available, error = _check_exa_available()
|
||||
if not is_available:
|
||||
print(f"\nExa is not available: {error}")
|
||||
print("\nTo use Exa tools:")
|
||||
print("1. Install the package: pip install exa_py")
|
||||
print("2. Set environment variable: export EXA_API_KEY=your_api_key")
|
||||
else:
|
||||
print("Exa SDK is available!")
|
||||
|
||||
# Example search
|
||||
print("\n1. Basic Search")
|
||||
print("-" * 30)
|
||||
results = exa_search("Latest AI developments 2024", num_results=3)
|
||||
if "error" not in results:
|
||||
print(f"Found {len(results.get('results', []))} results")
|
||||
for r in results.get("results", [])[:3]:
|
||||
print(f" - {r.get('title', 'No title')[:50]}...")
|
||||
print(f" URL: {r.get('url', 'No URL')}")
|
||||
else:
|
||||
print(f"Error: {results['error']}")
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("Demonstration Complete")
|
||||
print("=" * 60)
|
||||
@@ -0,0 +1,244 @@
|
||||
"""Example plugin tools demonstrating the BaseTool and @tool patterns.
|
||||
|
||||
This module shows how to create tools using the new plugin system.
|
||||
External developers can use these patterns to create pip-installable plugins.
|
||||
|
||||
Usage:
|
||||
from praisonaiagents import BaseTool, tool, Agent
|
||||
|
||||
# Method 1: Class-based tool
|
||||
class MyTool(BaseTool):
|
||||
name = "my_tool"
|
||||
description = "Does something useful"
|
||||
|
||||
def run(self, param: str) -> str:
|
||||
return f"Result: {param}"
|
||||
|
||||
# Method 2: Decorator-based tool
|
||||
@tool
|
||||
def my_function(param: str) -> str:
|
||||
'''Does something useful.'''
|
||||
return f"Result: {param}"
|
||||
|
||||
# Use with Agent
|
||||
agent = Agent(tools=[MyTool(), my_function])
|
||||
"""
|
||||
|
||||
from typing import Dict, Any, List
|
||||
from .base import BaseTool
|
||||
from .decorator import tool
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Example 1: Simple Class-Based Tool
|
||||
# =============================================================================
|
||||
|
||||
class EchoTool(BaseTool):
|
||||
"""A simple tool that echoes input back.
|
||||
|
||||
This demonstrates the minimal BaseTool implementation.
|
||||
"""
|
||||
name = "echo"
|
||||
description = "Echo the input message back"
|
||||
|
||||
def run(self, message: str) -> str:
|
||||
"""Echo the message.
|
||||
|
||||
Args:
|
||||
message: The message to echo
|
||||
|
||||
Returns:
|
||||
The same message
|
||||
"""
|
||||
return message
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Example 2: Tool with Multiple Parameters
|
||||
# =============================================================================
|
||||
|
||||
class MathTool(BaseTool):
|
||||
"""A tool for basic math operations.
|
||||
|
||||
Demonstrates:
|
||||
- Multiple parameters with types
|
||||
- Default parameter values
|
||||
- Returning structured data
|
||||
"""
|
||||
name = "basic_math"
|
||||
description = "Perform basic math operations (add, subtract, multiply, divide)"
|
||||
|
||||
def run(
|
||||
self,
|
||||
a: float,
|
||||
b: float,
|
||||
operation: str = "add"
|
||||
) -> Dict[str, Any]:
|
||||
"""Perform a math operation.
|
||||
|
||||
Args:
|
||||
a: First number
|
||||
b: Second number
|
||||
operation: One of 'add', 'subtract', 'multiply', 'divide'
|
||||
|
||||
Returns:
|
||||
Dict with result and operation details
|
||||
"""
|
||||
operations = {
|
||||
"add": lambda x, y: x + y,
|
||||
"subtract": lambda x, y: x - y,
|
||||
"multiply": lambda x, y: x * y,
|
||||
"divide": lambda x, y: x / y if y != 0 else float('inf')
|
||||
}
|
||||
|
||||
if operation not in operations:
|
||||
return {"error": f"Unknown operation: {operation}"}
|
||||
|
||||
result = operations[operation](a, b)
|
||||
return {
|
||||
"result": result,
|
||||
"operation": operation,
|
||||
"expression": f"{a} {operation} {b} = {result}"
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Example 3: Tool with State (Instance Variables)
|
||||
# =============================================================================
|
||||
|
||||
class CounterTool(BaseTool):
|
||||
"""A tool that maintains state between calls.
|
||||
|
||||
Demonstrates:
|
||||
- Instance variables for state
|
||||
- Custom __init__ with super() call
|
||||
"""
|
||||
name = "counter"
|
||||
description = "A counter that increments each time it's called"
|
||||
|
||||
def __init__(self, start: int = 0):
|
||||
self._count = start
|
||||
super().__init__() # Important: call parent init
|
||||
|
||||
def run(self, increment: int = 1) -> Dict[str, int]:
|
||||
"""Increment the counter.
|
||||
|
||||
Args:
|
||||
increment: Amount to increment by (default: 1)
|
||||
|
||||
Returns:
|
||||
Dict with previous and current count
|
||||
"""
|
||||
previous = self._count
|
||||
self._count += increment
|
||||
return {
|
||||
"previous": previous,
|
||||
"current": self._count,
|
||||
"increment": increment
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Example 4: Decorator-Based Tools
|
||||
# =============================================================================
|
||||
|
||||
@tool
|
||||
def greet(name: str, greeting: str = "Hello") -> str:
|
||||
"""Generate a greeting message.
|
||||
|
||||
Args:
|
||||
name: Name of the person to greet
|
||||
greeting: The greeting word (default: "Hello")
|
||||
|
||||
Returns:
|
||||
A greeting string
|
||||
"""
|
||||
return f"{greeting}, {name}!"
|
||||
|
||||
|
||||
@tool(name="reverse_text", description="Reverse a string of text")
|
||||
def reverse_string(text: str) -> str:
|
||||
"""Reverse the input text."""
|
||||
return text[::-1]
|
||||
|
||||
|
||||
@tool
|
||||
def list_numbers(start: int, end: int, step: int = 1) -> List[int]:
|
||||
"""Generate a list of numbers.
|
||||
|
||||
Args:
|
||||
start: Starting number (inclusive)
|
||||
end: Ending number (exclusive)
|
||||
step: Step between numbers (default: 1)
|
||||
|
||||
Returns:
|
||||
List of integers
|
||||
"""
|
||||
return list(range(start, end, step))
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Example 5: Tool with Error Handling
|
||||
# =============================================================================
|
||||
|
||||
class SafeDivideTool(BaseTool):
|
||||
"""A division tool with proper error handling.
|
||||
|
||||
Demonstrates:
|
||||
- Error handling in run()
|
||||
- Returning error information
|
||||
"""
|
||||
name = "safe_divide"
|
||||
description = "Safely divide two numbers with error handling"
|
||||
|
||||
def run(self, numerator: float, denominator: float) -> Dict[str, Any]:
|
||||
"""Divide numerator by denominator.
|
||||
|
||||
Args:
|
||||
numerator: The number to divide
|
||||
denominator: The number to divide by
|
||||
|
||||
Returns:
|
||||
Dict with result or error message
|
||||
"""
|
||||
if denominator == 0:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Division by zero is not allowed"
|
||||
}
|
||||
|
||||
result = numerator / denominator
|
||||
return {
|
||||
"success": True,
|
||||
"result": result
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Convenience exports
|
||||
# =============================================================================
|
||||
|
||||
# These can be imported directly:
|
||||
# from praisonaiagents.tools.example_plugin import echo_tool, math_tool, greet
|
||||
|
||||
echo_tool = EchoTool()
|
||||
math_tool = MathTool()
|
||||
counter_tool = CounterTool()
|
||||
safe_divide_tool = SafeDivideTool()
|
||||
|
||||
__all__ = [
|
||||
# Classes
|
||||
'EchoTool',
|
||||
'MathTool',
|
||||
'CounterTool',
|
||||
'SafeDivideTool',
|
||||
# Instances
|
||||
'echo_tool',
|
||||
'math_tool',
|
||||
'counter_tool',
|
||||
'safe_divide_tool',
|
||||
# Decorated functions
|
||||
'greet',
|
||||
'reverse_string',
|
||||
'list_numbers',
|
||||
]
|
||||
@@ -0,0 +1,225 @@
|
||||
"""Tool registry for managing and discovering tools.
|
||||
|
||||
This module provides centralized tool management with support for:
|
||||
- Manual registration
|
||||
- Auto-discovery via entry_points
|
||||
- Tool lookup by name
|
||||
|
||||
Usage:
|
||||
from praisonaiagents.tools.registry import get_registry
|
||||
|
||||
registry = get_registry()
|
||||
registry.register(my_tool)
|
||||
tool = registry.get("my_tool")
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Callable, Dict, List, Optional, Union
|
||||
from importlib.metadata import entry_points
|
||||
|
||||
from .base import BaseTool
|
||||
|
||||
|
||||
# Entry point group name for external plugins
|
||||
ENTRY_POINT_GROUP = "praisonaiagents.tools"
|
||||
|
||||
|
||||
class ToolRegistry:
|
||||
"""Central registry for all tools.
|
||||
|
||||
Provides:
|
||||
- Tool registration (manual and auto-discovery)
|
||||
- Tool lookup by name
|
||||
- Tool listing and filtering
|
||||
- Entry points discovery for external plugins
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._tools: Dict[str, BaseTool] = {}
|
||||
self._functions: Dict[str, Callable] = {} # For backward compat with plain functions
|
||||
self._discovered: bool = False
|
||||
|
||||
def register(
|
||||
self,
|
||||
tool: Union[BaseTool, Callable],
|
||||
name: Optional[str] = None,
|
||||
overwrite: bool = False
|
||||
) -> None:
|
||||
"""Register a tool with the registry.
|
||||
|
||||
Args:
|
||||
tool: BaseTool instance or callable function
|
||||
name: Override name (default: tool.name or function.__name__)
|
||||
overwrite: If True, overwrite existing tool with same name
|
||||
|
||||
Raises:
|
||||
ValueError: If tool with same name exists and overwrite=False
|
||||
"""
|
||||
# Handle BaseTool instances
|
||||
if isinstance(tool, BaseTool):
|
||||
tool_name = name or tool.name
|
||||
if tool_name in self._tools and not overwrite:
|
||||
logging.debug(f"Tool '{tool_name}' already registered, skipping")
|
||||
return
|
||||
self._tools[tool_name] = tool
|
||||
logging.debug(f"Registered tool: {tool_name}")
|
||||
return
|
||||
|
||||
# Handle plain callables
|
||||
if callable(tool):
|
||||
tool_name = name or getattr(tool, '__name__', str(id(tool)))
|
||||
if tool_name in self._functions and not overwrite:
|
||||
logging.debug(f"Function '{tool_name}' already registered, skipping")
|
||||
return
|
||||
self._functions[tool_name] = tool
|
||||
logging.debug(f"Registered function: {tool_name}")
|
||||
return
|
||||
|
||||
raise TypeError(f"Cannot register {type(tool)}, expected BaseTool or callable")
|
||||
|
||||
def unregister(self, name: str) -> bool:
|
||||
"""Remove a tool from the registry.
|
||||
|
||||
Args:
|
||||
name: Tool name to remove
|
||||
|
||||
Returns:
|
||||
True if tool was removed, False if not found
|
||||
"""
|
||||
if name in self._tools:
|
||||
del self._tools[name]
|
||||
return True
|
||||
if name in self._functions:
|
||||
del self._functions[name]
|
||||
return True
|
||||
return False
|
||||
|
||||
def get(self, name: str) -> Optional[Union[BaseTool, Callable]]:
|
||||
"""Get a tool by name.
|
||||
|
||||
Args:
|
||||
name: Tool name
|
||||
|
||||
Returns:
|
||||
BaseTool instance, callable, or None if not found
|
||||
"""
|
||||
# Check BaseTool registry first
|
||||
if name in self._tools:
|
||||
return self._tools[name]
|
||||
|
||||
# Check functions registry
|
||||
if name in self._functions:
|
||||
return self._functions[name]
|
||||
|
||||
# Try auto-discovery if not found
|
||||
if not self._discovered:
|
||||
self.discover_plugins()
|
||||
if name in self._tools:
|
||||
return self._tools[name]
|
||||
|
||||
return None
|
||||
|
||||
def list_tools(self) -> List[str]:
|
||||
"""List all registered tool names."""
|
||||
return list(self._tools.keys()) + list(self._functions.keys())
|
||||
|
||||
def list_base_tools(self) -> List[BaseTool]:
|
||||
"""List all registered BaseTool instances."""
|
||||
return list(self._tools.values())
|
||||
|
||||
def get_all(self) -> Dict[str, Union[BaseTool, Callable]]:
|
||||
"""Get all registered tools as a dict."""
|
||||
result = dict(self._tools)
|
||||
result.update(self._functions)
|
||||
return result
|
||||
|
||||
def discover_plugins(self) -> int:
|
||||
"""Discover and register tools from entry_points.
|
||||
|
||||
External packages can register tools by adding to pyproject.toml:
|
||||
|
||||
[project.entry-points."praisonaiagents.tools"]
|
||||
my_tool = "my_package.tools:MyTool"
|
||||
|
||||
Returns:
|
||||
Number of tools discovered
|
||||
"""
|
||||
if self._discovered:
|
||||
return 0
|
||||
|
||||
count = 0
|
||||
try:
|
||||
# Python 3.10+ style
|
||||
eps = entry_points(group=ENTRY_POINT_GROUP)
|
||||
except TypeError:
|
||||
# Python 3.9 fallback
|
||||
try:
|
||||
all_eps = entry_points()
|
||||
eps = all_eps.get(ENTRY_POINT_GROUP, [])
|
||||
except Exception:
|
||||
eps = []
|
||||
|
||||
for ep in eps:
|
||||
try:
|
||||
tool_class_or_func = ep.load()
|
||||
|
||||
# If it's a class, instantiate it
|
||||
if isinstance(tool_class_or_func, type) and issubclass(tool_class_or_func, BaseTool):
|
||||
tool_instance = tool_class_or_func()
|
||||
self.register(tool_instance, name=ep.name)
|
||||
# If it's already an instance or callable
|
||||
elif isinstance(tool_class_or_func, BaseTool):
|
||||
self.register(tool_class_or_func, name=ep.name)
|
||||
elif callable(tool_class_or_func):
|
||||
self.register(tool_class_or_func, name=ep.name)
|
||||
else:
|
||||
logging.warning(f"Entry point '{ep.name}' is not a valid tool")
|
||||
continue
|
||||
|
||||
count += 1
|
||||
logging.info(f"Discovered plugin tool: {ep.name}")
|
||||
except Exception as e:
|
||||
logging.warning(f"Failed to load plugin '{ep.name}': {e}")
|
||||
|
||||
self._discovered = True
|
||||
return count
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear all registered tools."""
|
||||
self._tools.clear()
|
||||
self._functions.clear()
|
||||
self._discovered = False
|
||||
|
||||
def __contains__(self, name: str) -> bool:
|
||||
return name in self._tools or name in self._functions
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self._tools) + len(self._functions)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"ToolRegistry(tools={len(self._tools)}, functions={len(self._functions)})"
|
||||
|
||||
|
||||
# Global registry instance
|
||||
_global_registry: Optional[ToolRegistry] = None
|
||||
|
||||
|
||||
def get_registry() -> ToolRegistry:
|
||||
"""Get the global tool registry instance."""
|
||||
global _global_registry
|
||||
if _global_registry is None:
|
||||
_global_registry = ToolRegistry()
|
||||
return _global_registry
|
||||
|
||||
|
||||
def register_tool(
|
||||
tool: Union[BaseTool, Callable],
|
||||
name: Optional[str] = None
|
||||
) -> None:
|
||||
"""Convenience function to register a tool with the global registry."""
|
||||
get_registry().register(tool, name=name)
|
||||
|
||||
|
||||
def get_tool(name: str) -> Optional[Union[BaseTool, Callable]]:
|
||||
"""Convenience function to get a tool from the global registry."""
|
||||
return get_registry().get(name)
|
||||
@@ -0,0 +1,591 @@
|
||||
"""Tavily search and extraction tools.
|
||||
|
||||
Tavily provides AI-powered web search, content extraction, and crawling capabilities.
|
||||
This module requires the TAVILY_API_KEY environment variable to be set.
|
||||
|
||||
Usage:
|
||||
from praisonaiagents.tools import tavily_search, tavily_extract
|
||||
|
||||
# Search the web
|
||||
results = tavily_search("AI news 2024")
|
||||
|
||||
# Extract content from URLs
|
||||
content = tavily_extract("https://example.com")
|
||||
|
||||
# Or use the class directly
|
||||
from praisonaiagents.tools import TavilyTools
|
||||
tavily = TavilyTools()
|
||||
results = tavily.search("AI news")
|
||||
"""
|
||||
|
||||
from typing import List, Dict, Any, Optional, Union
|
||||
import logging
|
||||
import os
|
||||
from importlib import util
|
||||
|
||||
|
||||
def _check_tavily_available() -> tuple[bool, Optional[str]]:
|
||||
"""Check if Tavily is available and API key is set.
|
||||
|
||||
Returns:
|
||||
Tuple of (is_available, error_message)
|
||||
"""
|
||||
# Check if tavily package is installed
|
||||
if util.find_spec("tavily") is None:
|
||||
return False, "Tavily package is not installed. Please install it using: pip install tavily-python"
|
||||
|
||||
# Check if API key is set
|
||||
api_key = os.environ.get("TAVILY_API_KEY")
|
||||
if not api_key:
|
||||
return False, "TAVILY_API_KEY environment variable is not set. Please set it to use Tavily tools."
|
||||
|
||||
return True, None
|
||||
|
||||
|
||||
class TavilyTools:
|
||||
"""Comprehensive tools for web search and content extraction using Tavily API.
|
||||
|
||||
Tavily provides AI-powered search capabilities optimized for LLM applications.
|
||||
|
||||
Features:
|
||||
- Web search with advanced filtering and ranking
|
||||
- Content extraction from URLs
|
||||
- Website crawling with intelligent navigation
|
||||
- Site mapping
|
||||
|
||||
Requires:
|
||||
- tavily-python package: pip install tavily-python
|
||||
- TAVILY_API_KEY environment variable
|
||||
"""
|
||||
|
||||
def __init__(self, api_key: Optional[str] = None):
|
||||
"""Initialize TavilyTools.
|
||||
|
||||
Args:
|
||||
api_key: Optional API key. If not provided, uses TAVILY_API_KEY env var.
|
||||
"""
|
||||
self._api_key = api_key
|
||||
self._client = None
|
||||
self._async_client = None
|
||||
|
||||
def _get_api_key(self) -> Optional[str]:
|
||||
"""Get API key from instance or environment."""
|
||||
return self._api_key or os.environ.get("TAVILY_API_KEY")
|
||||
|
||||
def _get_client(self):
|
||||
"""Get or create synchronous Tavily client."""
|
||||
if self._client is None:
|
||||
is_available, error = _check_tavily_available()
|
||||
if not is_available:
|
||||
return None, error
|
||||
|
||||
from tavily import TavilyClient
|
||||
api_key = self._get_api_key()
|
||||
if not api_key:
|
||||
return None, "TAVILY_API_KEY not set"
|
||||
self._client = TavilyClient(api_key=api_key)
|
||||
return self._client, None
|
||||
|
||||
def _get_async_client(self):
|
||||
"""Get or create asynchronous Tavily client."""
|
||||
if self._async_client is None:
|
||||
is_available, error = _check_tavily_available()
|
||||
if not is_available:
|
||||
return None, error
|
||||
|
||||
from tavily import AsyncTavilyClient
|
||||
api_key = self._get_api_key()
|
||||
if not api_key:
|
||||
return None, "TAVILY_API_KEY not set"
|
||||
self._async_client = AsyncTavilyClient(api_key=api_key)
|
||||
return self._async_client, None
|
||||
|
||||
def search(
|
||||
self,
|
||||
query: str,
|
||||
search_depth: str = "basic",
|
||||
topic: str = "general",
|
||||
max_results: int = 5,
|
||||
include_answer: bool = False,
|
||||
include_raw_content: bool = False,
|
||||
include_images: bool = False,
|
||||
include_domains: Optional[List[str]] = None,
|
||||
exclude_domains: Optional[List[str]] = None,
|
||||
time_range: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Search the web using Tavily's AI-powered search.
|
||||
|
||||
Args:
|
||||
query: The search query
|
||||
search_depth: "basic" or "advanced" (advanced uses more credits but better results)
|
||||
topic: "general", "news", or "finance"
|
||||
max_results: Maximum number of results (1-20)
|
||||
include_answer: Include LLM-generated answer based on results
|
||||
include_raw_content: Include cleaned HTML content of each result
|
||||
include_images: Include query-related images
|
||||
include_domains: List of domains to include (max 300)
|
||||
exclude_domains: List of domains to exclude (max 150)
|
||||
time_range: Filter by time - "day", "week", "month", "year" or "d", "w", "m", "y"
|
||||
|
||||
Returns:
|
||||
Dict containing search results with keys:
|
||||
- results: List of search results with title, url, content, score
|
||||
- query: The search query
|
||||
- response_time: Time taken for search
|
||||
- answer: (optional) LLM-generated answer if include_answer=True
|
||||
- images: (optional) List of image URLs if include_images=True
|
||||
"""
|
||||
client, error = self._get_client()
|
||||
if error:
|
||||
logging.error(error)
|
||||
return {"error": error}
|
||||
|
||||
try:
|
||||
kwargs = {
|
||||
"query": query,
|
||||
"search_depth": search_depth,
|
||||
"topic": topic,
|
||||
"max_results": max_results,
|
||||
"include_answer": include_answer,
|
||||
"include_raw_content": include_raw_content,
|
||||
"include_images": include_images,
|
||||
}
|
||||
|
||||
if include_domains:
|
||||
kwargs["include_domains"] = include_domains
|
||||
if exclude_domains:
|
||||
kwargs["exclude_domains"] = exclude_domains
|
||||
if time_range:
|
||||
kwargs["time_range"] = time_range
|
||||
|
||||
response = client.search(**kwargs)
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Tavily search error: {str(e)}"
|
||||
logging.error(error_msg)
|
||||
return {"error": error_msg}
|
||||
|
||||
def extract(
|
||||
self,
|
||||
urls: Union[str, List[str]],
|
||||
include_images: bool = False,
|
||||
extract_depth: str = "basic",
|
||||
) -> Dict[str, Any]:
|
||||
"""Extract content from one or more URLs.
|
||||
|
||||
Args:
|
||||
urls: Single URL or list of URLs (max 20)
|
||||
include_images: Include extracted images
|
||||
extract_depth: "basic" or "advanced" (advanced retrieves more data)
|
||||
|
||||
Returns:
|
||||
Dict containing:
|
||||
- results: List of successful extractions with url, raw_content
|
||||
- failed_results: List of URLs that failed with error messages
|
||||
- response_time: Time taken for extraction
|
||||
"""
|
||||
client, error = self._get_client()
|
||||
if error:
|
||||
logging.error(error)
|
||||
return {"error": error}
|
||||
|
||||
try:
|
||||
response = client.extract(
|
||||
urls=urls,
|
||||
include_images=include_images,
|
||||
extract_depth=extract_depth,
|
||||
)
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Tavily extract error: {str(e)}"
|
||||
logging.error(error_msg)
|
||||
return {"error": error_msg}
|
||||
|
||||
def crawl(
|
||||
self,
|
||||
url: str,
|
||||
max_depth: int = 1,
|
||||
max_breadth: int = 20,
|
||||
limit: int = 50,
|
||||
instructions: Optional[str] = None,
|
||||
include_images: bool = False,
|
||||
extract_depth: str = "basic",
|
||||
) -> Dict[str, Any]:
|
||||
"""Crawl a website starting from a base URL.
|
||||
|
||||
Args:
|
||||
url: The root URL to begin crawling
|
||||
max_depth: Maximum depth from base URL (default: 1)
|
||||
max_breadth: Max links to follow per page (default: 20)
|
||||
limit: Total links to process before stopping (default: 50)
|
||||
instructions: Natural language instructions for the crawler
|
||||
include_images: Extract image URLs from pages
|
||||
extract_depth: "basic" or "advanced"
|
||||
|
||||
Returns:
|
||||
Dict containing:
|
||||
- base_url: The starting URL
|
||||
- results: List of crawled pages with url, raw_content, images
|
||||
- response_time: Time taken for crawl
|
||||
"""
|
||||
client, error = self._get_client()
|
||||
if error:
|
||||
logging.error(error)
|
||||
return {"error": error}
|
||||
|
||||
try:
|
||||
kwargs = {
|
||||
"url": url,
|
||||
"max_depth": max_depth,
|
||||
"max_breadth": max_breadth,
|
||||
"limit": limit,
|
||||
"include_images": include_images,
|
||||
"extract_depth": extract_depth,
|
||||
}
|
||||
|
||||
if instructions:
|
||||
kwargs["instructions"] = instructions
|
||||
|
||||
response = client.crawl(**kwargs)
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Tavily crawl error: {str(e)}"
|
||||
logging.error(error_msg)
|
||||
return {"error": error_msg}
|
||||
|
||||
def map(
|
||||
self,
|
||||
url: str,
|
||||
max_depth: int = 1,
|
||||
max_breadth: int = 20,
|
||||
limit: int = 50,
|
||||
instructions: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Get a sitemap starting from a base URL.
|
||||
|
||||
Args:
|
||||
url: The root URL to begin mapping
|
||||
max_depth: Maximum depth from base URL (default: 1)
|
||||
max_breadth: Max links to follow per page (default: 20)
|
||||
limit: Total links to process before stopping (default: 50)
|
||||
instructions: Natural language instructions for the mapper
|
||||
|
||||
Returns:
|
||||
Dict containing:
|
||||
- base_url: The starting URL
|
||||
- results: List of discovered URLs
|
||||
- response_time: Time taken for mapping
|
||||
"""
|
||||
client, error = self._get_client()
|
||||
if error:
|
||||
logging.error(error)
|
||||
return {"error": error}
|
||||
|
||||
try:
|
||||
kwargs = {
|
||||
"url": url,
|
||||
"max_depth": max_depth,
|
||||
"max_breadth": max_breadth,
|
||||
"limit": limit,
|
||||
}
|
||||
|
||||
if instructions:
|
||||
kwargs["instructions"] = instructions
|
||||
|
||||
response = client.map(**kwargs)
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Tavily map error: {str(e)}"
|
||||
logging.error(error_msg)
|
||||
return {"error": error_msg}
|
||||
|
||||
# Async methods
|
||||
async def asearch(
|
||||
self,
|
||||
query: str,
|
||||
search_depth: str = "basic",
|
||||
topic: str = "general",
|
||||
max_results: int = 5,
|
||||
include_answer: bool = False,
|
||||
include_raw_content: bool = False,
|
||||
include_images: bool = False,
|
||||
include_domains: Optional[List[str]] = None,
|
||||
exclude_domains: Optional[List[str]] = None,
|
||||
time_range: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Async version of search. See search() for documentation."""
|
||||
client, error = self._get_async_client()
|
||||
if error:
|
||||
logging.error(error)
|
||||
return {"error": error}
|
||||
|
||||
try:
|
||||
kwargs = {
|
||||
"query": query,
|
||||
"search_depth": search_depth,
|
||||
"topic": topic,
|
||||
"max_results": max_results,
|
||||
"include_answer": include_answer,
|
||||
"include_raw_content": include_raw_content,
|
||||
"include_images": include_images,
|
||||
}
|
||||
|
||||
if include_domains:
|
||||
kwargs["include_domains"] = include_domains
|
||||
if exclude_domains:
|
||||
kwargs["exclude_domains"] = exclude_domains
|
||||
if time_range:
|
||||
kwargs["time_range"] = time_range
|
||||
|
||||
response = await client.search(**kwargs)
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Tavily async search error: {str(e)}"
|
||||
logging.error(error_msg)
|
||||
return {"error": error_msg}
|
||||
|
||||
async def aextract(
|
||||
self,
|
||||
urls: Union[str, List[str]],
|
||||
include_images: bool = False,
|
||||
extract_depth: str = "basic",
|
||||
) -> Dict[str, Any]:
|
||||
"""Async version of extract. See extract() for documentation."""
|
||||
client, error = self._get_async_client()
|
||||
if error:
|
||||
logging.error(error)
|
||||
return {"error": error}
|
||||
|
||||
try:
|
||||
response = await client.extract(
|
||||
urls=urls,
|
||||
include_images=include_images,
|
||||
extract_depth=extract_depth,
|
||||
)
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Tavily async extract error: {str(e)}"
|
||||
logging.error(error_msg)
|
||||
return {"error": error_msg}
|
||||
|
||||
|
||||
# Standalone functions for direct import
|
||||
|
||||
def tavily_search(
|
||||
query: str,
|
||||
search_depth: str = "basic",
|
||||
topic: str = "general",
|
||||
max_results: int = 5,
|
||||
include_answer: bool = False,
|
||||
include_raw_content: bool = False,
|
||||
include_images: bool = False,
|
||||
include_domains: Optional[List[str]] = None,
|
||||
exclude_domains: Optional[List[str]] = None,
|
||||
time_range: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Search the web using Tavily's AI-powered search.
|
||||
|
||||
This is a convenience function that creates a TavilyTools instance.
|
||||
For repeated calls, consider using TavilyTools class directly.
|
||||
|
||||
Args:
|
||||
query: The search query
|
||||
search_depth: "basic" or "advanced"
|
||||
topic: "general", "news", or "finance"
|
||||
max_results: Maximum number of results (1-20)
|
||||
include_answer: Include LLM-generated answer
|
||||
include_raw_content: Include cleaned HTML content
|
||||
include_images: Include query-related images
|
||||
include_domains: List of domains to include
|
||||
exclude_domains: List of domains to exclude
|
||||
time_range: Filter by time - "day", "week", "month", "year"
|
||||
|
||||
Returns:
|
||||
Dict containing search results
|
||||
"""
|
||||
tools = TavilyTools()
|
||||
return tools.search(
|
||||
query=query,
|
||||
search_depth=search_depth,
|
||||
topic=topic,
|
||||
max_results=max_results,
|
||||
include_answer=include_answer,
|
||||
include_raw_content=include_raw_content,
|
||||
include_images=include_images,
|
||||
include_domains=include_domains,
|
||||
exclude_domains=exclude_domains,
|
||||
time_range=time_range,
|
||||
)
|
||||
|
||||
|
||||
def tavily_extract(
|
||||
urls: Union[str, List[str]],
|
||||
include_images: bool = False,
|
||||
extract_depth: str = "basic",
|
||||
) -> Dict[str, Any]:
|
||||
"""Extract content from one or more URLs using Tavily.
|
||||
|
||||
Args:
|
||||
urls: Single URL or list of URLs (max 20)
|
||||
include_images: Include extracted images
|
||||
extract_depth: "basic" or "advanced"
|
||||
|
||||
Returns:
|
||||
Dict containing extraction results
|
||||
"""
|
||||
tools = TavilyTools()
|
||||
return tools.extract(
|
||||
urls=urls,
|
||||
include_images=include_images,
|
||||
extract_depth=extract_depth,
|
||||
)
|
||||
|
||||
|
||||
def tavily_crawl(
|
||||
url: str,
|
||||
max_depth: int = 1,
|
||||
max_breadth: int = 20,
|
||||
limit: int = 50,
|
||||
instructions: Optional[str] = None,
|
||||
include_images: bool = False,
|
||||
extract_depth: str = "basic",
|
||||
) -> Dict[str, Any]:
|
||||
"""Crawl a website starting from a base URL using Tavily.
|
||||
|
||||
Args:
|
||||
url: The root URL to begin crawling
|
||||
max_depth: Maximum depth from base URL
|
||||
max_breadth: Max links to follow per page
|
||||
limit: Total links to process before stopping
|
||||
instructions: Natural language instructions for the crawler
|
||||
include_images: Extract image URLs from pages
|
||||
extract_depth: "basic" or "advanced"
|
||||
|
||||
Returns:
|
||||
Dict containing crawl results
|
||||
"""
|
||||
tools = TavilyTools()
|
||||
return tools.crawl(
|
||||
url=url,
|
||||
max_depth=max_depth,
|
||||
max_breadth=max_breadth,
|
||||
limit=limit,
|
||||
instructions=instructions,
|
||||
include_images=include_images,
|
||||
extract_depth=extract_depth,
|
||||
)
|
||||
|
||||
|
||||
def tavily_map(
|
||||
url: str,
|
||||
max_depth: int = 1,
|
||||
max_breadth: int = 20,
|
||||
limit: int = 50,
|
||||
instructions: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Get a sitemap starting from a base URL using Tavily.
|
||||
|
||||
Args:
|
||||
url: The root URL to begin mapping
|
||||
max_depth: Maximum depth from base URL
|
||||
max_breadth: Max links to follow per page
|
||||
limit: Total links to process before stopping
|
||||
instructions: Natural language instructions for the mapper
|
||||
|
||||
Returns:
|
||||
Dict containing map results with discovered URLs
|
||||
"""
|
||||
tools = TavilyTools()
|
||||
return tools.map(
|
||||
url=url,
|
||||
max_depth=max_depth,
|
||||
max_breadth=max_breadth,
|
||||
limit=limit,
|
||||
instructions=instructions,
|
||||
)
|
||||
|
||||
|
||||
# Async standalone functions
|
||||
async def tavily_search_async(
|
||||
query: str,
|
||||
search_depth: str = "basic",
|
||||
topic: str = "general",
|
||||
max_results: int = 5,
|
||||
include_answer: bool = False,
|
||||
include_raw_content: bool = False,
|
||||
include_images: bool = False,
|
||||
include_domains: Optional[List[str]] = None,
|
||||
exclude_domains: Optional[List[str]] = None,
|
||||
time_range: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Async version of tavily_search. See tavily_search() for documentation."""
|
||||
tools = TavilyTools()
|
||||
return await tools.asearch(
|
||||
query=query,
|
||||
search_depth=search_depth,
|
||||
topic=topic,
|
||||
max_results=max_results,
|
||||
include_answer=include_answer,
|
||||
include_raw_content=include_raw_content,
|
||||
include_images=include_images,
|
||||
include_domains=include_domains,
|
||||
exclude_domains=exclude_domains,
|
||||
time_range=time_range,
|
||||
)
|
||||
|
||||
|
||||
async def tavily_extract_async(
|
||||
urls: Union[str, List[str]],
|
||||
include_images: bool = False,
|
||||
extract_depth: str = "basic",
|
||||
) -> Dict[str, Any]:
|
||||
"""Async version of tavily_extract. See tavily_extract() for documentation."""
|
||||
tools = TavilyTools()
|
||||
return await tools.aextract(
|
||||
urls=urls,
|
||||
include_images=include_images,
|
||||
extract_depth=extract_depth,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Example usage
|
||||
print("\n==================================================")
|
||||
print("Tavily Tools Demonstration")
|
||||
print("==================================================\n")
|
||||
|
||||
# Check if API key is available
|
||||
is_available, error = _check_tavily_available()
|
||||
if not is_available:
|
||||
print(f"Error: {error}")
|
||||
print("\nTo use Tavily tools:")
|
||||
print("1. Install: pip install tavily-python")
|
||||
print("2. Set environment variable: export TAVILY_API_KEY=your_api_key")
|
||||
else:
|
||||
print("Tavily is available!")
|
||||
|
||||
# Example search
|
||||
print("\n1. Basic Search")
|
||||
print("-" * 30)
|
||||
results = tavily_search("Latest AI developments 2024", max_results=3)
|
||||
if "error" not in results:
|
||||
print(f"Found {len(results.get('results', []))} results")
|
||||
for r in results.get("results", [])[:3]:
|
||||
print(f" - {r.get('title', 'No title')}")
|
||||
print(f" URL: {r.get('url', 'No URL')}")
|
||||
else:
|
||||
print(f"Error: {results['error']}")
|
||||
|
||||
print("\n==================================================")
|
||||
print("Demonstration Complete")
|
||||
print("==================================================")
|
||||
|
||||
|
||||
# Alias for simple usage: from praisonaiagents.tools import tavily
|
||||
tavily = tavily_search
|
||||
@@ -0,0 +1,533 @@
|
||||
"""You.com search and content tools.
|
||||
|
||||
You.com provides AI-powered web search, news search, content extraction, and image search.
|
||||
This module requires the YDC_API_KEY environment variable to be set.
|
||||
|
||||
Usage:
|
||||
from praisonaiagents.tools import ydc_search, ydc_news, ydc_contents
|
||||
|
||||
# Search the web (unified web + news)
|
||||
results = ydc_search("AI news 2024")
|
||||
|
||||
# Get live news
|
||||
news = ydc_news("technology trends")
|
||||
|
||||
# Extract content from URLs
|
||||
content = ydc_contents(["https://example.com"])
|
||||
|
||||
# Or use the class directly
|
||||
from praisonaiagents.tools import YouTools
|
||||
you = YouTools()
|
||||
results = you.search("AI news")
|
||||
"""
|
||||
|
||||
from typing import List, Dict, Any, Optional, Union
|
||||
import logging
|
||||
import os
|
||||
from importlib import util
|
||||
|
||||
|
||||
def _check_youdotcom_available() -> tuple[bool, Optional[str]]:
|
||||
"""Check if You.com SDK is available and API key is set.
|
||||
|
||||
Returns:
|
||||
Tuple of (is_available, error_message)
|
||||
"""
|
||||
# Check if youdotcom package is installed
|
||||
if util.find_spec("youdotcom") is None:
|
||||
return False, "youdotcom package is not installed. Please install it using: pip install youdotcom"
|
||||
|
||||
# Check if API key is set
|
||||
api_key = os.environ.get("YDC_API_KEY")
|
||||
if not api_key:
|
||||
return False, "YDC_API_KEY environment variable is not set. Please set it to use You.com tools."
|
||||
|
||||
return True, None
|
||||
|
||||
|
||||
class YouTools:
|
||||
"""Comprehensive tools for web search and content using You.com API.
|
||||
|
||||
You.com provides AI-powered search capabilities optimized for LLM applications.
|
||||
|
||||
Features:
|
||||
- Unified web and news search with LLM-ready snippets
|
||||
- Live news search from authoritative sources
|
||||
- Content extraction from URLs (HTML/Markdown)
|
||||
- Image search
|
||||
- Advanced search operators support
|
||||
- Geographic and language targeting
|
||||
- Freshness controls
|
||||
|
||||
Requires:
|
||||
- youdotcom package: pip install youdotcom
|
||||
- YDC_API_KEY environment variable
|
||||
"""
|
||||
|
||||
def __init__(self, api_key: Optional[str] = None):
|
||||
"""Initialize YouTools.
|
||||
|
||||
Args:
|
||||
api_key: Optional API key. If not provided, uses YDC_API_KEY env var.
|
||||
"""
|
||||
self._api_key = api_key
|
||||
self._client = None
|
||||
|
||||
def _get_api_key(self) -> Optional[str]:
|
||||
"""Get API key from instance or environment."""
|
||||
return self._api_key or os.environ.get("YDC_API_KEY")
|
||||
|
||||
def _get_client(self):
|
||||
"""Get or create You.com client."""
|
||||
if self._client is None:
|
||||
is_available, error = _check_youdotcom_available()
|
||||
if not is_available:
|
||||
return None, error
|
||||
|
||||
from youdotcom import You
|
||||
api_key = self._get_api_key()
|
||||
if not api_key:
|
||||
return None, "YDC_API_KEY not set"
|
||||
self._client = You(api_key_auth=api_key)
|
||||
return self._client, None
|
||||
|
||||
def search(
|
||||
self,
|
||||
query: str,
|
||||
count: int = 10,
|
||||
freshness: Optional[str] = None,
|
||||
country: Optional[str] = None,
|
||||
language: Optional[str] = None,
|
||||
offset: int = 0,
|
||||
safesearch: str = "moderate",
|
||||
livecrawl: Optional[str] = None,
|
||||
livecrawl_formats: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Search the web using You.com's unified search API.
|
||||
|
||||
Returns both web and news results in a single call with LLM-ready snippets.
|
||||
|
||||
Args:
|
||||
query: Search query (supports operators like site:, filetype:, +term, -term)
|
||||
count: Max results per section (default 10, max 100)
|
||||
freshness: Filter by recency - "day", "week", "month", "year" or "YYYY-MM-DDtoYYYY-MM-DD"
|
||||
country: ISO country code (e.g., "US", "GB", "FR")
|
||||
language: BCP 47 language code (e.g., "EN", "JP", "DE")
|
||||
offset: Pagination offset (0-9), results = offset * count
|
||||
safesearch: Content filter - "off", "moderate", "strict"
|
||||
livecrawl: Fetch full content - "web", "news", or "all"
|
||||
livecrawl_formats: Content format - "html" or "markdown"
|
||||
|
||||
Returns:
|
||||
Dict containing:
|
||||
- results: {web: [...], news: [...]}
|
||||
- metadata: {query, search_uuid, latency}
|
||||
|
||||
Search Operators:
|
||||
- site:domain.com - Search within specific domain
|
||||
- filetype:pdf - Filter by file type
|
||||
- +term - Must include term
|
||||
- -term - Exclude term
|
||||
- AND, OR, NOT - Boolean operators
|
||||
"""
|
||||
client, error = self._get_client()
|
||||
if error:
|
||||
logging.error(error)
|
||||
return {"error": error}
|
||||
|
||||
try:
|
||||
kwargs = {"query": query, "count": count, "offset": offset}
|
||||
|
||||
if freshness:
|
||||
kwargs["freshness"] = freshness
|
||||
if country:
|
||||
kwargs["country"] = country
|
||||
if language:
|
||||
kwargs["language"] = language
|
||||
if safesearch:
|
||||
kwargs["safesearch"] = safesearch
|
||||
if livecrawl:
|
||||
kwargs["livecrawl"] = livecrawl
|
||||
if livecrawl_formats:
|
||||
kwargs["livecrawl_formats"] = livecrawl_formats
|
||||
|
||||
response = client.search.unified(**kwargs)
|
||||
|
||||
# Convert response to dict if it's a model object
|
||||
if hasattr(response, 'model_dump'):
|
||||
return response.model_dump()
|
||||
elif hasattr(response, 'to_dict'):
|
||||
return response.to_dict()
|
||||
elif hasattr(response, '__dict__'):
|
||||
return self._convert_response(response)
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"You.com search error: {str(e)}"
|
||||
logging.error(error_msg)
|
||||
return {"error": error_msg}
|
||||
|
||||
def _convert_response(self, response) -> Dict[str, Any]:
|
||||
"""Convert SDK response object to dictionary."""
|
||||
result = {}
|
||||
|
||||
if hasattr(response, 'results'):
|
||||
results = response.results
|
||||
result['results'] = {}
|
||||
if hasattr(results, 'web') and results.web:
|
||||
result['results']['web'] = [
|
||||
{
|
||||
'url': getattr(r, 'url', ''),
|
||||
'title': getattr(r, 'title', ''),
|
||||
'description': getattr(r, 'description', ''),
|
||||
'snippets': getattr(r, 'snippets', []),
|
||||
'thumbnail_url': getattr(r, 'thumbnail_url', None),
|
||||
'page_age': getattr(r, 'page_age', None),
|
||||
'favicon_url': getattr(r, 'favicon_url', None),
|
||||
'authors': getattr(r, 'authors', []),
|
||||
}
|
||||
for r in results.web
|
||||
]
|
||||
if hasattr(results, 'news') and results.news:
|
||||
result['results']['news'] = [
|
||||
{
|
||||
'url': getattr(r, 'url', ''),
|
||||
'title': getattr(r, 'title', ''),
|
||||
'description': getattr(r, 'description', ''),
|
||||
'thumbnail_url': getattr(r, 'thumbnail_url', None),
|
||||
'page_age': getattr(r, 'page_age', None),
|
||||
}
|
||||
for r in results.news
|
||||
]
|
||||
|
||||
if hasattr(response, 'metadata'):
|
||||
meta = response.metadata
|
||||
result['metadata'] = {
|
||||
'query': getattr(meta, 'query', ''),
|
||||
'search_uuid': getattr(meta, 'search_uuid', ''),
|
||||
'latency': getattr(meta, 'latency', 0),
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
def get_contents(
|
||||
self,
|
||||
urls: Union[str, List[str]],
|
||||
format: str = "markdown",
|
||||
) -> Dict[str, Any]:
|
||||
"""Fetch content from URLs using You.com's content extraction API.
|
||||
|
||||
Args:
|
||||
urls: Single URL or list of URLs to fetch content from
|
||||
format: Content format - "html" or "markdown" (default: markdown)
|
||||
|
||||
Returns:
|
||||
Dict containing array of results with:
|
||||
- url: The fetched URL
|
||||
- title: Page title
|
||||
- html: HTML content (if format="html")
|
||||
- markdown: Markdown content (if format="markdown")
|
||||
- metadata: {site_name, favicon_url}
|
||||
"""
|
||||
client, error = self._get_client()
|
||||
if error:
|
||||
logging.error(error)
|
||||
return {"error": error}
|
||||
|
||||
try:
|
||||
if isinstance(urls, str):
|
||||
urls = [urls]
|
||||
|
||||
# Use the contents endpoint
|
||||
response = client.search.contents(urls=urls, format=format)
|
||||
|
||||
# Convert response
|
||||
if hasattr(response, 'model_dump'):
|
||||
return {"results": response.model_dump()}
|
||||
elif isinstance(response, list):
|
||||
return {"results": [
|
||||
{
|
||||
'url': getattr(r, 'url', ''),
|
||||
'title': getattr(r, 'title', ''),
|
||||
'html': getattr(r, 'html', None),
|
||||
'markdown': getattr(r, 'markdown', None),
|
||||
'metadata': {
|
||||
'site_name': getattr(getattr(r, 'metadata', None), 'site_name', None) if hasattr(r, 'metadata') else None,
|
||||
'favicon_url': getattr(getattr(r, 'metadata', None), 'favicon_url', None) if hasattr(r, 'metadata') else None,
|
||||
}
|
||||
}
|
||||
for r in response
|
||||
]}
|
||||
return {"results": response}
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"You.com contents error: {str(e)}"
|
||||
logging.error(error_msg)
|
||||
return {"error": error_msg}
|
||||
|
||||
def live_news(
|
||||
self,
|
||||
query: str,
|
||||
count: Optional[int] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Search for live news using You.com's news API.
|
||||
|
||||
Note: This API may require early access. Contact api@you.com for access.
|
||||
|
||||
Args:
|
||||
query: News search query
|
||||
count: Maximum number of news results to return
|
||||
|
||||
Returns:
|
||||
Dict containing:
|
||||
- news: {query, results: [...], metadata}
|
||||
- Each result has: title, description, url, page_age, source_name, thumbnail
|
||||
"""
|
||||
client, error = self._get_client()
|
||||
if error:
|
||||
logging.error(error)
|
||||
return {"error": error}
|
||||
|
||||
try:
|
||||
kwargs = {"q": query}
|
||||
if count:
|
||||
kwargs["count"] = count
|
||||
|
||||
# Try to access live news endpoint
|
||||
if hasattr(client, 'livenews'):
|
||||
response = client.livenews.get(**kwargs)
|
||||
else:
|
||||
# Fallback: use unified search with news focus
|
||||
return self.search(query=query, count=count or 10)
|
||||
|
||||
if hasattr(response, 'model_dump'):
|
||||
return response.model_dump()
|
||||
elif hasattr(response, 'news'):
|
||||
news = response.news
|
||||
return {
|
||||
'news': {
|
||||
'query': getattr(getattr(news, 'query', None), 'original', query) if hasattr(news, 'query') else query,
|
||||
'results': [
|
||||
{
|
||||
'title': getattr(r, 'title', ''),
|
||||
'description': getattr(r, 'description', ''),
|
||||
'url': getattr(r, 'url', ''),
|
||||
'page_age': getattr(r, 'page_age', None),
|
||||
'age': getattr(r, 'age', None),
|
||||
'source_name': getattr(r, 'source_name', None),
|
||||
'thumbnail': getattr(getattr(r, 'thumbnail', None), 'src', None) if hasattr(r, 'thumbnail') else None,
|
||||
}
|
||||
for r in getattr(news, 'results', [])
|
||||
],
|
||||
'metadata': {
|
||||
'request_uuid': getattr(getattr(news, 'metadata', None), 'request_uuid', None) if hasattr(news, 'metadata') else None,
|
||||
}
|
||||
}
|
||||
}
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"You.com live news error: {str(e)}"
|
||||
logging.error(error_msg)
|
||||
return {"error": error_msg}
|
||||
|
||||
def images(
|
||||
self,
|
||||
query: str,
|
||||
) -> Dict[str, Any]:
|
||||
"""Search for images using You.com's image search API.
|
||||
|
||||
Note: This API may require early access. Contact api@you.com for access.
|
||||
|
||||
Args:
|
||||
query: Image search query
|
||||
|
||||
Returns:
|
||||
Dict containing:
|
||||
- images: {results: [...]}
|
||||
- metadata: {query, search_uuid}
|
||||
- Each result has: title, page_url, image_url
|
||||
"""
|
||||
client, error = self._get_client()
|
||||
if error:
|
||||
logging.error(error)
|
||||
return {"error": error}
|
||||
|
||||
try:
|
||||
if hasattr(client, 'images'):
|
||||
response = client.images.get(q=query)
|
||||
else:
|
||||
return {"error": "Image search not available in this SDK version"}
|
||||
|
||||
if hasattr(response, 'model_dump'):
|
||||
return response.model_dump()
|
||||
elif hasattr(response, 'images'):
|
||||
images = response.images
|
||||
return {
|
||||
'images': {
|
||||
'results': [
|
||||
{
|
||||
'title': getattr(r, 'title', ''),
|
||||
'page_url': getattr(r, 'page_url', ''),
|
||||
'image_url': getattr(r, 'image_url', ''),
|
||||
}
|
||||
for r in getattr(images, 'results', [])
|
||||
]
|
||||
},
|
||||
'metadata': {
|
||||
'query': getattr(getattr(response, 'metadata', None), 'query', query) if hasattr(response, 'metadata') else query,
|
||||
'search_uuid': getattr(getattr(response, 'metadata', None), 'search_uuid', None) if hasattr(response, 'metadata') else None,
|
||||
}
|
||||
}
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"You.com images error: {str(e)}"
|
||||
logging.error(error_msg)
|
||||
return {"error": error_msg}
|
||||
|
||||
def __enter__(self):
|
||||
"""Context manager entry."""
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
"""Context manager exit."""
|
||||
if self._client is not None:
|
||||
# Close client if it has a close method
|
||||
if hasattr(self._client, 'close'):
|
||||
self._client.close()
|
||||
elif hasattr(self._client, '__exit__'):
|
||||
self._client.__exit__(exc_type, exc_val, exc_tb)
|
||||
return False
|
||||
|
||||
|
||||
# Standalone functions for direct import
|
||||
def ydc_search(
|
||||
query: str,
|
||||
count: int = 10,
|
||||
freshness: Optional[str] = None,
|
||||
country: Optional[str] = None,
|
||||
language: Optional[str] = None,
|
||||
offset: int = 0,
|
||||
safesearch: str = "moderate",
|
||||
livecrawl: Optional[str] = None,
|
||||
livecrawl_formats: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Search the web using You.com's unified search API.
|
||||
|
||||
This is a convenience function that creates a YouTools instance.
|
||||
For repeated calls, consider using YouTools class directly.
|
||||
|
||||
Args:
|
||||
query: Search query (supports operators like site:, filetype:, +term, -term)
|
||||
count: Max results per section (default 10, max 100)
|
||||
freshness: Filter by recency - "day", "week", "month", "year"
|
||||
country: ISO country code (e.g., "US", "GB")
|
||||
language: BCP 47 language code (e.g., "EN", "JP")
|
||||
offset: Pagination offset (0-9)
|
||||
safesearch: Content filter - "off", "moderate", "strict"
|
||||
livecrawl: Fetch full content - "web", "news", or "all"
|
||||
livecrawl_formats: Content format - "html" or "markdown"
|
||||
|
||||
Returns:
|
||||
Dict containing search results with web and news sections
|
||||
"""
|
||||
tools = YouTools()
|
||||
return tools.search(
|
||||
query=query,
|
||||
count=count,
|
||||
freshness=freshness,
|
||||
country=country,
|
||||
language=language,
|
||||
offset=offset,
|
||||
safesearch=safesearch,
|
||||
livecrawl=livecrawl,
|
||||
livecrawl_formats=livecrawl_formats,
|
||||
)
|
||||
|
||||
|
||||
def ydc_contents(
|
||||
urls: Union[str, List[str]],
|
||||
format: str = "markdown",
|
||||
) -> Dict[str, Any]:
|
||||
"""Fetch content from URLs using You.com's content extraction API.
|
||||
|
||||
Args:
|
||||
urls: Single URL or list of URLs to fetch content from
|
||||
format: Content format - "html" or "markdown"
|
||||
|
||||
Returns:
|
||||
Dict containing extracted content for each URL
|
||||
"""
|
||||
tools = YouTools()
|
||||
return tools.get_contents(urls=urls, format=format)
|
||||
|
||||
|
||||
def ydc_news(
|
||||
query: str,
|
||||
count: Optional[int] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Search for live news using You.com's news API.
|
||||
|
||||
Args:
|
||||
query: News search query
|
||||
count: Maximum number of results
|
||||
|
||||
Returns:
|
||||
Dict containing news results
|
||||
"""
|
||||
tools = YouTools()
|
||||
return tools.live_news(query=query, count=count)
|
||||
|
||||
|
||||
def ydc_images(
|
||||
query: str,
|
||||
) -> Dict[str, Any]:
|
||||
"""Search for images using You.com's image search API.
|
||||
|
||||
Args:
|
||||
query: Image search query
|
||||
|
||||
Returns:
|
||||
Dict containing image results
|
||||
"""
|
||||
tools = YouTools()
|
||||
return tools.images(query=query)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Example usage
|
||||
print("\n" + "=" * 60)
|
||||
print("You.com Tools Demonstration")
|
||||
print("=" * 60 + "\n")
|
||||
|
||||
# Check if API key is available
|
||||
is_available, error = _check_youdotcom_available()
|
||||
if not is_available:
|
||||
print(f"Error: {error}")
|
||||
print("\nTo use You.com tools:")
|
||||
print("1. Install: pip install youdotcom")
|
||||
print("2. Set environment variable: export YDC_API_KEY=your_api_key")
|
||||
else:
|
||||
print("You.com SDK is available!")
|
||||
|
||||
# Example search
|
||||
print("\n1. Basic Search")
|
||||
print("-" * 30)
|
||||
results = ydc_search("Latest AI developments 2024", count=3)
|
||||
if "error" not in results:
|
||||
web_results = results.get('results', {}).get('web', [])
|
||||
print(f"Found {len(web_results)} web results")
|
||||
for r in web_results[:3]:
|
||||
print(f" - {r.get('title', 'No title')[:50]}...")
|
||||
print(f" URL: {r.get('url', 'No URL')}")
|
||||
else:
|
||||
print(f"Error: {results['error']}")
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("Demonstration Complete")
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
# Alias for simple usage: from praisonaiagents.tools import ydc
|
||||
ydc = ydc_search
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "praisonaiagents"
|
||||
version = "0.0.163"
|
||||
version = "0.0.185"
|
||||
description = "Praison AI agents for completing complex tasks with Self Reflection Agents"
|
||||
requires-python = ">=3.10"
|
||||
authors = [
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
"""
|
||||
Query Rewriter Agent Example
|
||||
|
||||
Demonstrates various query rewriting strategies for improving RAG retrieval.
|
||||
"""
|
||||
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
|
||||
from praisonaiagents import QueryRewriterAgent, RewriteStrategy
|
||||
|
||||
def main():
|
||||
# Initialize the agent
|
||||
agent = QueryRewriterAgent(
|
||||
model="gpt-4o-mini",
|
||||
verbose=True
|
||||
)
|
||||
|
||||
print("=" * 60)
|
||||
print("Query Rewriter Agent Examples")
|
||||
print("=" * 60)
|
||||
|
||||
# 1. Basic Rewriting - Short keyword queries
|
||||
print("\n1. BASIC REWRITING (Short Queries)")
|
||||
print("-" * 40)
|
||||
|
||||
short_queries = [
|
||||
"AI trends",
|
||||
"python performance",
|
||||
"RAG best practices"
|
||||
]
|
||||
|
||||
for query in short_queries:
|
||||
result = agent.rewrite(query, strategy=RewriteStrategy.BASIC)
|
||||
print(f"Original: {query}")
|
||||
print(f"Rewritten: {result.primary_query}")
|
||||
print()
|
||||
|
||||
# 2. HyDE - Hypothetical Document Embeddings
|
||||
print("\n2. HYDE (Hypothetical Document)")
|
||||
print("-" * 40)
|
||||
|
||||
query = "What is quantum computing?"
|
||||
result = agent.rewrite(query, strategy=RewriteStrategy.HYDE)
|
||||
print(f"Original: {query}")
|
||||
print("Hypothetical Document (truncated):")
|
||||
print(f"{result.hypothetical_document[:300]}...")
|
||||
print()
|
||||
|
||||
# 3. Step-Back - Higher-level concept questions
|
||||
print("\n3. STEP-BACK (Broader Context)")
|
||||
print("-" * 40)
|
||||
|
||||
query = "What is the difference between GPT-4 and Claude 3?"
|
||||
result = agent.rewrite(query, strategy=RewriteStrategy.STEP_BACK)
|
||||
print(f"Original: {query}")
|
||||
print(f"Rewritten: {result.primary_query}")
|
||||
print(f"Step-back Question: {result.step_back_question}")
|
||||
print()
|
||||
|
||||
# 4. Sub-Queries - Decompose complex questions
|
||||
print("\n4. SUB-QUERIES (Decomposition)")
|
||||
print("-" * 40)
|
||||
|
||||
query = "How do I set up a RAG pipeline with vector search and what are the best embedding models to use?"
|
||||
result = agent.rewrite(query, strategy=RewriteStrategy.SUB_QUERIES)
|
||||
print(f"Original: {query}")
|
||||
print("Sub-queries:")
|
||||
for i, sq in enumerate(result.sub_queries, 1):
|
||||
print(f" {i}. {sq}")
|
||||
print()
|
||||
|
||||
# 5. Multi-Query - Multiple paraphrased versions
|
||||
print("\n5. MULTI-QUERY (Ensemble Retrieval)")
|
||||
print("-" * 40)
|
||||
|
||||
query = "How to improve LLM response quality?"
|
||||
result = agent.rewrite(query, strategy=RewriteStrategy.MULTI_QUERY, num_queries=3)
|
||||
print(f"Original: {query}")
|
||||
print("Alternative queries:")
|
||||
for i, q in enumerate(result.rewritten_queries, 1):
|
||||
print(f" {i}. {q}")
|
||||
print()
|
||||
|
||||
# 6. Contextual - Using conversation history
|
||||
print("\n6. CONTEXTUAL (With Chat History)")
|
||||
print("-" * 40)
|
||||
|
||||
chat_history = [
|
||||
{"role": "user", "content": "Tell me about Python programming"},
|
||||
{"role": "assistant", "content": "Python is a high-level programming language known for its simplicity and readability..."},
|
||||
{"role": "user", "content": "What frameworks are popular?"},
|
||||
{"role": "assistant", "content": "Popular Python frameworks include Django for web development, FastAPI for APIs, and PyTorch for ML..."}
|
||||
]
|
||||
|
||||
query = "What about its performance compared to others?"
|
||||
result = agent.rewrite(query, strategy=RewriteStrategy.CONTEXTUAL, chat_history=chat_history)
|
||||
print(f"Original: {query}")
|
||||
print(f"Standalone: {result.primary_query}")
|
||||
print()
|
||||
|
||||
# 7. AUTO - Automatic strategy detection
|
||||
print("\n7. AUTO (Automatic Strategy Detection)")
|
||||
print("-" * 40)
|
||||
|
||||
test_queries = [
|
||||
("ML", None), # Short query -> BASIC
|
||||
("What about the cost?", chat_history), # Follow-up -> CONTEXTUAL
|
||||
("Compare transformers vs RNNs and explain their use cases", None), # Complex -> SUB_QUERIES
|
||||
]
|
||||
|
||||
for query, history in test_queries:
|
||||
result = agent.rewrite(query, strategy=RewriteStrategy.AUTO, chat_history=history)
|
||||
print(f"Query: {query}")
|
||||
print(f"Detected Strategy: {result.strategy_used.value}")
|
||||
print(f"Result: {result.primary_query}")
|
||||
print()
|
||||
|
||||
# 8. Custom Abbreviations
|
||||
print("\n8. CUSTOM ABBREVIATIONS")
|
||||
print("-" * 40)
|
||||
|
||||
agent.add_abbreviations({
|
||||
"K8s": "Kubernetes",
|
||||
"TF": "TensorFlow",
|
||||
"PT": "PyTorch"
|
||||
})
|
||||
|
||||
query = "K8s deployment for TF models"
|
||||
result = agent.rewrite(query, strategy=RewriteStrategy.BASIC)
|
||||
print(f"Original: {query}")
|
||||
print(f"Rewritten: {result.primary_query}")
|
||||
print()
|
||||
|
||||
print("=" * 60)
|
||||
print("Examples Complete!")
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,16 @@
|
||||
from praisonaiagents import Agent
|
||||
|
||||
def search_web(query: str) -> str:
|
||||
return "AI trends: LLMs, multimodal AI, autonomous agents"
|
||||
|
||||
agent = Agent(
|
||||
name="AI Assistant",
|
||||
role="Research and Writing Specialist",
|
||||
llm="gpt-4o-mini",
|
||||
planning=True,
|
||||
planning_tools=[search_web],
|
||||
planning_reasoning=True
|
||||
)
|
||||
|
||||
result = agent.start("Research AI trends in 2025 and write a 2 sentence summary")
|
||||
print(result)
|
||||
@@ -0,0 +1,18 @@
|
||||
from praisonaiagents import Agent
|
||||
|
||||
def search_web(query: str) -> str:
|
||||
return "AI trends: LLMs, multimodal AI, autonomous agents"
|
||||
|
||||
agent = Agent(
|
||||
name="AI Assistant",
|
||||
role="Research and Writing Specialist",
|
||||
llm="gpt-4o-mini",
|
||||
planning=True,
|
||||
planning_tools=[search_web],
|
||||
planning_reasoning=True,
|
||||
verbose=False # Quiet mode
|
||||
)
|
||||
|
||||
result = agent.start("Research AI trends in 2025 and write a 2 sentence summary")
|
||||
print("\n=== FINAL RESULT ===")
|
||||
print(result)
|
||||
@@ -0,0 +1,62 @@
|
||||
from praisonaiagents import Agent
|
||||
|
||||
def search_web(query: str) -> str:
|
||||
return "AI trends: LLMs, multimodal AI, autonomous agents, edge AI"
|
||||
|
||||
print("=" * 60)
|
||||
print("TEST 1: Planning ONLY (no tools, no reasoning)")
|
||||
print("=" * 60)
|
||||
|
||||
agent1 = Agent(
|
||||
name="Assistant",
|
||||
llm="gpt-4o-mini",
|
||||
planning=True,
|
||||
verbose=False
|
||||
)
|
||||
result1 = agent1.start("Write 2 sentences about AI in healthcare")
|
||||
print(f"\nResult: {result1[:200]}...")
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("TEST 2: Planning + Tools (no reasoning)")
|
||||
print("=" * 60)
|
||||
|
||||
agent2 = Agent(
|
||||
name="Assistant",
|
||||
llm="gpt-4o-mini",
|
||||
planning=True,
|
||||
planning_tools=[search_web],
|
||||
verbose=False
|
||||
)
|
||||
result2 = agent2.start("Write 2 sentences about AI trends")
|
||||
print(f"\nResult: {result2[:200]}...")
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("TEST 3: Planning + Tools + Reasoning")
|
||||
print("=" * 60)
|
||||
|
||||
agent3 = Agent(
|
||||
name="Assistant",
|
||||
llm="gpt-4o-mini",
|
||||
planning=True,
|
||||
planning_tools=[search_web],
|
||||
planning_reasoning=True,
|
||||
verbose=False
|
||||
)
|
||||
result3 = agent3.start("Write 2 sentences about AI trends")
|
||||
print(f"\nResult: {result3[:200]}...")
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("TEST 4: No Planning (direct execution)")
|
||||
print("=" * 60)
|
||||
|
||||
agent4 = Agent(
|
||||
name="Assistant",
|
||||
llm="gpt-4o-mini",
|
||||
verbose=False
|
||||
)
|
||||
result4 = agent4.start("Write 2 sentences about AI trends")
|
||||
print(f"\nResult: {result4[:200]}...")
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("COMPARISON COMPLETE")
|
||||
print("=" * 60)
|
||||
@@ -0,0 +1,183 @@
|
||||
"""
|
||||
Complete Planning Mode Demo: Plan First, Then Execute Todo Items One by One.
|
||||
|
||||
This demonstrates the full workflow:
|
||||
1. Create a plan with the PlanningAgent
|
||||
2. Generate todo list from the plan
|
||||
3. Execute each todo item sequentially with the appropriate agent
|
||||
4. Update progress as items are completed
|
||||
|
||||
Run with: python test_planning_execution.py
|
||||
"""
|
||||
|
||||
from praisonaiagents import Agent, Task
|
||||
from praisonaiagents.planning import PlanningAgent, TodoList
|
||||
|
||||
|
||||
def print_separator(title: str):
|
||||
print("\n" + "=" * 70)
|
||||
print(f" {title}")
|
||||
print("=" * 70 + "\n")
|
||||
|
||||
|
||||
def print_progress(todo_list: TodoList):
|
||||
"""Print current progress."""
|
||||
completed = len(todo_list.completed)
|
||||
total = len(todo_list.items)
|
||||
progress = todo_list.progress * 100
|
||||
bar_length = 30
|
||||
filled = int(bar_length * todo_list.progress)
|
||||
bar = "█" * filled + "░" * (bar_length - filled)
|
||||
print(f"\n📊 Progress: [{bar}] {progress:.0f}% ({completed}/{total} completed)\n")
|
||||
|
||||
|
||||
def main():
|
||||
print_separator("🎯 PLANNING MODE: PLAN FIRST, EXECUTE TODO ITEMS")
|
||||
|
||||
# ==========================================================================
|
||||
# STEP 1: Create Agents
|
||||
# ==========================================================================
|
||||
print("📦 Creating agents...")
|
||||
|
||||
researcher = Agent(
|
||||
name="Researcher",
|
||||
role="Research Analyst",
|
||||
goal="Research topics thoroughly and provide accurate information",
|
||||
backstory="Expert researcher with access to vast knowledge bases",
|
||||
verbose=True
|
||||
)
|
||||
|
||||
writer = Agent(
|
||||
name="Writer",
|
||||
role="Content Writer",
|
||||
goal="Write clear, engaging, and well-structured content",
|
||||
backstory="Professional writer skilled in creating compelling narratives",
|
||||
verbose=True
|
||||
)
|
||||
|
||||
print("✅ Agents created: Researcher, Writer\n")
|
||||
|
||||
# ==========================================================================
|
||||
# STEP 2: Create Plan using PlanningAgent
|
||||
# ==========================================================================
|
||||
print_separator("📋 PHASE 1: CREATING PLAN")
|
||||
|
||||
planner = PlanningAgent(
|
||||
llm="gpt-4o-mini",
|
||||
read_only=True,
|
||||
verbose=1
|
||||
)
|
||||
|
||||
request = "Write a short article about the top 3 benefits of meditation for mental health"
|
||||
|
||||
print(f"📝 Request: {request}\n")
|
||||
print("🤔 Planning agent is analyzing and creating a plan...\n")
|
||||
|
||||
plan = planner.create_plan_sync(
|
||||
request=request,
|
||||
agents=[researcher, writer],
|
||||
context="Keep it concise - each section should be 2-3 sentences max"
|
||||
)
|
||||
|
||||
print("\n✅ Plan created successfully!")
|
||||
print(f" Plan Name: {plan.name}")
|
||||
print(f" Total Steps: {len(plan.steps)}")
|
||||
|
||||
# ==========================================================================
|
||||
# STEP 3: Display the Plan
|
||||
# ==========================================================================
|
||||
print_separator("📄 GENERATED PLAN")
|
||||
print(plan.to_markdown())
|
||||
|
||||
# ==========================================================================
|
||||
# STEP 4: Create Todo List from Plan
|
||||
# ==========================================================================
|
||||
print_separator("📝 PHASE 2: TODO LIST CREATED")
|
||||
|
||||
todo_list = TodoList.from_plan(plan)
|
||||
print(todo_list.to_markdown())
|
||||
print_progress(todo_list)
|
||||
|
||||
# ==========================================================================
|
||||
# STEP 5: Execute Each Todo Item One by One
|
||||
# ==========================================================================
|
||||
print_separator("🚀 PHASE 3: EXECUTING TODO ITEMS")
|
||||
|
||||
# Map agent names to agent instances
|
||||
agent_map = {
|
||||
"Researcher": researcher,
|
||||
"Writer": writer
|
||||
}
|
||||
|
||||
results = {}
|
||||
|
||||
for i, item in enumerate(todo_list.items):
|
||||
print(f"\n{'─' * 70}")
|
||||
print(f"📌 TODO ITEM {i + 1}/{len(todo_list.items)}")
|
||||
print(f"{'─' * 70}")
|
||||
print(f" Description: {item.description}")
|
||||
print(f" Agent: {item.agent or 'Auto-assign'}")
|
||||
print(f" Status: {item.status}")
|
||||
|
||||
if item.dependencies:
|
||||
print(f" Dependencies: {', '.join(item.dependencies)}")
|
||||
|
||||
# Mark as in progress
|
||||
todo_list.start(item.id)
|
||||
print(f"\n ⏳ Starting execution...")
|
||||
|
||||
# Get the appropriate agent
|
||||
agent_name = item.agent or "Researcher"
|
||||
agent = agent_map.get(agent_name, researcher)
|
||||
|
||||
# Build context from previous results
|
||||
context = ""
|
||||
for dep_id in item.dependencies:
|
||||
if dep_id in results:
|
||||
context += f"\nPrevious result ({dep_id}):\n{results[dep_id]}\n"
|
||||
|
||||
# Create task description
|
||||
task_description = item.description
|
||||
if context:
|
||||
task_description += f"\n\nContext from previous steps:{context}"
|
||||
|
||||
# Execute with the agent
|
||||
try:
|
||||
result = agent.chat(task_description)
|
||||
results[item.id] = result
|
||||
|
||||
# Mark as completed
|
||||
todo_list.complete(item.id)
|
||||
|
||||
print(f"\n ✅ Completed!")
|
||||
print(f"\n 📄 Result Preview:")
|
||||
print(" " + "-" * 50)
|
||||
preview = str(result)[:500] + "..." if len(str(result)) > 500 else str(result)
|
||||
for line in preview.split('\n'):
|
||||
print(f" {line}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n ❌ Error: {e}")
|
||||
results[item.id] = f"Error: {e}"
|
||||
|
||||
print_progress(todo_list)
|
||||
|
||||
# ==========================================================================
|
||||
# STEP 6: Final Summary
|
||||
# ==========================================================================
|
||||
print_separator("🎉 EXECUTION COMPLETE")
|
||||
|
||||
print("📋 Final Todo List Status:")
|
||||
print(todo_list.to_markdown())
|
||||
|
||||
print("\n📊 Final Results Summary:")
|
||||
print("-" * 50)
|
||||
for item in todo_list.items:
|
||||
status_icon = "✅" if item.status == "completed" else "❌"
|
||||
print(f"{status_icon} {item.description[:60]}...")
|
||||
|
||||
print(f"\n🏆 All {len(todo_list.completed)} tasks completed successfully!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,22 @@
|
||||
from praisonaiagents import Agent, Task, PraisonAIAgents
|
||||
|
||||
def search_web(query: str) -> str:
|
||||
return f"Results: AI trends include LLMs, multimodal AI, autonomous agents"
|
||||
|
||||
researcher = Agent(name="Researcher", role="Research Analyst", llm="gpt-4o-mini")
|
||||
writer = Agent(name="Writer", role="Content Writer", llm="gpt-4o-mini")
|
||||
|
||||
task1 = Task(description="Research AI trends in 2025", agent=researcher)
|
||||
task2 = Task(description="Write a 2 sentence summary", agent=writer)
|
||||
|
||||
agents = PraisonAIAgents(
|
||||
agents=[researcher, writer],
|
||||
tasks=[task1, task2],
|
||||
planning=True,
|
||||
planning_tools=[search_web],
|
||||
planning_reasoning=True,
|
||||
auto_approve_plan=True
|
||||
)
|
||||
|
||||
result = agents.start()
|
||||
print(result)
|
||||
@@ -0,0 +1,28 @@
|
||||
from praisonaiagents import Agent, Task, PraisonAIAgents
|
||||
|
||||
def search_web(query: str) -> str:
|
||||
return "AI trends: LLMs, multimodal AI, autonomous agents"
|
||||
|
||||
agent = Agent(
|
||||
name="AI Assistant",
|
||||
role="Research and Writing Specialist",
|
||||
llm="gpt-4o-mini",
|
||||
instructions="Research AI trends in 2025 and write a 2 sentence summary"
|
||||
)
|
||||
|
||||
task = Task(
|
||||
description="Research AI trends in 2025 and write a 2 sentence summary",
|
||||
agent=agent
|
||||
)
|
||||
|
||||
agents = PraisonAIAgents(
|
||||
agents=[agent],
|
||||
tasks=[task],
|
||||
planning=True,
|
||||
planning_tools=[search_web],
|
||||
planning_reasoning=True,
|
||||
auto_approve_plan=True
|
||||
)
|
||||
|
||||
result = agents.start()
|
||||
print(result)
|
||||
@@ -0,0 +1,34 @@
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.planning import PlanningAgent
|
||||
|
||||
def search_web(query: str) -> str:
|
||||
return f"Results for '{query}': LLMs, multimodal AI, autonomous agents"
|
||||
|
||||
researcher = Agent(name="Researcher", role="Research Analyst", llm="gpt-4o-mini")
|
||||
writer = Agent(name="Writer", role="Content Writer", llm="gpt-4o-mini")
|
||||
|
||||
print("\n=== TEST 1: Basic Planning (NO tools) ===")
|
||||
planner1 = PlanningAgent(llm="gpt-4o-mini")
|
||||
plan1 = planner1.create_plan_sync(
|
||||
request="Write about AI trends 2025",
|
||||
agents=[researcher, writer]
|
||||
)
|
||||
print(f"Plan: {plan1.name}, Steps: {len(plan1.steps)}")
|
||||
|
||||
print("\n=== TEST 2: Planning WITH tools ===")
|
||||
planner2 = PlanningAgent(llm="gpt-4o-mini", tools=[search_web])
|
||||
plan2 = planner2.create_plan_sync(
|
||||
request="Write about AI trends 2025",
|
||||
agents=[researcher, writer]
|
||||
)
|
||||
print(f"Plan: {plan2.name}, Steps: {len(plan2.steps)}")
|
||||
|
||||
print("\n=== TEST 3: Planning WITH tools + reasoning ===")
|
||||
planner3 = PlanningAgent(llm="gpt-4o-mini", tools=[search_web], reasoning=True)
|
||||
plan3 = planner3.create_plan_sync(
|
||||
request="Write about AI trends 2025",
|
||||
agents=[researcher, writer]
|
||||
)
|
||||
print(f"Plan: {plan3.name}, Steps: {len(plan3.steps)}")
|
||||
|
||||
print("\n✅ Done!")
|
||||
@@ -0,0 +1,403 @@
|
||||
"""
|
||||
Tests for QueryRewriterAgent
|
||||
|
||||
Run with: python -m pytest tests/query_rewriter_test.py -v
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
class TestQueryRewriterImports:
|
||||
"""Test that all imports work correctly."""
|
||||
|
||||
def test_import_from_package(self):
|
||||
"""Test importing from main package."""
|
||||
from praisonaiagents import QueryRewriterAgent, RewriteStrategy, RewriteResult
|
||||
assert QueryRewriterAgent is not None
|
||||
assert RewriteStrategy is not None
|
||||
assert RewriteResult is not None
|
||||
|
||||
def test_import_from_agent_module(self):
|
||||
"""Test importing from agent module."""
|
||||
from praisonaiagents.agent import QueryRewriterAgent, RewriteStrategy, RewriteResult
|
||||
assert QueryRewriterAgent is not None
|
||||
assert RewriteStrategy is not None
|
||||
assert RewriteResult is not None
|
||||
|
||||
|
||||
class TestRewriteStrategy:
|
||||
"""Test RewriteStrategy enum."""
|
||||
|
||||
def test_strategy_values(self):
|
||||
"""Test all strategy values exist."""
|
||||
from praisonaiagents import RewriteStrategy
|
||||
|
||||
assert RewriteStrategy.BASIC.value == "basic"
|
||||
assert RewriteStrategy.HYDE.value == "hyde"
|
||||
assert RewriteStrategy.STEP_BACK.value == "step_back"
|
||||
assert RewriteStrategy.SUB_QUERIES.value == "sub_queries"
|
||||
assert RewriteStrategy.MULTI_QUERY.value == "multi_query"
|
||||
assert RewriteStrategy.CONTEXTUAL.value == "contextual"
|
||||
assert RewriteStrategy.AUTO.value == "auto"
|
||||
|
||||
|
||||
class TestRewriteResult:
|
||||
"""Test RewriteResult dataclass."""
|
||||
|
||||
def test_result_creation(self):
|
||||
"""Test creating a RewriteResult."""
|
||||
from praisonaiagents import RewriteResult, RewriteStrategy
|
||||
|
||||
result = RewriteResult(
|
||||
original_query="test query",
|
||||
rewritten_queries=["rewritten test query"],
|
||||
strategy_used=RewriteStrategy.BASIC
|
||||
)
|
||||
|
||||
assert result.original_query == "test query"
|
||||
assert result.rewritten_queries == ["rewritten test query"]
|
||||
assert result.strategy_used == RewriteStrategy.BASIC
|
||||
|
||||
def test_primary_query_property(self):
|
||||
"""Test primary_query property."""
|
||||
from praisonaiagents import RewriteResult, RewriteStrategy
|
||||
|
||||
result = RewriteResult(
|
||||
original_query="test",
|
||||
rewritten_queries=["first", "second"],
|
||||
strategy_used=RewriteStrategy.MULTI_QUERY
|
||||
)
|
||||
|
||||
assert result.primary_query == "first"
|
||||
|
||||
def test_primary_query_fallback(self):
|
||||
"""Test primary_query falls back to original if empty."""
|
||||
from praisonaiagents import RewriteResult, RewriteStrategy
|
||||
|
||||
result = RewriteResult(
|
||||
original_query="test",
|
||||
rewritten_queries=[],
|
||||
strategy_used=RewriteStrategy.BASIC
|
||||
)
|
||||
|
||||
assert result.primary_query == "test"
|
||||
|
||||
def test_all_queries_property(self):
|
||||
"""Test all_queries property combines all queries."""
|
||||
from praisonaiagents import RewriteResult, RewriteStrategy
|
||||
|
||||
result = RewriteResult(
|
||||
original_query="original",
|
||||
rewritten_queries=["rewritten"],
|
||||
strategy_used=RewriteStrategy.STEP_BACK,
|
||||
step_back_question="step back",
|
||||
sub_queries=["sub1", "sub2"]
|
||||
)
|
||||
|
||||
all_q = result.all_queries
|
||||
assert "original" in all_q
|
||||
assert "rewritten" in all_q
|
||||
assert "step back" in all_q
|
||||
assert "sub1" in all_q
|
||||
assert "sub2" in all_q
|
||||
|
||||
|
||||
class TestQueryRewriterAgentInit:
|
||||
"""Test QueryRewriterAgent initialization."""
|
||||
|
||||
def test_default_initialization(self):
|
||||
"""Test agent initializes with defaults."""
|
||||
from praisonaiagents import QueryRewriterAgent
|
||||
|
||||
agent = QueryRewriterAgent()
|
||||
|
||||
assert agent.name == "QueryRewriterAgent"
|
||||
assert agent.model == "gpt-4o-mini"
|
||||
assert not agent.verbose
|
||||
assert agent.max_queries == 5
|
||||
assert agent.temperature == 0.3
|
||||
assert agent.max_tokens == 500
|
||||
|
||||
def test_custom_initialization(self):
|
||||
"""Test agent initializes with custom values."""
|
||||
from praisonaiagents import QueryRewriterAgent
|
||||
|
||||
agent = QueryRewriterAgent(
|
||||
name="CustomRewriter",
|
||||
model="gpt-4o",
|
||||
verbose=True,
|
||||
max_queries=10,
|
||||
temperature=0.5,
|
||||
max_tokens=1000
|
||||
)
|
||||
|
||||
assert agent.name == "CustomRewriter"
|
||||
assert agent.model == "gpt-4o"
|
||||
assert agent.verbose
|
||||
assert agent.max_queries == 10
|
||||
assert agent.temperature == 0.5
|
||||
assert agent.max_tokens == 1000
|
||||
|
||||
def test_default_abbreviations(self):
|
||||
"""Test default abbreviations are set."""
|
||||
from praisonaiagents import QueryRewriterAgent
|
||||
|
||||
agent = QueryRewriterAgent()
|
||||
|
||||
assert "AI" in agent.abbreviations
|
||||
assert "ML" in agent.abbreviations
|
||||
assert "RAG" in agent.abbreviations
|
||||
assert agent.abbreviations["AI"] == "Artificial Intelligence"
|
||||
|
||||
|
||||
class TestAbbreviationExpansion:
|
||||
"""Test abbreviation expansion functionality."""
|
||||
|
||||
def test_expand_abbreviations(self):
|
||||
"""Test _expand_abbreviations method."""
|
||||
from praisonaiagents import QueryRewriterAgent
|
||||
|
||||
agent = QueryRewriterAgent()
|
||||
|
||||
result = agent._expand_abbreviations("AI trends")
|
||||
assert "Artificial Intelligence" in result
|
||||
|
||||
def test_add_abbreviation(self):
|
||||
"""Test adding single abbreviation."""
|
||||
from praisonaiagents import QueryRewriterAgent
|
||||
|
||||
agent = QueryRewriterAgent()
|
||||
agent.add_abbreviation("K8s", "Kubernetes")
|
||||
|
||||
assert "K8S" in agent.abbreviations
|
||||
assert agent.abbreviations["K8S"] == "Kubernetes"
|
||||
|
||||
def test_add_abbreviations(self):
|
||||
"""Test adding multiple abbreviations."""
|
||||
from praisonaiagents import QueryRewriterAgent
|
||||
|
||||
agent = QueryRewriterAgent()
|
||||
agent.add_abbreviations({
|
||||
"TF": "TensorFlow",
|
||||
"PT": "PyTorch"
|
||||
})
|
||||
|
||||
assert "TF" in agent.abbreviations
|
||||
assert "PT" in agent.abbreviations
|
||||
|
||||
|
||||
class TestQueryDetection:
|
||||
"""Test query analysis methods."""
|
||||
|
||||
def test_is_short_query(self):
|
||||
"""Test short query detection."""
|
||||
from praisonaiagents import QueryRewriterAgent
|
||||
|
||||
agent = QueryRewriterAgent()
|
||||
|
||||
assert agent._is_short_query("AI")
|
||||
assert agent._is_short_query("AI trends")
|
||||
assert not agent._is_short_query("What are the latest AI trends in 2025?")
|
||||
|
||||
def test_is_follow_up(self):
|
||||
"""Test follow-up query detection."""
|
||||
from praisonaiagents import QueryRewriterAgent
|
||||
|
||||
agent = QueryRewriterAgent()
|
||||
|
||||
assert agent._is_follow_up("What about it?")
|
||||
assert agent._is_follow_up("And this one?")
|
||||
assert not agent._is_follow_up("What is AI?")
|
||||
|
||||
def test_is_complex_query(self):
|
||||
"""Test complex query detection."""
|
||||
from praisonaiagents import QueryRewriterAgent
|
||||
|
||||
agent = QueryRewriterAgent()
|
||||
|
||||
assert agent._is_complex_query("What is A and what is B?")
|
||||
assert agent._is_complex_query("Question one? Question two?")
|
||||
assert not agent._is_complex_query("What is AI?")
|
||||
|
||||
|
||||
class TestStrategyDetection:
|
||||
"""Test automatic strategy detection."""
|
||||
|
||||
def test_detect_basic_for_short(self):
|
||||
"""Test BASIC detected for short queries."""
|
||||
from praisonaiagents import QueryRewriterAgent, RewriteStrategy
|
||||
|
||||
agent = QueryRewriterAgent()
|
||||
|
||||
strategy = agent._detect_strategy("AI")
|
||||
assert strategy == RewriteStrategy.BASIC
|
||||
|
||||
def test_detect_contextual_for_followup(self):
|
||||
"""Test CONTEXTUAL detected for follow-up with history."""
|
||||
from praisonaiagents import QueryRewriterAgent, RewriteStrategy
|
||||
|
||||
agent = QueryRewriterAgent()
|
||||
chat_history = [{"role": "user", "content": "Tell me about Python"}]
|
||||
|
||||
strategy = agent._detect_strategy("What about it?", chat_history)
|
||||
assert strategy == RewriteStrategy.CONTEXTUAL
|
||||
|
||||
def test_detect_sub_queries_for_complex(self):
|
||||
"""Test SUB_QUERIES detected for complex queries."""
|
||||
from praisonaiagents import QueryRewriterAgent, RewriteStrategy
|
||||
|
||||
agent = QueryRewriterAgent()
|
||||
|
||||
strategy = agent._detect_strategy("What is A and what is B?")
|
||||
assert strategy == RewriteStrategy.SUB_QUERIES
|
||||
|
||||
|
||||
class TestJSONParsing:
|
||||
"""Test JSON array parsing from LLM responses."""
|
||||
|
||||
def test_parse_valid_json(self):
|
||||
"""Test parsing valid JSON array."""
|
||||
from praisonaiagents import QueryRewriterAgent
|
||||
|
||||
agent = QueryRewriterAgent()
|
||||
|
||||
result = agent._parse_json_array('["query1", "query2"]')
|
||||
assert result == ["query1", "query2"]
|
||||
|
||||
def test_parse_json_in_text(self):
|
||||
"""Test extracting JSON from surrounding text."""
|
||||
from praisonaiagents import QueryRewriterAgent
|
||||
|
||||
agent = QueryRewriterAgent()
|
||||
|
||||
result = agent._parse_json_array('Here are the queries: ["q1", "q2"]')
|
||||
assert result == ["q1", "q2"]
|
||||
|
||||
def test_parse_fallback_newlines(self):
|
||||
"""Test fallback to newline splitting."""
|
||||
from praisonaiagents import QueryRewriterAgent
|
||||
|
||||
agent = QueryRewriterAgent()
|
||||
|
||||
result = agent._parse_json_array("1. Query one\n2. Query two\n3. Query three")
|
||||
assert len(result) >= 2
|
||||
|
||||
|
||||
class TestRewriteWithMockedLLM:
|
||||
"""Test rewrite methods with mocked LLM."""
|
||||
|
||||
@patch.object(__import__('praisonaiagents.agent.query_rewriter_agent', fromlist=['QueryRewriterAgent']).QueryRewriterAgent, '_call_agent')
|
||||
def test_rewrite_basic(self, mock_llm):
|
||||
"""Test basic rewriting with mocked LLM."""
|
||||
from praisonaiagents import QueryRewriterAgent, RewriteStrategy
|
||||
|
||||
mock_llm.return_value = "What are the current trends in AI?"
|
||||
|
||||
agent = QueryRewriterAgent()
|
||||
result = agent.rewrite("AI trends", strategy=RewriteStrategy.BASIC)
|
||||
|
||||
assert result.strategy_used == RewriteStrategy.BASIC
|
||||
assert len(result.rewritten_queries) > 0
|
||||
mock_llm.assert_called_once()
|
||||
|
||||
@patch.object(__import__('praisonaiagents.agent.query_rewriter_agent', fromlist=['QueryRewriterAgent']).QueryRewriterAgent, '_call_agent')
|
||||
def test_rewrite_hyde(self, mock_llm):
|
||||
"""Test HyDE rewriting with mocked LLM."""
|
||||
from praisonaiagents import QueryRewriterAgent, RewriteStrategy
|
||||
|
||||
mock_llm.return_value = "Quantum computing is a type of computation..."
|
||||
|
||||
agent = QueryRewriterAgent()
|
||||
result = agent.rewrite("What is quantum computing?", strategy=RewriteStrategy.HYDE)
|
||||
|
||||
assert result.strategy_used == RewriteStrategy.HYDE
|
||||
assert result.hypothetical_document is not None
|
||||
mock_llm.assert_called_once()
|
||||
|
||||
@patch.object(__import__('praisonaiagents.agent.query_rewriter_agent', fromlist=['QueryRewriterAgent']).QueryRewriterAgent, '_call_agent')
|
||||
def test_rewrite_step_back(self, mock_llm):
|
||||
"""Test step-back rewriting with mocked LLM."""
|
||||
from praisonaiagents import QueryRewriterAgent, RewriteStrategy
|
||||
|
||||
# First call for step-back, second for basic
|
||||
mock_llm.side_effect = [
|
||||
"What are the fundamentals of language models?",
|
||||
"What are the differences between GPT-4 and Claude 3?"
|
||||
]
|
||||
|
||||
agent = QueryRewriterAgent()
|
||||
result = agent.rewrite("GPT-4 vs Claude 3?", strategy=RewriteStrategy.STEP_BACK)
|
||||
|
||||
assert result.strategy_used == RewriteStrategy.STEP_BACK
|
||||
assert result.step_back_question is not None
|
||||
|
||||
@patch.object(__import__('praisonaiagents.agent.query_rewriter_agent', fromlist=['QueryRewriterAgent']).QueryRewriterAgent, '_call_agent')
|
||||
def test_rewrite_sub_queries(self, mock_llm):
|
||||
"""Test sub-query decomposition with mocked LLM."""
|
||||
from praisonaiagents import QueryRewriterAgent, RewriteStrategy
|
||||
|
||||
mock_llm.return_value = '["What is RAG?", "What are the best embedding models?"]'
|
||||
|
||||
agent = QueryRewriterAgent()
|
||||
result = agent.rewrite("RAG setup and embedding models?", strategy=RewriteStrategy.SUB_QUERIES)
|
||||
|
||||
assert result.strategy_used == RewriteStrategy.SUB_QUERIES
|
||||
assert result.sub_queries is not None
|
||||
assert len(result.sub_queries) >= 1
|
||||
|
||||
@patch.object(__import__('praisonaiagents.agent.query_rewriter_agent', fromlist=['QueryRewriterAgent']).QueryRewriterAgent, '_call_agent')
|
||||
def test_rewrite_multi_query(self, mock_llm):
|
||||
"""Test multi-query generation with mocked LLM."""
|
||||
from praisonaiagents import QueryRewriterAgent, RewriteStrategy
|
||||
|
||||
mock_llm.return_value = '["Query 1", "Query 2", "Query 3"]'
|
||||
|
||||
agent = QueryRewriterAgent()
|
||||
result = agent.rewrite("LLM quality?", strategy=RewriteStrategy.MULTI_QUERY, num_queries=3)
|
||||
|
||||
assert result.strategy_used == RewriteStrategy.MULTI_QUERY
|
||||
assert len(result.rewritten_queries) >= 1
|
||||
|
||||
@patch.object(__import__('praisonaiagents.agent.query_rewriter_agent', fromlist=['QueryRewriterAgent']).QueryRewriterAgent, '_call_agent')
|
||||
def test_rewrite_contextual(self, mock_llm):
|
||||
"""Test contextual rewriting with mocked LLM."""
|
||||
from praisonaiagents import QueryRewriterAgent, RewriteStrategy
|
||||
|
||||
mock_llm.return_value = "How does Python's performance compare to other languages?"
|
||||
|
||||
agent = QueryRewriterAgent()
|
||||
chat_history = [
|
||||
{"role": "user", "content": "Tell me about Python"},
|
||||
{"role": "assistant", "content": "Python is a programming language..."}
|
||||
]
|
||||
|
||||
result = agent.rewrite(
|
||||
"What about its performance?",
|
||||
strategy=RewriteStrategy.CONTEXTUAL,
|
||||
chat_history=chat_history
|
||||
)
|
||||
|
||||
assert result.strategy_used == RewriteStrategy.CONTEXTUAL
|
||||
assert "Python" in result.primary_query or len(result.rewritten_queries) > 0
|
||||
|
||||
|
||||
class TestConvenienceMethods:
|
||||
"""Test convenience methods."""
|
||||
|
||||
def test_convenience_methods_exist(self):
|
||||
"""Test all convenience methods exist."""
|
||||
from praisonaiagents import QueryRewriterAgent
|
||||
|
||||
agent = QueryRewriterAgent()
|
||||
|
||||
assert hasattr(agent, 'rewrite_basic')
|
||||
assert hasattr(agent, 'rewrite_hyde')
|
||||
assert hasattr(agent, 'rewrite_step_back')
|
||||
assert hasattr(agent, 'rewrite_sub_queries')
|
||||
assert hasattr(agent, 'rewrite_multi_query')
|
||||
assert hasattr(agent, 'rewrite_contextual')
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -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
|
||||
@@ -11,6 +11,7 @@ This test script validates the ContextAgent implementation for:
|
||||
"""
|
||||
|
||||
import sys
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
|
||||
# Add the src directory to Python path
|
||||
@@ -34,11 +35,11 @@ def test_imports():
|
||||
from praisonaiagents.agent.context_agent import ContextAgent as DirectContextAgent
|
||||
print("✅ Successfully imported ContextAgent from direct module")
|
||||
|
||||
return True
|
||||
assert True
|
||||
|
||||
except ImportError as e:
|
||||
print(f"❌ Import failed: {e}")
|
||||
return False
|
||||
assert False, str(e)
|
||||
|
||||
def test_basic_instantiation():
|
||||
"""Test basic ContextAgent instantiation."""
|
||||
@@ -64,11 +65,11 @@ def test_basic_instantiation():
|
||||
factory_agent = create_context_agent(llm="gpt-5-nano")
|
||||
print("✅ Successfully created ContextAgent using factory function")
|
||||
|
||||
return True, [context_agent, custom_agent, factory_agent]
|
||||
assert True
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Instantiation failed: {e}")
|
||||
return False, []
|
||||
assert False, str(e)
|
||||
|
||||
def test_agent_inheritance():
|
||||
"""Test that ContextAgent properly inherits from Agent."""
|
||||
@@ -89,12 +90,13 @@ def test_agent_inheritance():
|
||||
assert hasattr(context_agent, 'goal'), "Should have goal attribute"
|
||||
print("✅ ContextAgent has all required Agent attributes")
|
||||
|
||||
return True
|
||||
assert True
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Inheritance test failed: {e}")
|
||||
return False
|
||||
assert False, str(e)
|
||||
|
||||
@pytest.mark.skip(reason="ContextAgent methods not yet implemented")
|
||||
def test_context_engineering_methods():
|
||||
"""Test Context Engineering specific methods."""
|
||||
print("\\n🧪 Testing Context Engineering Methods...")
|
||||
@@ -137,12 +139,13 @@ def test_context_engineering_methods():
|
||||
|
||||
print(f"✅ All {len(async_methods)} async methods are available")
|
||||
|
||||
return True
|
||||
assert True
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Method availability test failed: {e}")
|
||||
return False
|
||||
assert False, str(e)
|
||||
|
||||
@pytest.mark.skip(reason="ContextAgent methods not yet implemented")
|
||||
def test_basic_functionality():
|
||||
"""Test basic functionality of ContextAgent methods."""
|
||||
print("\\n🧪 Testing Basic Functionality...")
|
||||
@@ -194,11 +197,11 @@ def test_basic_functionality():
|
||||
assert "PRP" in prp, "Should contain PRP reference"
|
||||
print("✅ generate_prp works correctly")
|
||||
|
||||
return True
|
||||
assert True
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Functionality test failed: {e}")
|
||||
return False
|
||||
assert False, str(e)
|
||||
|
||||
def test_backward_compatibility():
|
||||
"""Test that existing PraisonAI functionality still works."""
|
||||
@@ -232,11 +235,11 @@ def test_backward_compatibility():
|
||||
|
||||
print("✅ All expected exports are available")
|
||||
|
||||
return True
|
||||
assert True
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Backward compatibility test failed: {e}")
|
||||
return False
|
||||
assert False, str(e)
|
||||
|
||||
def test_syntax_validation():
|
||||
"""Test that all Python files have valid syntax."""
|
||||
@@ -268,14 +271,14 @@ def test_syntax_validation():
|
||||
ast.parse(content)
|
||||
print(f"✅ {example_file.name} has valid Python syntax")
|
||||
|
||||
return True
|
||||
assert True
|
||||
|
||||
except SyntaxError as e:
|
||||
print(f"❌ Syntax error found: {e}")
|
||||
return False
|
||||
assert False, str(e)
|
||||
except Exception as e:
|
||||
print(f"❌ Syntax validation failed: {e}")
|
||||
return False
|
||||
assert False, str(e)
|
||||
|
||||
def run_all_tests():
|
||||
"""Run all tests and provide summary."""
|
||||
|
||||
@@ -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,855 @@
|
||||
"""Tests for the plugin system (BaseTool, @tool decorator, ToolRegistry).
|
||||
|
||||
Run with: pytest tests/test_plugin_system.py -v
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from typing import List
|
||||
|
||||
|
||||
class TestBaseTool:
|
||||
"""Tests for BaseTool abstract class."""
|
||||
|
||||
def test_import_from_main_package(self):
|
||||
"""BaseTool should be importable from praisonaiagents."""
|
||||
from praisonaiagents import BaseTool
|
||||
assert BaseTool is not None
|
||||
|
||||
def test_subclass_with_required_attributes(self):
|
||||
"""Subclass with name, description, and run() should work."""
|
||||
from praisonaiagents import BaseTool
|
||||
|
||||
class MyTool(BaseTool):
|
||||
name = "my_tool"
|
||||
description = "A test tool"
|
||||
|
||||
def run(self, query: str) -> str:
|
||||
return f"Result: {query}"
|
||||
|
||||
tool = MyTool()
|
||||
assert tool.name == "my_tool"
|
||||
assert tool.description == "A test tool"
|
||||
assert tool.run(query="test") == "Result: test"
|
||||
|
||||
def test_auto_name_from_class(self):
|
||||
"""Name should default to lowercase class name without 'tool'."""
|
||||
from praisonaiagents import BaseTool
|
||||
|
||||
class WeatherTool(BaseTool):
|
||||
description = "Get weather"
|
||||
|
||||
def run(self) -> str:
|
||||
return "sunny"
|
||||
|
||||
tool = WeatherTool()
|
||||
assert tool.name == "weather"
|
||||
|
||||
def test_auto_description_from_docstring(self):
|
||||
"""Description should default to class docstring."""
|
||||
from praisonaiagents import BaseTool
|
||||
|
||||
class MyTool(BaseTool):
|
||||
"""This tool does amazing things."""
|
||||
name = "my_tool"
|
||||
|
||||
def run(self) -> str:
|
||||
return "done"
|
||||
|
||||
tool = MyTool()
|
||||
assert "amazing things" in tool.description
|
||||
|
||||
def test_auto_generate_parameters_schema(self):
|
||||
"""Parameters schema should be auto-generated from run() signature."""
|
||||
from praisonaiagents import BaseTool
|
||||
|
||||
class SearchTool(BaseTool):
|
||||
name = "search"
|
||||
description = "Search for things"
|
||||
|
||||
def run(self, query: str, max_results: int = 5) -> List[str]:
|
||||
return []
|
||||
|
||||
tool = SearchTool()
|
||||
schema = tool.parameters
|
||||
|
||||
assert schema["type"] == "object"
|
||||
assert "query" in schema["properties"]
|
||||
assert "max_results" in schema["properties"]
|
||||
assert "query" in schema["required"]
|
||||
assert "max_results" not in schema["required"] # Has default
|
||||
|
||||
def test_callable_like_function(self):
|
||||
"""Tool should be callable directly."""
|
||||
from praisonaiagents import BaseTool
|
||||
|
||||
class AddTool(BaseTool):
|
||||
name = "add"
|
||||
description = "Add numbers"
|
||||
|
||||
def run(self, a: int, b: int) -> int:
|
||||
return a + b
|
||||
|
||||
tool = AddTool()
|
||||
result = tool(a=2, b=3)
|
||||
assert result == 5
|
||||
|
||||
def test_safe_run_success(self):
|
||||
"""safe_run() should return ToolResult on success."""
|
||||
from praisonaiagents import BaseTool
|
||||
|
||||
class OkTool(BaseTool):
|
||||
name = "ok"
|
||||
description = "Always works"
|
||||
|
||||
def run(self) -> str:
|
||||
return "ok"
|
||||
|
||||
tool = OkTool()
|
||||
result = tool.safe_run()
|
||||
|
||||
assert result.success is True
|
||||
assert result.output == "ok"
|
||||
assert result.error is None
|
||||
|
||||
def test_safe_run_failure(self):
|
||||
"""safe_run() should catch exceptions and return ToolResult."""
|
||||
from praisonaiagents import BaseTool
|
||||
|
||||
class FailTool(BaseTool):
|
||||
name = "fail"
|
||||
description = "Always fails"
|
||||
|
||||
def run(self) -> str:
|
||||
raise ValueError("Something went wrong")
|
||||
|
||||
tool = FailTool()
|
||||
result = tool.safe_run()
|
||||
|
||||
assert result.success is False
|
||||
assert result.output is None
|
||||
assert "Something went wrong" in result.error
|
||||
|
||||
def test_get_schema_openai_format(self):
|
||||
"""get_schema() should return OpenAI-compatible function schema."""
|
||||
from praisonaiagents import BaseTool
|
||||
|
||||
class MyTool(BaseTool):
|
||||
name = "my_tool"
|
||||
description = "Does something"
|
||||
|
||||
def run(self, x: str) -> str:
|
||||
return x
|
||||
|
||||
tool = MyTool()
|
||||
schema = tool.get_schema()
|
||||
|
||||
assert schema["type"] == "function"
|
||||
assert schema["function"]["name"] == "my_tool"
|
||||
assert schema["function"]["description"] == "Does something"
|
||||
assert "parameters" in schema["function"]
|
||||
|
||||
|
||||
class TestToolDecorator:
|
||||
"""Tests for @tool decorator."""
|
||||
|
||||
def test_import_from_main_package(self):
|
||||
"""tool decorator should be importable from praisonaiagents."""
|
||||
from praisonaiagents import tool
|
||||
assert tool is not None
|
||||
|
||||
def test_decorator_without_args(self):
|
||||
"""@tool without arguments should work."""
|
||||
from praisonaiagents import tool
|
||||
|
||||
@tool
|
||||
def my_search(query: str) -> List[str]:
|
||||
"""Search for things."""
|
||||
return [query]
|
||||
|
||||
assert my_search.name == "my_search"
|
||||
assert "Search for things" in my_search.description
|
||||
result = my_search(query="test")
|
||||
assert result == ["test"]
|
||||
|
||||
def test_decorator_with_args(self):
|
||||
"""@tool(name=..., description=...) should work."""
|
||||
from praisonaiagents import tool
|
||||
|
||||
@tool(name="custom_search", description="Custom description")
|
||||
def search(query: str) -> List[str]:
|
||||
return [query]
|
||||
|
||||
assert search.name == "custom_search"
|
||||
assert search.description == "Custom description"
|
||||
|
||||
def test_decorated_function_is_basetool(self):
|
||||
"""Decorated function should be a BaseTool instance."""
|
||||
from praisonaiagents import tool, BaseTool
|
||||
|
||||
@tool
|
||||
def my_func(x: str) -> str:
|
||||
return x
|
||||
|
||||
assert isinstance(my_func, BaseTool)
|
||||
|
||||
def test_decorated_function_has_schema(self):
|
||||
"""Decorated function should have auto-generated schema."""
|
||||
from praisonaiagents import tool
|
||||
|
||||
@tool
|
||||
def calculate(a: int, b: int, operation: str = "add") -> int:
|
||||
"""Perform calculation."""
|
||||
return a + b
|
||||
|
||||
schema = calculate.get_schema()
|
||||
params = schema["function"]["parameters"]
|
||||
|
||||
assert "a" in params["properties"]
|
||||
assert "b" in params["properties"]
|
||||
assert "operation" in params["properties"]
|
||||
assert "a" in params["required"]
|
||||
assert "operation" not in params["required"]
|
||||
|
||||
def test_decorated_function_callable_both_ways(self):
|
||||
"""Decorated function should work with positional and keyword args."""
|
||||
from praisonaiagents import tool
|
||||
|
||||
@tool
|
||||
def greet(name: str, greeting: str = "Hello") -> str:
|
||||
return f"{greeting}, {name}!"
|
||||
|
||||
# Keyword args (BaseTool style)
|
||||
assert greet(name="World") == "Hello, World!"
|
||||
|
||||
# Positional args (function style)
|
||||
assert greet("World", "Hi") == "Hi, World!"
|
||||
|
||||
|
||||
class TestToolRegistry:
|
||||
"""Tests for ToolRegistry."""
|
||||
|
||||
def test_import_from_main_package(self):
|
||||
"""Registry functions should be importable from praisonaiagents."""
|
||||
from praisonaiagents import get_registry, register_tool, get_tool, ToolRegistry
|
||||
assert get_registry is not None
|
||||
assert register_tool is not None
|
||||
assert get_tool is not None
|
||||
assert ToolRegistry is not None
|
||||
|
||||
def test_register_and_get_basetool(self):
|
||||
"""Should register and retrieve BaseTool instances."""
|
||||
from praisonaiagents import BaseTool, ToolRegistry
|
||||
|
||||
class MyTool(BaseTool):
|
||||
name = "registry_test_tool"
|
||||
description = "Test"
|
||||
|
||||
def run(self) -> str:
|
||||
return "ok"
|
||||
|
||||
registry = ToolRegistry()
|
||||
tool = MyTool()
|
||||
registry.register(tool)
|
||||
|
||||
retrieved = registry.get("registry_test_tool")
|
||||
assert retrieved is tool
|
||||
|
||||
def test_register_and_get_function(self):
|
||||
"""Should register and retrieve plain functions."""
|
||||
from praisonaiagents import ToolRegistry
|
||||
|
||||
def my_func(x: str) -> str:
|
||||
return x
|
||||
|
||||
registry = ToolRegistry()
|
||||
registry.register(my_func)
|
||||
|
||||
retrieved = registry.get("my_func")
|
||||
assert retrieved is my_func
|
||||
|
||||
def test_register_with_custom_name(self):
|
||||
"""Should allow custom name override."""
|
||||
from praisonaiagents import ToolRegistry
|
||||
|
||||
def original_name() -> str:
|
||||
return "ok"
|
||||
|
||||
registry = ToolRegistry()
|
||||
registry.register(original_name, name="custom_name")
|
||||
|
||||
assert registry.get("custom_name") is original_name
|
||||
assert registry.get("original_name") is None
|
||||
|
||||
def test_list_tools(self):
|
||||
"""Should list all registered tool names."""
|
||||
from praisonaiagents import ToolRegistry, BaseTool
|
||||
|
||||
class Tool1(BaseTool):
|
||||
name = "tool1"
|
||||
description = "First"
|
||||
def run(self): return 1
|
||||
|
||||
class Tool2(BaseTool):
|
||||
name = "tool2"
|
||||
description = "Second"
|
||||
def run(self): return 2
|
||||
|
||||
registry = ToolRegistry()
|
||||
registry.register(Tool1())
|
||||
registry.register(Tool2())
|
||||
|
||||
names = registry.list_tools()
|
||||
assert "tool1" in names
|
||||
assert "tool2" in names
|
||||
|
||||
def test_unregister(self):
|
||||
"""Should remove tools from registry."""
|
||||
from praisonaiagents import ToolRegistry
|
||||
|
||||
def temp_func() -> str:
|
||||
return "temp"
|
||||
|
||||
registry = ToolRegistry()
|
||||
registry.register(temp_func)
|
||||
assert "temp_func" in registry
|
||||
|
||||
result = registry.unregister("temp_func")
|
||||
assert result is True
|
||||
assert "temp_func" not in registry
|
||||
|
||||
def test_no_duplicate_registration(self):
|
||||
"""Should not overwrite existing tools by default."""
|
||||
from praisonaiagents import ToolRegistry
|
||||
|
||||
def func_v1() -> str:
|
||||
return "v1"
|
||||
|
||||
def func_v2() -> str:
|
||||
return "v2"
|
||||
|
||||
registry = ToolRegistry()
|
||||
registry.register(func_v1, name="my_func")
|
||||
registry.register(func_v2, name="my_func") # Should be ignored
|
||||
|
||||
retrieved = registry.get("my_func")
|
||||
assert retrieved() == "v1"
|
||||
|
||||
def test_overwrite_registration(self):
|
||||
"""Should overwrite when overwrite=True."""
|
||||
from praisonaiagents import ToolRegistry
|
||||
|
||||
def func_v1() -> str:
|
||||
return "v1"
|
||||
|
||||
def func_v2() -> str:
|
||||
return "v2"
|
||||
|
||||
registry = ToolRegistry()
|
||||
registry.register(func_v1, name="my_func")
|
||||
registry.register(func_v2, name="my_func", overwrite=True)
|
||||
|
||||
retrieved = registry.get("my_func")
|
||||
assert retrieved() == "v2"
|
||||
|
||||
def test_global_registry_singleton(self):
|
||||
"""get_registry() should return same instance."""
|
||||
from praisonaiagents import get_registry
|
||||
|
||||
reg1 = get_registry()
|
||||
reg2 = get_registry()
|
||||
assert reg1 is reg2
|
||||
|
||||
def test_clear_registry(self):
|
||||
"""clear() should remove all tools."""
|
||||
from praisonaiagents import ToolRegistry
|
||||
|
||||
registry = ToolRegistry()
|
||||
registry.register(lambda: "a", name="a")
|
||||
registry.register(lambda: "b", name="b")
|
||||
assert len(registry) == 2
|
||||
|
||||
registry.clear()
|
||||
assert len(registry) == 0
|
||||
|
||||
|
||||
class TestToolResult:
|
||||
"""Tests for ToolResult wrapper."""
|
||||
|
||||
def test_import_from_main_package(self):
|
||||
"""ToolResult should be importable from praisonaiagents."""
|
||||
from praisonaiagents import ToolResult
|
||||
assert ToolResult is not None
|
||||
|
||||
def test_success_result(self):
|
||||
"""Should create success result."""
|
||||
from praisonaiagents import ToolResult
|
||||
|
||||
result = ToolResult(output="data", success=True)
|
||||
assert result.success is True
|
||||
assert result.output == "data"
|
||||
assert result.error is None
|
||||
|
||||
def test_error_result(self):
|
||||
"""Should create error result."""
|
||||
from praisonaiagents import ToolResult
|
||||
|
||||
result = ToolResult(output=None, success=False, error="Failed")
|
||||
assert result.success is False
|
||||
assert result.error == "Failed"
|
||||
|
||||
def test_to_dict(self):
|
||||
"""Should convert to dictionary."""
|
||||
from praisonaiagents import ToolResult
|
||||
|
||||
result = ToolResult(output="data", success=True, metadata={"key": "value"})
|
||||
d = result.to_dict()
|
||||
|
||||
assert d["output"] == "data"
|
||||
assert d["success"] is True
|
||||
assert d["metadata"]["key"] == "value"
|
||||
|
||||
def test_str_representation(self):
|
||||
"""String should show output or error."""
|
||||
from praisonaiagents import ToolResult
|
||||
|
||||
success = ToolResult(output="hello", success=True)
|
||||
assert str(success) == "hello"
|
||||
|
||||
failure = ToolResult(output=None, success=False, error="oops")
|
||||
assert "oops" in str(failure)
|
||||
|
||||
|
||||
class TestFunctionTool:
|
||||
"""Tests for FunctionTool class."""
|
||||
|
||||
def test_import_from_main_package(self):
|
||||
"""FunctionTool should be importable from praisonaiagents."""
|
||||
from praisonaiagents import FunctionTool
|
||||
assert FunctionTool is not None
|
||||
|
||||
def test_wraps_function(self):
|
||||
"""Should wrap a function and preserve its behavior."""
|
||||
from praisonaiagents import FunctionTool
|
||||
|
||||
def original(x: int, y: int) -> int:
|
||||
"""Add two numbers."""
|
||||
return x + y
|
||||
|
||||
wrapped = FunctionTool(original)
|
||||
|
||||
assert wrapped.name == "original"
|
||||
assert "Add two numbers" in wrapped.description
|
||||
assert wrapped(1, 2) == 3
|
||||
assert wrapped.run(x=1, y=2) == 3
|
||||
|
||||
|
||||
class TestAgentIntegration:
|
||||
"""Tests for Agent integration with plugin tools."""
|
||||
|
||||
def test_agent_with_basetool_instance(self):
|
||||
"""Agent should accept BaseTool instances in tools list."""
|
||||
from praisonaiagents import Agent, BaseTool
|
||||
|
||||
class AddTool(BaseTool):
|
||||
name = "add"
|
||||
description = "Add two numbers"
|
||||
|
||||
def run(self, a: int, b: int) -> int:
|
||||
return a + b
|
||||
|
||||
tool = AddTool()
|
||||
agent = Agent(
|
||||
name="TestAgent",
|
||||
role="Tester",
|
||||
goal="Test tools",
|
||||
tools=[tool]
|
||||
)
|
||||
|
||||
assert len(agent.tools) == 1
|
||||
assert agent.tools[0] is tool
|
||||
|
||||
def test_agent_with_decorated_tool(self):
|
||||
"""Agent should accept @tool decorated functions."""
|
||||
from praisonaiagents import Agent, tool
|
||||
|
||||
@tool
|
||||
def multiply(a: int, b: int) -> int:
|
||||
"""Multiply two numbers."""
|
||||
return a * b
|
||||
|
||||
agent = Agent(
|
||||
name="TestAgent",
|
||||
role="Tester",
|
||||
goal="Test tools",
|
||||
tools=[multiply]
|
||||
)
|
||||
|
||||
assert len(agent.tools) == 1
|
||||
assert agent.tools[0].name == "multiply"
|
||||
|
||||
def test_agent_execute_basetool(self):
|
||||
"""Agent.execute_tool should work with BaseTool instances."""
|
||||
from praisonaiagents import Agent, BaseTool
|
||||
|
||||
class ConcatTool(BaseTool):
|
||||
name = "concat"
|
||||
description = "Concatenate strings"
|
||||
|
||||
def run(self, a: str, b: str) -> str:
|
||||
return a + b
|
||||
|
||||
agent = Agent(
|
||||
name="TestAgent",
|
||||
role="Tester",
|
||||
goal="Test tools",
|
||||
tools=[ConcatTool()]
|
||||
)
|
||||
|
||||
result = agent.execute_tool("concat", {"a": "hello", "b": "world"})
|
||||
assert result == "helloworld"
|
||||
|
||||
def test_agent_execute_decorated_tool(self):
|
||||
"""Agent.execute_tool should work with @tool decorated functions."""
|
||||
from praisonaiagents import Agent, tool
|
||||
|
||||
@tool
|
||||
def subtract(a: int, b: int) -> int:
|
||||
"""Subtract b from a."""
|
||||
return a - b
|
||||
|
||||
agent = Agent(
|
||||
name="TestAgent",
|
||||
role="Tester",
|
||||
goal="Test tools",
|
||||
tools=[subtract]
|
||||
)
|
||||
|
||||
result = agent.execute_tool("subtract", {"a": 10, "b": 3})
|
||||
assert result == 7
|
||||
|
||||
def test_agent_with_tool_names_from_registry(self):
|
||||
"""Agent should resolve tool names from registry."""
|
||||
from praisonaiagents import Agent, BaseTool, register_tool, get_registry
|
||||
|
||||
# Clear registry first
|
||||
get_registry().clear()
|
||||
|
||||
class DivideTool(BaseTool):
|
||||
name = "divide"
|
||||
description = "Divide two numbers"
|
||||
|
||||
def run(self, a: float, b: float) -> float:
|
||||
return a / b
|
||||
|
||||
# Register tool
|
||||
register_tool(DivideTool())
|
||||
|
||||
# Create agent with tool name string
|
||||
agent = Agent(
|
||||
name="TestAgent",
|
||||
role="Tester",
|
||||
goal="Test tools",
|
||||
tools=["divide"]
|
||||
)
|
||||
|
||||
assert len(agent.tools) == 1
|
||||
assert agent.tools[0].name == "divide"
|
||||
|
||||
# Clean up
|
||||
get_registry().clear()
|
||||
|
||||
def test_agent_execute_tool_from_registry(self):
|
||||
"""Agent should find tools in registry during execute_tool."""
|
||||
from praisonaiagents import Agent, BaseTool, register_tool, get_registry
|
||||
|
||||
# Clear registry first
|
||||
get_registry().clear()
|
||||
|
||||
class PowerTool(BaseTool):
|
||||
name = "power"
|
||||
description = "Raise to power"
|
||||
|
||||
def run(self, base: int, exp: int) -> int:
|
||||
return base ** exp
|
||||
|
||||
register_tool(PowerTool())
|
||||
|
||||
# Agent without tools - should still find from registry
|
||||
agent = Agent(
|
||||
name="TestAgent",
|
||||
role="Tester",
|
||||
goal="Test tools",
|
||||
tools=[]
|
||||
)
|
||||
|
||||
result = agent.execute_tool("power", {"base": 2, "exp": 3})
|
||||
assert result == 8
|
||||
|
||||
# Clean up
|
||||
get_registry().clear()
|
||||
|
||||
|
||||
class TestPluginDiscovery:
|
||||
"""Tests for entry_points plugin discovery."""
|
||||
|
||||
def test_discover_plugins_returns_count(self):
|
||||
"""discover_plugins() should return number of plugins found."""
|
||||
from praisonaiagents import ToolRegistry
|
||||
|
||||
registry = ToolRegistry()
|
||||
count = registry.discover_plugins()
|
||||
# Should return 0 or more (no external plugins installed in test env)
|
||||
assert isinstance(count, int)
|
||||
assert count >= 0
|
||||
|
||||
def test_discover_plugins_only_runs_once(self):
|
||||
"""discover_plugins() should only run once per registry."""
|
||||
from praisonaiagents import ToolRegistry
|
||||
|
||||
registry = ToolRegistry()
|
||||
_count1 = registry.discover_plugins() # First call
|
||||
count2 = registry.discover_plugins()
|
||||
|
||||
# Second call should return 0 (already discovered)
|
||||
assert count2 == 0
|
||||
assert _count1 >= 0 # Use variable to avoid lint warning
|
||||
|
||||
def test_discover_plugins_with_mock_entry_point(self, mocker):
|
||||
"""Should load tools from mocked entry_points."""
|
||||
from praisonaiagents import ToolRegistry, BaseTool
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
# Create a mock tool class
|
||||
class MockPluginTool(BaseTool):
|
||||
name = "mock_plugin"
|
||||
description = "A mocked plugin tool"
|
||||
def run(self) -> str:
|
||||
return "mocked"
|
||||
|
||||
# Create mock entry point
|
||||
mock_ep = MagicMock()
|
||||
mock_ep.name = "mock_plugin"
|
||||
mock_ep.load.return_value = MockPluginTool
|
||||
|
||||
# Mock entry_points to return our mock
|
||||
mocker.patch(
|
||||
'praisonaiagents.tools.registry.entry_points',
|
||||
return_value=[mock_ep]
|
||||
)
|
||||
|
||||
registry = ToolRegistry()
|
||||
count = registry.discover_plugins()
|
||||
|
||||
assert count == 1
|
||||
assert "mock_plugin" in registry
|
||||
|
||||
tool = registry.get("mock_plugin")
|
||||
assert tool.run() == "mocked"
|
||||
|
||||
def test_discover_plugins_handles_load_error(self, mocker):
|
||||
"""Should handle errors when loading plugins gracefully."""
|
||||
from praisonaiagents import ToolRegistry
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
# Create mock entry point that raises on load
|
||||
mock_ep = MagicMock()
|
||||
mock_ep.name = "broken_plugin"
|
||||
mock_ep.load.side_effect = ImportError("Module not found")
|
||||
|
||||
mocker.patch(
|
||||
'praisonaiagents.tools.registry.entry_points',
|
||||
return_value=[mock_ep]
|
||||
)
|
||||
|
||||
registry = ToolRegistry()
|
||||
# Should not raise, just log warning
|
||||
count = registry.discover_plugins()
|
||||
|
||||
assert count == 0
|
||||
assert "broken_plugin" not in registry
|
||||
|
||||
def test_discover_callable_function(self, mocker):
|
||||
"""Should register plain functions from entry_points."""
|
||||
from praisonaiagents import ToolRegistry
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
def plugin_function(x: str) -> str:
|
||||
return f"plugin: {x}"
|
||||
|
||||
mock_ep = MagicMock()
|
||||
mock_ep.name = "func_plugin"
|
||||
mock_ep.load.return_value = plugin_function
|
||||
|
||||
mocker.patch(
|
||||
'praisonaiagents.tools.registry.entry_points',
|
||||
return_value=[mock_ep]
|
||||
)
|
||||
|
||||
registry = ToolRegistry()
|
||||
count = registry.discover_plugins()
|
||||
|
||||
assert count == 1
|
||||
func = registry.get("func_plugin")
|
||||
assert func("test") == "plugin: test"
|
||||
|
||||
def test_auto_discover_on_get_if_not_found(self, mocker):
|
||||
"""get() should trigger discovery if tool not found."""
|
||||
from praisonaiagents import ToolRegistry, BaseTool
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
class LazyTool(BaseTool):
|
||||
name = "lazy_tool"
|
||||
description = "Lazy loaded"
|
||||
def run(self) -> str:
|
||||
return "lazy"
|
||||
|
||||
mock_ep = MagicMock()
|
||||
mock_ep.name = "lazy_tool"
|
||||
mock_ep.load.return_value = LazyTool
|
||||
|
||||
mocker.patch(
|
||||
'praisonaiagents.tools.registry.entry_points',
|
||||
return_value=[mock_ep]
|
||||
)
|
||||
|
||||
registry = ToolRegistry()
|
||||
# Don't call discover_plugins() explicitly
|
||||
# get() should trigger it
|
||||
tool = registry.get("lazy_tool")
|
||||
|
||||
assert tool is not None
|
||||
assert tool.run() == "lazy"
|
||||
|
||||
|
||||
class TestToolValidation:
|
||||
"""Tests for tool validation."""
|
||||
|
||||
def test_import_validation_error(self):
|
||||
"""ToolValidationError should be importable."""
|
||||
from praisonaiagents import ToolValidationError
|
||||
assert ToolValidationError is not None
|
||||
|
||||
def test_import_validate_tool(self):
|
||||
"""validate_tool should be importable."""
|
||||
from praisonaiagents import validate_tool
|
||||
assert validate_tool is not None
|
||||
|
||||
def test_valid_tool_passes_validation(self):
|
||||
"""Valid tool should pass validation."""
|
||||
from praisonaiagents import BaseTool
|
||||
|
||||
class ValidTool(BaseTool):
|
||||
name = "valid"
|
||||
description = "A valid tool"
|
||||
|
||||
def run(self) -> str:
|
||||
return "ok"
|
||||
|
||||
tool = ValidTool()
|
||||
assert tool.validate() is True
|
||||
|
||||
def test_tool_without_name_gets_auto_name(self):
|
||||
"""Tool without name should get auto-generated name from class."""
|
||||
from praisonaiagents import BaseTool
|
||||
|
||||
class MyCustomTool(BaseTool):
|
||||
name = "" # Empty name - will be auto-filled
|
||||
description = "Has description"
|
||||
|
||||
def run(self) -> str:
|
||||
return "ok"
|
||||
|
||||
tool = MyCustomTool()
|
||||
# Name should be auto-generated from class name
|
||||
assert tool.name == "mycustom" # "MyCustomTool" -> "mycustom" (lowercase, "tool" removed)
|
||||
assert tool.validate() is True
|
||||
|
||||
def test_tool_without_description_gets_auto_description(self):
|
||||
"""Tool without description should get auto-generated description."""
|
||||
from praisonaiagents import BaseTool
|
||||
|
||||
class NoDescTool(BaseTool):
|
||||
"""This is the docstring description."""
|
||||
name = "no_desc"
|
||||
description = "" # Empty - will use docstring
|
||||
|
||||
def run(self) -> str:
|
||||
return "ok"
|
||||
|
||||
tool = NoDescTool()
|
||||
# Description should be auto-generated from docstring
|
||||
assert "docstring description" in tool.description
|
||||
assert tool.validate() is True
|
||||
|
||||
def test_tool_with_manually_cleared_name_fails(self):
|
||||
"""Tool with name manually cleared after init should fail validation."""
|
||||
from praisonaiagents import BaseTool, ToolValidationError
|
||||
|
||||
class MyTool(BaseTool):
|
||||
name = "my_tool"
|
||||
description = "Test"
|
||||
def run(self) -> str:
|
||||
return "ok"
|
||||
|
||||
tool = MyTool()
|
||||
tool.name = "" # Manually clear after init
|
||||
with pytest.raises(ToolValidationError):
|
||||
tool.validate()
|
||||
|
||||
def test_validate_tool_function_with_basetool(self):
|
||||
"""validate_tool() should work with BaseTool instances."""
|
||||
from praisonaiagents import BaseTool, validate_tool
|
||||
|
||||
class MyTool(BaseTool):
|
||||
name = "my_tool"
|
||||
description = "Test"
|
||||
def run(self) -> str:
|
||||
return "ok"
|
||||
|
||||
assert validate_tool(MyTool()) is True
|
||||
|
||||
def test_validate_tool_function_with_callable(self):
|
||||
"""validate_tool() should work with plain callables."""
|
||||
from praisonaiagents import validate_tool
|
||||
|
||||
def my_func() -> str:
|
||||
return "ok"
|
||||
|
||||
assert validate_tool(my_func) is True
|
||||
|
||||
def test_validate_tool_rejects_invalid_type(self):
|
||||
"""validate_tool() should reject non-tool objects."""
|
||||
from praisonaiagents import validate_tool, ToolValidationError
|
||||
|
||||
with pytest.raises(ToolValidationError):
|
||||
validate_tool("not a tool")
|
||||
|
||||
with pytest.raises(ToolValidationError):
|
||||
validate_tool(123)
|
||||
|
||||
def test_validate_class_method(self):
|
||||
"""BaseTool.validate_class() should check class validity."""
|
||||
from praisonaiagents import BaseTool
|
||||
|
||||
class ValidTool(BaseTool):
|
||||
name = "valid"
|
||||
description = "Valid"
|
||||
def run(self) -> str:
|
||||
return "ok"
|
||||
|
||||
assert ValidTool.validate_class() is True
|
||||
|
||||
def test_decorated_tool_passes_validation(self):
|
||||
"""@tool decorated functions should pass validation."""
|
||||
from praisonaiagents import tool, validate_tool
|
||||
|
||||
@tool
|
||||
def my_search(query: str) -> list:
|
||||
"""Search for things."""
|
||||
return []
|
||||
|
||||
assert validate_tool(my_search) is True
|
||||
|
||||
|
||||
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,142 @@
|
||||
"""Test Tavily tools integration."""
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Test 1: Import test - verify lazy loading works
|
||||
print("=" * 60)
|
||||
print("Test 1: Import Test (Lazy Loading)")
|
||||
print("=" * 60)
|
||||
|
||||
try:
|
||||
from praisonaiagents.tools import tavily_search, tavily_extract, tavily_crawl, tavily_map
|
||||
print("✓ Successfully imported tavily_search, tavily_extract, tavily_crawl, tavily_map")
|
||||
except ImportError as e:
|
||||
print(f"✗ Import failed: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
from praisonaiagents.tools import TavilyTools
|
||||
print("✓ Successfully imported TavilyTools class")
|
||||
except ImportError as e:
|
||||
print(f"✗ TavilyTools import failed: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
from praisonaiagents.tools import tavily_search_async, tavily_extract_async
|
||||
print("✓ Successfully imported async functions")
|
||||
except ImportError as e:
|
||||
print(f"✗ Async import failed: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
# Test 2: Check API key detection
|
||||
print("\n" + "=" * 60)
|
||||
print("Test 2: API Key Detection")
|
||||
print("=" * 60)
|
||||
|
||||
api_key = os.environ.get("TAVILY_API_KEY")
|
||||
if api_key:
|
||||
print(f"✓ TAVILY_API_KEY is set (length: {len(api_key)})")
|
||||
else:
|
||||
print("✗ TAVILY_API_KEY is not set")
|
||||
print(" To run full tests, set: export TAVILY_API_KEY=your_api_key")
|
||||
|
||||
# Test 3: Error handling without API key
|
||||
print("\n" + "=" * 60)
|
||||
print("Test 3: Error Handling (No API Key)")
|
||||
print("=" * 60)
|
||||
|
||||
# Temporarily unset API key to test error handling
|
||||
original_key = os.environ.pop("TAVILY_API_KEY", None)
|
||||
|
||||
try:
|
||||
result = tavily_search("test query")
|
||||
if "error" in result:
|
||||
print(f"✓ Proper error returned when no API key: {result['error'][:50]}...")
|
||||
else:
|
||||
print("✗ Expected error but got results")
|
||||
except Exception as e:
|
||||
print(f"✗ Unexpected exception: {e}")
|
||||
|
||||
# Restore API key
|
||||
if original_key:
|
||||
os.environ["TAVILY_API_KEY"] = original_key
|
||||
|
||||
# Test 4: TavilyTools class instantiation
|
||||
print("\n" + "=" * 60)
|
||||
print("Test 4: TavilyTools Class")
|
||||
print("=" * 60)
|
||||
|
||||
try:
|
||||
tools = TavilyTools()
|
||||
print("✓ TavilyTools instantiated successfully")
|
||||
|
||||
# Check methods exist
|
||||
methods = ['search', 'extract', 'crawl', 'map', 'asearch', 'aextract']
|
||||
for method in methods:
|
||||
if hasattr(tools, method):
|
||||
print(f" ✓ Method '{method}' exists")
|
||||
else:
|
||||
print(f" ✗ Method '{method}' missing")
|
||||
except Exception as e:
|
||||
print(f"✗ TavilyTools instantiation failed: {e}")
|
||||
|
||||
# Test 5: Full integration test (only if API key is available)
|
||||
print("\n" + "=" * 60)
|
||||
print("Test 5: Full Integration Test")
|
||||
print("=" * 60)
|
||||
|
||||
if os.environ.get("TAVILY_API_KEY"):
|
||||
print("Running search test...")
|
||||
try:
|
||||
result = tavily_search("Python programming", max_results=2)
|
||||
if "error" not in result:
|
||||
print(f"✓ Search successful!")
|
||||
print(f" Query: {result.get('query', 'N/A')}")
|
||||
print(f" Results: {len(result.get('results', []))} items")
|
||||
if result.get('results'):
|
||||
print(f" First result: {result['results'][0].get('title', 'N/A')[:50]}...")
|
||||
else:
|
||||
print(f"✗ Search returned error: {result['error']}")
|
||||
except Exception as e:
|
||||
print(f"✗ Search failed with exception: {e}")
|
||||
else:
|
||||
print("⊘ Skipped (TAVILY_API_KEY not set)")
|
||||
|
||||
# Test 6: Agent integration test
|
||||
print("\n" + "=" * 60)
|
||||
print("Test 6: Agent Integration Test")
|
||||
print("=" * 60)
|
||||
|
||||
if os.environ.get("TAVILY_API_KEY") and os.environ.get("OPENAI_API_KEY"):
|
||||
try:
|
||||
from praisonaiagents import Agent
|
||||
|
||||
agent = Agent(
|
||||
name="SearchAgent",
|
||||
role="Web Researcher",
|
||||
goal="Search the web for information",
|
||||
tools=[tavily_search]
|
||||
)
|
||||
print("✓ Agent created with tavily_search tool")
|
||||
|
||||
# Check tool is properly registered
|
||||
if agent.tools and tavily_search in agent.tools:
|
||||
print("✓ tavily_search is in agent's tools list")
|
||||
else:
|
||||
print("⊘ Tool registration check inconclusive")
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ Agent integration failed: {e}")
|
||||
else:
|
||||
missing = []
|
||||
if not os.environ.get("TAVILY_API_KEY"):
|
||||
missing.append("TAVILY_API_KEY")
|
||||
if not os.environ.get("OPENAI_API_KEY"):
|
||||
missing.append("OPENAI_API_KEY")
|
||||
print(f"⊘ Skipped (missing: {', '.join(missing)})")
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("Test Summary")
|
||||
print("=" * 60)
|
||||
print("All import and basic tests passed!")
|
||||
print("For full functionality, ensure TAVILY_API_KEY is set.")
|
||||
@@ -59,12 +59,14 @@ class TestValidationFeedback:
|
||||
|
||||
# Simulate task execution results
|
||||
collect_task.result = TaskOutput(
|
||||
description="Collected data",
|
||||
raw="Collected only 3 items",
|
||||
agent="agent1"
|
||||
)
|
||||
collect_task.status = "completed"
|
||||
|
||||
validate_task.result = TaskOutput(
|
||||
description="Validation result",
|
||||
raw="Not enough results, need at least 10",
|
||||
agent="agent2"
|
||||
)
|
||||
@@ -219,12 +221,14 @@ class TestValidationFeedback:
|
||||
|
||||
# Simulate task execution results
|
||||
data_task.result = TaskOutput(
|
||||
description="Generated data",
|
||||
raw="Generated data with errors",
|
||||
agent="agent1"
|
||||
)
|
||||
data_task.status = "completed"
|
||||
|
||||
validate_task.result = TaskOutput(
|
||||
description="Validation result",
|
||||
raw="Data has errors, please fix",
|
||||
agent="agent2"
|
||||
)
|
||||
|
||||
@@ -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"])
|
||||
@@ -0,0 +1,135 @@
|
||||
"""Test You.com tools integration."""
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Test 1: Import test - verify lazy loading works
|
||||
print("=" * 60)
|
||||
print("Test 1: Import Test (Lazy Loading)")
|
||||
print("=" * 60)
|
||||
|
||||
try:
|
||||
from praisonaiagents.tools import ydc_search, ydc_contents, ydc_news, ydc_images
|
||||
print("✓ Successfully imported ydc_search, ydc_contents, ydc_news, ydc_images")
|
||||
except ImportError as e:
|
||||
print(f"✗ Import failed: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
from praisonaiagents.tools import YouTools
|
||||
print("✓ Successfully imported YouTools class")
|
||||
except ImportError as e:
|
||||
print(f"✗ YouTools import failed: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
# Test 2: Check API key detection
|
||||
print("\n" + "=" * 60)
|
||||
print("Test 2: API Key Detection")
|
||||
print("=" * 60)
|
||||
|
||||
api_key = os.environ.get("YDC_API_KEY")
|
||||
if api_key:
|
||||
print(f"✓ YDC_API_KEY is set (length: {len(api_key)})")
|
||||
else:
|
||||
print("✗ YDC_API_KEY is not set")
|
||||
print(" To run full tests, set: export YDC_API_KEY=your_api_key")
|
||||
|
||||
# Test 3: Error handling without API key
|
||||
print("\n" + "=" * 60)
|
||||
print("Test 3: Error Handling (No API Key)")
|
||||
print("=" * 60)
|
||||
|
||||
# Temporarily unset API key to test error handling
|
||||
original_key = os.environ.pop("YDC_API_KEY", None)
|
||||
|
||||
try:
|
||||
result = ydc_search("test query")
|
||||
if "error" in result:
|
||||
print(f"✓ Proper error returned when no API key: {result['error'][:50]}...")
|
||||
else:
|
||||
print("✗ Expected error but got results")
|
||||
except Exception as e:
|
||||
print(f"✗ Unexpected exception: {e}")
|
||||
|
||||
# Restore API key
|
||||
if original_key:
|
||||
os.environ["YDC_API_KEY"] = original_key
|
||||
|
||||
# Test 4: YouTools class instantiation
|
||||
print("\n" + "=" * 60)
|
||||
print("Test 4: YouTools Class")
|
||||
print("=" * 60)
|
||||
|
||||
try:
|
||||
tools = YouTools()
|
||||
print("✓ YouTools instantiated successfully")
|
||||
|
||||
# Check methods exist
|
||||
methods = ['search', 'get_contents', 'live_news', 'images']
|
||||
for method in methods:
|
||||
if hasattr(tools, method):
|
||||
print(f" ✓ Method '{method}' exists")
|
||||
else:
|
||||
print(f" ✗ Method '{method}' missing")
|
||||
except Exception as e:
|
||||
print(f"✗ YouTools instantiation failed: {e}")
|
||||
|
||||
# Test 5: Full integration test (only if API key is available)
|
||||
print("\n" + "=" * 60)
|
||||
print("Test 5: Full Integration Test")
|
||||
print("=" * 60)
|
||||
|
||||
if os.environ.get("YDC_API_KEY"):
|
||||
print("Running search test...")
|
||||
try:
|
||||
result = ydc_search("Python programming", count=2)
|
||||
if "error" not in result:
|
||||
print(f"✓ Search successful!")
|
||||
web_results = result.get('results', {}).get('web', [])
|
||||
print(f" Web results: {len(web_results)} items")
|
||||
if web_results:
|
||||
print(f" First result: {web_results[0].get('title', 'N/A')[:50]}...")
|
||||
else:
|
||||
print(f"✗ Search returned error: {result['error']}")
|
||||
except Exception as e:
|
||||
print(f"✗ Search failed with exception: {e}")
|
||||
else:
|
||||
print("⊘ Skipped (YDC_API_KEY not set)")
|
||||
|
||||
# Test 6: Agent integration test
|
||||
print("\n" + "=" * 60)
|
||||
print("Test 6: Agent Integration Test")
|
||||
print("=" * 60)
|
||||
|
||||
if os.environ.get("YDC_API_KEY") and os.environ.get("OPENAI_API_KEY"):
|
||||
try:
|
||||
from praisonaiagents import Agent
|
||||
|
||||
agent = Agent(
|
||||
name="SearchAgent",
|
||||
role="Web Researcher",
|
||||
goal="Search the web for information",
|
||||
tools=[ydc_search]
|
||||
)
|
||||
print("✓ Agent created with ydc_search tool")
|
||||
|
||||
# Check tool is properly registered
|
||||
if agent.tools and ydc_search in agent.tools:
|
||||
print("✓ ydc_search is in agent's tools list")
|
||||
else:
|
||||
print("⊘ Tool registration check inconclusive")
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ Agent integration failed: {e}")
|
||||
else:
|
||||
missing = []
|
||||
if not os.environ.get("YDC_API_KEY"):
|
||||
missing.append("YDC_API_KEY")
|
||||
if not os.environ.get("OPENAI_API_KEY"):
|
||||
missing.append("OPENAI_API_KEY")
|
||||
print(f"⊘ Skipped (missing: {', '.join(missing)})")
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("Test Summary")
|
||||
print("=" * 60)
|
||||
print("All import and basic tests passed!")
|
||||
print("For full functionality, ensure YDC_API_KEY is set.")
|
||||
Generated
+1
-1
@@ -2422,7 +2422,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "praisonaiagents"
|
||||
version = "0.0.163"
|
||||
version = "0.0.185"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
|
||||
+1170
-5
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.90.tar.gz"
|
||||
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v2.2.90.tar.gz | shasum -a 256`.split.first
|
||||
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v2.3.7.tar.gz"
|
||||
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v2.3.7.tar.gz | shasum -a 256`.split.first
|
||||
license "MIT"
|
||||
|
||||
depends_on "[email protected]"
|
||||
|
||||
+1003
-20
File diff suppressed because it is too large.
Load diff
@@ -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.90 gunicorn markdown\n")
|
||||
file.write("RUN pip install flask praisonai==2.3.7 gunicorn markdown\n")
|
||||
file.write("EXPOSE 8080\n")
|
||||
file.write('CMD ["gunicorn", "-b", "0.0.0.0:8080", "api:app"]\n')
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "0.0.1"
|
||||
__version__ = "2.3.7"
|
||||
+11
-194
@@ -1,9 +1,9 @@
|
||||
[project]
|
||||
name = "PraisonAI"
|
||||
version = "2.2.90"
|
||||
dynamic = ["version"]
|
||||
description = "PraisonAI is an AI Agents Framework with Self Reflection. PraisonAI application combines PraisonAI Agents, AutoGen, and CrewAI into a low-code solution for building and managing multi-agent LLM systems, focusing on simplicity, customisation, and efficient human-agent collaboration."
|
||||
readme = "README.md"
|
||||
license = ""
|
||||
license = {text = "MIT"}
|
||||
requires-python = ">=3.10"
|
||||
authors = [
|
||||
{ name = "Mervin Praison" }
|
||||
@@ -12,7 +12,7 @@ dependencies = [
|
||||
"rich>=13.7",
|
||||
"markdown>=3.5",
|
||||
"pyparsing>=3.0.0",
|
||||
"praisonaiagents>=0.0.163",
|
||||
"praisonaiagents>=0.0.185",
|
||||
"python-dotenv>=0.19.0",
|
||||
"instructor>=1.3.3",
|
||||
"PyYAML>=6.0",
|
||||
@@ -100,200 +100,17 @@ autogen-v4 = [
|
||||
"crewai"
|
||||
]
|
||||
|
||||
[tool.poetry]
|
||||
name = "PraisonAI"
|
||||
version = "2.2.90"
|
||||
description = "PraisonAI is an AI Agents Framework with Self Reflection. PraisonAI application combines PraisonAI Agents, AutoGen, and CrewAI into a low-code solution for building and managing multi-agent LLM systems, focusing on simplicity, customisation, and efficient human-agent collaboration."
|
||||
authors = ["Mervin Praison"]
|
||||
license = ""
|
||||
readme = "README.md"
|
||||
|
||||
[tool.poetry.urls]
|
||||
[project.urls]
|
||||
Homepage = "https://docs.praison.ai"
|
||||
Repository = "https://github.com/mervinpraison/PraisonAI"
|
||||
|
||||
[tool.setuptools]
|
||||
packages = ["praisonai"]
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = ">=3.10,<3.13"
|
||||
rich = ">=13.7"
|
||||
markdown = ">=3.5"
|
||||
pyparsing = ">=3.0.0"
|
||||
praisonaiagents = ">=0.0.163"
|
||||
python-dotenv = ">=0.19.0"
|
||||
instructor = ">=1.3.3"
|
||||
PyYAML = ">=6.0"
|
||||
mcp = ">=1.6.0"
|
||||
pyautogen = {version = "==0.2.29", optional = true}
|
||||
autogen-agentchat = {version = ">=0.4.0", optional = true}
|
||||
autogen-ext = {version = ">=0.4.0", optional = true}
|
||||
autogen-core = {version = ">=0.4.0", optional = true}
|
||||
crewai = {version = ">=0.157.0", optional = true}
|
||||
praisonai-tools = {version = ">=0.0.22", optional = true}
|
||||
chainlit = {version = "==2.5.5", optional = true}
|
||||
gradio = {version = ">=4.26.0", optional = true}
|
||||
flask = {version = ">=3.0.0", optional = true}
|
||||
agentops = {version = ">=0.3.12", optional = true}
|
||||
langchain-google-genai = {version = ">=2.1.0", optional = true}
|
||||
langchain-anthropic = {version = ">=0.3.0", optional = true}
|
||||
langchain-openai = {version = ">=0.2.1,<0.3.0", optional = true}
|
||||
langchain-cohere = {version = ">=0.3.0,<0.4.0", optional = true}
|
||||
litellm = {version = ">=1.72.6", optional = true}
|
||||
aiosqlite= {version = ">=0.20.0", optional = true}
|
||||
greenlet = {version = ">=3.0.3", optional = true}
|
||||
tavily-python = {version = "==0.5.0", optional=true}
|
||||
crawl4ai = {version = ">=0.7.0", optional = true}
|
||||
websockets = {version = ">=12.0", optional = true}
|
||||
plotly = {version = ">=5.24.0", optional = true}
|
||||
yfinance = {version = ">=0.2.44", optional = true}
|
||||
duckduckgo_search = {version = ">=6.3.0", optional = true}
|
||||
twilio = {version = ">=7.0.0", optional = true}
|
||||
fastapi = {version = ">=0.95.0", optional = true}
|
||||
uvicorn = {version = ">=0.20.0", optional = true}
|
||||
flaml = {version = ">=2.3.1", extras = ["automl"], optional = true}
|
||||
pyngrok = {version = ">=1.4.0", optional = true}
|
||||
sqlalchemy = {version = ">=2.0.36", optional = true}
|
||||
playwright = {version = ">=1.47.0", optional = true}
|
||||
openai = {version = ">=1.54.0", optional = true}
|
||||
pydantic = {version = "<=2.10.1", optional = true}
|
||||
# unsloth = {version = ">=2024.11.7", extras = ["colab-new"], optional = true}
|
||||
|
||||
[tool.poetry.group.docs.dependencies]
|
||||
mkdocs = "*"
|
||||
markdown-include = "*"
|
||||
mkdocs-material = "*"
|
||||
mkdocs-jupyter = "*"
|
||||
mkdocs-glightbox = "*"
|
||||
pymdown-extensions = "*"
|
||||
Pygments = "*"
|
||||
mkdocstrings = "*"
|
||||
mkdocs-apidoc = "*"
|
||||
mkdocstrings-python = "*"
|
||||
pdoc3 = "*"
|
||||
|
||||
[tool.poetry.group.test.dependencies]
|
||||
pytest = "8.2.2"
|
||||
pytest-asyncio = ">=0.26.0"
|
||||
pre-commit = "3.7.1"
|
||||
unittest-xml-reporting = "3.2.0"
|
||||
xmlrunner = "*"
|
||||
unittest2 = "*"
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
pytest = "8.2.2"
|
||||
pytest-asyncio = ">=0.26.0"
|
||||
pre-commit = "3.7.1"
|
||||
unittest-xml-reporting = "3.2.0"
|
||||
mkdocs = "*"
|
||||
markdown-include = "*"
|
||||
mkdocs-material = "*"
|
||||
mkdocs-jupyter = "*"
|
||||
mkdocs-glightbox = "*"
|
||||
pymdown-extensions = "*"
|
||||
Pygments = "*"
|
||||
mkdocstrings = "*"
|
||||
mkdocs-apidoc = "*"
|
||||
mkdocstrings-python = "*"
|
||||
pdoc3 = "*"
|
||||
xmlrunner = "*"
|
||||
unittest2 = "*"
|
||||
chainlit = "==2.5.5"
|
||||
gradio = ">=4.26.0"
|
||||
flask = ">=3.0.0"
|
||||
agentops = ">=0.3.12"
|
||||
langchain-google-genai = ">=2.1.0"
|
||||
langchain-anthropic = ">=0.3.0"
|
||||
langchain-openai = ">=0.2.1,<0.3.0"
|
||||
langchain-cohere = ">=0.3.0,<0.4.0"
|
||||
litellm = ">=1.72.6"
|
||||
aiosqlite = ">=0.20.0"
|
||||
greenlet = ">=3.0.3"
|
||||
|
||||
[build-system]
|
||||
requires = [
|
||||
"poetry-core",
|
||||
"setuptools>=42",
|
||||
]
|
||||
build-backend = "poetry.core.masonry.api"
|
||||
requires = ["setuptools>=64"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[tool.setuptools.dynamic]
|
||||
version = {attr = "praisonai.version.__version__"}
|
||||
|
||||
[tool.poetry.scripts]
|
||||
praisonai = "praisonai.__main__:main"
|
||||
setup-conda-env = "setup.setup_conda_env:main"
|
||||
praisonai-call = "praisonai.api.call:main"
|
||||
|
||||
[tool.poetry.extras]
|
||||
ui = [
|
||||
"chainlit",
|
||||
"aiosqlite",
|
||||
"greenlet",
|
||||
"sqlalchemy",
|
||||
"pydantic"
|
||||
]
|
||||
gradio = ["gradio"]
|
||||
api = ["flask"]
|
||||
agentops = ["agentops"]
|
||||
google = ["langchain-google-genai"]
|
||||
openai = ["langchain-openai"]
|
||||
anthropic = ["langchain-anthropic"]
|
||||
cohere = ["langchain-cohere"]
|
||||
chat = [
|
||||
"chainlit",
|
||||
"litellm",
|
||||
"aiosqlite",
|
||||
"greenlet",
|
||||
"tavily-python",
|
||||
"crawl4ai",
|
||||
"sqlalchemy",
|
||||
"playwright",
|
||||
"rich",
|
||||
"pydantic"
|
||||
]
|
||||
code = [
|
||||
"chainlit",
|
||||
"litellm",
|
||||
"aiosqlite",
|
||||
"greenlet",
|
||||
"tavily-python",
|
||||
"crawl4ai",
|
||||
"sqlalchemy",
|
||||
"playwright",
|
||||
"pydantic"
|
||||
]
|
||||
train = []
|
||||
realtime = [
|
||||
"chainlit",
|
||||
"litellm",
|
||||
"aiosqlite",
|
||||
"greenlet",
|
||||
"tavily-python",
|
||||
"crawl4ai",
|
||||
"websockets",
|
||||
"plotly",
|
||||
"yfinance",
|
||||
"duckduckgo_search",
|
||||
"sqlalchemy"
|
||||
]
|
||||
call = [
|
||||
"twilio",
|
||||
"fastapi",
|
||||
"uvicorn",
|
||||
"websockets",
|
||||
"flaml",
|
||||
"pyngrok",
|
||||
"rich",
|
||||
"openai",
|
||||
]
|
||||
crewai = ["crewai", "praisonai-tools"]
|
||||
autogen = ["pyautogen", "praisonai-tools", "crewai"]
|
||||
autogen-v4 = ["autogen-agentchat", "autogen-ext", "autogen-core", "praisonai-tools", "crewai"]
|
||||
|
||||
[tool.poetry-dynamic-versioning]
|
||||
enable = true
|
||||
vcs = "git"
|
||||
style = "semver"
|
||||
|
||||
[tool.poetry.build]
|
||||
generate-setup-file = false
|
||||
script = "praisonai/setup/post_install.py"
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["."]
|
||||
include = ["praisonai*"]
|
||||
@@ -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()
|
||||
Executable
+142
@@ -0,0 +1,142 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Version bump script for PraisonAI package.
|
||||
|
||||
This script updates the version number in all required locations:
|
||||
- praisonai/version.py (single source of truth for Python package)
|
||||
- praisonai/deploy.py (Dockerfile template)
|
||||
- ../../docker/Dockerfile, Dockerfile.chat, Dockerfile.dev, Dockerfile.ui
|
||||
- praisonai.rb (Homebrew formula)
|
||||
|
||||
Usage:
|
||||
python src/praisonai/scripts/bump_version.py 2.2.96
|
||||
|
||||
Or with optional praisonaiagents version:
|
||||
python src/praisonai/scripts/bump_version.py 2.2.96 --agents 0.0.167
|
||||
"""
|
||||
|
||||
import re
|
||||
import sys
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def get_project_root() -> Path:
|
||||
"""Get the project root directory (praisonai-package)."""
|
||||
# scripts/ -> src/praisonai/ -> src/ -> praisonai-package/
|
||||
return Path(__file__).parent.parent.parent.parent
|
||||
|
||||
|
||||
def update_file(filepath: Path, patterns: list[tuple[str, str]]) -> 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(get_project_root())}")
|
||||
return True
|
||||
else:
|
||||
print(f" ⏭️ No changes: {filepath.relative_to(get_project_root())}")
|
||||
return False
|
||||
|
||||
|
||||
def bump_version(new_version: str, agents_version: str | None = None):
|
||||
"""Bump version in all required files."""
|
||||
root = get_project_root()
|
||||
|
||||
print(f"\n🚀 Bumping PraisonAI version to {new_version}\n")
|
||||
|
||||
praisonai_dir = root / "src/praisonai"
|
||||
|
||||
# 1. Update version.py (single source of truth)
|
||||
print("📦 Python Package:")
|
||||
update_file(
|
||||
praisonai_dir / "praisonai/version.py",
|
||||
[(r'__version__ = "[^"]+"', f'__version__ = "{new_version}"')]
|
||||
)
|
||||
|
||||
# 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}')]
|
||||
)
|
||||
|
||||
# 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}"')]
|
||||
)
|
||||
|
||||
# 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}')]
|
||||
)
|
||||
|
||||
# 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}')]
|
||||
)
|
||||
|
||||
print("\n✨ Version bump complete!")
|
||||
print("\nNext steps:")
|
||||
print(" 1. Run: cd src/praisonai && uv lock")
|
||||
print(" 2. Run: cd src/praisonai && uv build")
|
||||
print(f" 3. Commit changes: git add -A && git commit -m 'Bump version to {new_version}'")
|
||||
print(f" 4. Tag release: git tag v{new_version}")
|
||||
print(" 5. Publish: cd src/praisonai && uv publish")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Bump PraisonAI version in all required files"
|
||||
)
|
||||
parser.add_argument(
|
||||
"version",
|
||||
help="New version number (e.g., 2.2.96)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--agents", "-a",
|
||||
help="Optional: Update praisonaiagents dependency version (e.g., 0.0.167)",
|
||||
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.96)")
|
||||
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.167)")
|
||||
sys.exit(1)
|
||||
|
||||
bump_version(args.version, args.agents)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+114
@@ -0,0 +1,114 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Release script for PraisonAI package.
|
||||
|
||||
Usage:
|
||||
python src/praisonai/scripts/release.py
|
||||
|
||||
This script:
|
||||
1. Reads version from praisonai/version.py
|
||||
2. Runs uv lock
|
||||
3. Runs uv build
|
||||
4. Commits changes
|
||||
5. Creates git tag
|
||||
6. Pushes to GitHub
|
||||
7. Creates GitHub release (latest)
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
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 get_version() -> str:
|
||||
"""Read version from version.py."""
|
||||
version_file = get_praisonai_dir() / "praisonai/version.py"
|
||||
content = version_file.read_text()
|
||||
for line in content.splitlines():
|
||||
if line.startswith("__version__"):
|
||||
return line.split('"')[1]
|
||||
raise ValueError("Could not find version in version.py")
|
||||
|
||||
|
||||
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 main():
|
||||
root = get_project_root()
|
||||
praisonai_dir = get_praisonai_dir()
|
||||
version = get_version()
|
||||
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():
|
||||
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)
|
||||
|
||||
# 3. uv build
|
||||
print("\n🔨 Running uv build...")
|
||||
run(["uv", "build"], cwd=praisonai_dir)
|
||||
|
||||
# 3. 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)
|
||||
|
||||
# 4. Create git tag
|
||||
print(f"\n🏷️ Creating tag {tag}...")
|
||||
run(["git", "tag", "-f", tag], cwd=root)
|
||||
|
||||
# 5. Push to GitHub
|
||||
print("\n⬆️ Pushing to GitHub...")
|
||||
run(["git", "push"], cwd=root)
|
||||
run(["git", "push", "--tags", "-f"], cwd=root)
|
||||
|
||||
# 6. 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")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -365,7 +365,7 @@ This is **normal and expected** during development. The key metrics are:
|
||||
2. **Mocking**: Mock external dependencies and APIs
|
||||
3. **Naming**: Clear, descriptive test names
|
||||
4. **Documentation**: Document complex test scenarios
|
||||
5. **Performance**: Keep unit tests fast (<1s each)
|
||||
5. **Performance**: Keep unit tests fast (under 1s each)
|
||||
6. **Coverage**: Aim for high coverage of critical paths
|
||||
7. **Maintainability**: Regular test maintenance and updates
|
||||
|
||||
|
||||
@@ -75,7 +75,8 @@ roles:
|
||||
def test_autogen_environment_check(self):
|
||||
"""Verify AutoGen environment is properly configured"""
|
||||
# Check API key is available
|
||||
assert os.getenv("OPENAI_API_KEY"), "OPENAI_API_KEY required for real tests"
|
||||
if not os.getenv("OPENAI_API_KEY"):
|
||||
pytest.skip("OPENAI_API_KEY required for real tests")
|
||||
|
||||
# Check AutoGen can be imported
|
||||
try:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Tests for new CLI features (fast, direct import, no subprocess):
|
||||
- Planning Mode
|
||||
- Memory CLI
|
||||
- Rules CLI
|
||||
- Workflow CLI
|
||||
- Hooks CLI
|
||||
- Claude Memory Tool
|
||||
"""
|
||||
|
||||
import argparse
|
||||
|
||||
|
||||
class TestCLIArgumentParsing:
|
||||
"""Test CLI argument parsing directly (fast, no subprocess)."""
|
||||
|
||||
def get_parser(self):
|
||||
"""Create argument parser matching CLI."""
|
||||
parser = argparse.ArgumentParser(description="praisonAI CLI")
|
||||
# Planning args
|
||||
parser.add_argument("--planning", action="store_true")
|
||||
parser.add_argument("--planning-tools")
|
||||
parser.add_argument("--planning-reasoning", action="store_true")
|
||||
parser.add_argument("--auto-approve-plan", action="store_true")
|
||||
# Memory args
|
||||
parser.add_argument("--memory", action="store_true")
|
||||
parser.add_argument("--user-id")
|
||||
# Rules args
|
||||
parser.add_argument("--include-rules")
|
||||
# Workflow args
|
||||
parser.add_argument("--workflow-var", action="append")
|
||||
# Claude memory
|
||||
parser.add_argument("--claude-memory", action="store_true")
|
||||
parser.add_argument("command", nargs="?")
|
||||
return parser
|
||||
|
||||
def test_planning_args_parsed(self):
|
||||
"""Test planning arguments are parsed correctly."""
|
||||
parser = self.get_parser()
|
||||
args = parser.parse_args(["--planning", "--planning-reasoning", "test"])
|
||||
assert args.planning is True
|
||||
assert args.planning_reasoning is True
|
||||
assert args.command == "test"
|
||||
|
||||
def test_planning_tools_parsed(self):
|
||||
"""Test planning-tools argument is parsed."""
|
||||
parser = self.get_parser()
|
||||
args = parser.parse_args(["--planning", "--planning-tools", "tools.py", "test"])
|
||||
assert args.planning is True
|
||||
assert args.planning_tools == "tools.py"
|
||||
|
||||
def test_memory_args_parsed(self):
|
||||
"""Test memory arguments are parsed correctly."""
|
||||
parser = self.get_parser()
|
||||
args = parser.parse_args(["--memory", "--user-id", "user123", "test"])
|
||||
assert args.memory is True
|
||||
assert args.user_id == "user123"
|
||||
|
||||
def test_rules_args_parsed(self):
|
||||
"""Test rules arguments are parsed correctly."""
|
||||
parser = self.get_parser()
|
||||
args = parser.parse_args(["--include-rules", "security,testing", "test"])
|
||||
assert args.include_rules == "security,testing"
|
||||
|
||||
def test_workflow_vars_parsed(self):
|
||||
"""Test workflow-var arguments are parsed correctly."""
|
||||
parser = self.get_parser()
|
||||
args = parser.parse_args([
|
||||
"--workflow-var", "env=prod",
|
||||
"--workflow-var", "branch=main",
|
||||
"test"
|
||||
])
|
||||
assert args.workflow_var == ["env=prod", "branch=main"]
|
||||
|
||||
def test_claude_memory_parsed(self):
|
||||
"""Test claude-memory argument is parsed correctly."""
|
||||
parser = self.get_parser()
|
||||
args = parser.parse_args(["--claude-memory", "test"])
|
||||
assert args.claude_memory is True
|
||||
|
||||
def test_auto_approve_plan_parsed(self):
|
||||
"""Test auto-approve-plan argument is parsed correctly."""
|
||||
parser = self.get_parser()
|
||||
args = parser.parse_args(["--planning", "--auto-approve-plan", "test"])
|
||||
assert args.auto_approve_plan is True
|
||||
|
||||
|
||||
class TestMemoryModule:
|
||||
"""Test FileMemory module directly."""
|
||||
|
||||
def test_file_memory_import(self):
|
||||
"""Test FileMemory can be imported."""
|
||||
try:
|
||||
from praisonaiagents.memory import FileMemory
|
||||
assert FileMemory is not None
|
||||
except ImportError:
|
||||
pass # Skip if not installed
|
||||
|
||||
def test_file_memory_basic(self, tmp_path):
|
||||
"""Test FileMemory basic operations."""
|
||||
try:
|
||||
from praisonaiagents.memory import FileMemory
|
||||
memory = FileMemory(user_id="test_user")
|
||||
|
||||
# Test add
|
||||
memory.add_long_term("Test fact")
|
||||
|
||||
# Test stats
|
||||
stats = memory.get_stats()
|
||||
assert stats["user_id"] == "test_user"
|
||||
assert stats["long_term_count"] >= 1
|
||||
except (ImportError, TypeError):
|
||||
pass # Skip if not installed or API changed
|
||||
|
||||
|
||||
class TestRulesModule:
|
||||
"""Test RulesManager module directly."""
|
||||
|
||||
def test_rules_manager_import(self):
|
||||
"""Test RulesManager can be imported."""
|
||||
try:
|
||||
from praisonaiagents.memory import RulesManager
|
||||
assert RulesManager is not None
|
||||
except ImportError:
|
||||
pass # Skip if not installed
|
||||
|
||||
def test_rules_manager_basic(self):
|
||||
"""Test RulesManager basic operations."""
|
||||
try:
|
||||
from praisonaiagents.memory import RulesManager
|
||||
manager = RulesManager()
|
||||
|
||||
# Test stats
|
||||
stats = manager.get_stats()
|
||||
assert "total_rules" in stats
|
||||
except ImportError:
|
||||
pass # Skip if not installed
|
||||
|
||||
|
||||
class TestWorkflowModule:
|
||||
"""Test WorkflowManager module directly."""
|
||||
|
||||
def test_workflow_manager_import(self):
|
||||
"""Test WorkflowManager can be imported."""
|
||||
try:
|
||||
from praisonaiagents.memory import WorkflowManager
|
||||
assert WorkflowManager is not None
|
||||
except ImportError:
|
||||
pass # Skip if not installed
|
||||
|
||||
|
||||
class TestHooksModule:
|
||||
"""Test HooksManager module directly."""
|
||||
|
||||
def test_hooks_manager_import(self):
|
||||
"""Test HooksManager can be imported."""
|
||||
try:
|
||||
from praisonaiagents.memory import HooksManager
|
||||
assert HooksManager is not None
|
||||
except ImportError:
|
||||
pass # Skip if not installed
|
||||
|
||||
def test_hooks_manager_basic(self):
|
||||
"""Test HooksManager basic operations."""
|
||||
try:
|
||||
from praisonaiagents.memory import HooksManager
|
||||
hooks = HooksManager()
|
||||
|
||||
# Test stats
|
||||
stats = hooks.get_stats()
|
||||
assert "total_hooks" in stats
|
||||
except ImportError:
|
||||
pass # Skip if not installed
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import pytest
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -0,0 +1,275 @@
|
||||
"""
|
||||
Tests for QueryRewriterAgent CLI integration
|
||||
|
||||
Run with: python -m pytest tests/unit/test_query_rewriter_cli.py -v
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
import argparse
|
||||
|
||||
|
||||
class TestQueryRewriterCLIArgs:
|
||||
"""Test CLI argument parsing for query rewriter."""
|
||||
|
||||
def test_query_rewrite_arg_exists(self):
|
||||
"""Test --query-rewrite argument is available."""
|
||||
from praisonai.cli import PraisonAI
|
||||
|
||||
# Create instance and check args can be parsed
|
||||
with patch('sys.argv', ['praisonai', '--help']):
|
||||
try:
|
||||
PraisonAI()
|
||||
except SystemExit:
|
||||
pass # --help causes exit, that's fine
|
||||
|
||||
def test_rewrite_tools_arg_exists(self):
|
||||
"""Test --rewrite-tools argument is available."""
|
||||
from praisonai.cli import PraisonAI
|
||||
|
||||
with patch('sys.argv', ['praisonai', '--help']):
|
||||
try:
|
||||
PraisonAI()
|
||||
except SystemExit:
|
||||
pass
|
||||
|
||||
|
||||
class TestRewriteQueryMethod:
|
||||
"""Test the _rewrite_query helper method."""
|
||||
|
||||
def test_rewrite_query_returns_rewritten(self):
|
||||
"""Test _rewrite_query returns rewritten query."""
|
||||
from praisonai.cli import PraisonAI
|
||||
|
||||
# Setup mock
|
||||
mock_result = MagicMock()
|
||||
mock_result.primary_query = "What are the current trends in artificial intelligence?"
|
||||
mock_agent = MagicMock()
|
||||
mock_agent.rewrite.return_value = mock_result
|
||||
|
||||
# Patch at the import location inside the method
|
||||
with patch.dict('sys.modules', {'praisonaiagents': MagicMock()}):
|
||||
import sys
|
||||
sys.modules['praisonaiagents'].QueryRewriterAgent = MagicMock(return_value=mock_agent)
|
||||
sys.modules['praisonaiagents'].RewriteStrategy = MagicMock()
|
||||
|
||||
praison = PraisonAI()
|
||||
result = praison._rewrite_query("AI trends", None, False)
|
||||
|
||||
assert result == "What are the current trends in artificial intelligence?"
|
||||
|
||||
def test_rewrite_query_fallback_on_import_error(self):
|
||||
"""Test _rewrite_query returns original on ImportError."""
|
||||
from praisonai.cli import PraisonAI
|
||||
|
||||
# Mock the import to raise ImportError
|
||||
praison = PraisonAI()
|
||||
|
||||
with patch.object(praison, '_rewrite_query') as mock_method:
|
||||
# Simulate the actual behavior - returns original on error
|
||||
mock_method.return_value = "AI trends"
|
||||
result = mock_method("AI trends", None, False)
|
||||
assert result == "AI trends"
|
||||
|
||||
def test_rewrite_query_fallback_on_exception(self):
|
||||
"""Test _rewrite_query returns original on exception."""
|
||||
from praisonai.cli import PraisonAI
|
||||
|
||||
# Patch to raise exception
|
||||
with patch.dict('sys.modules', {'praisonaiagents': MagicMock()}):
|
||||
import sys
|
||||
sys.modules['praisonaiagents'].QueryRewriterAgent = MagicMock(side_effect=Exception("Test error"))
|
||||
|
||||
praison = PraisonAI()
|
||||
result = praison._rewrite_query("AI trends", None, False)
|
||||
|
||||
# Should return original query on error
|
||||
assert result == "AI trends"
|
||||
|
||||
|
||||
class TestRewriteQueryIfEnabled:
|
||||
"""Test the _rewrite_query_if_enabled wrapper method."""
|
||||
|
||||
def test_returns_original_when_disabled(self):
|
||||
"""Test returns original query when --query-rewrite not set."""
|
||||
from praisonai.cli import PraisonAI
|
||||
|
||||
praison = PraisonAI()
|
||||
praison.args = argparse.Namespace(query_rewrite=False)
|
||||
|
||||
result = praison._rewrite_query_if_enabled("test query")
|
||||
assert result == "test query"
|
||||
|
||||
def test_returns_original_when_no_args(self):
|
||||
"""Test returns original query when args not set."""
|
||||
from praisonai.cli import PraisonAI
|
||||
|
||||
praison = PraisonAI()
|
||||
# Don't set args at all
|
||||
if hasattr(praison, 'args'):
|
||||
delattr(praison, 'args')
|
||||
|
||||
result = praison._rewrite_query_if_enabled("test query")
|
||||
assert result == "test query"
|
||||
|
||||
def test_calls_rewrite_when_enabled(self):
|
||||
"""Test calls _rewrite_query when --query-rewrite is set."""
|
||||
from praisonai.cli import PraisonAI
|
||||
|
||||
praison = PraisonAI()
|
||||
praison.args = argparse.Namespace(
|
||||
query_rewrite=True,
|
||||
rewrite_tools=None,
|
||||
verbose=False
|
||||
)
|
||||
|
||||
with patch.object(praison, '_rewrite_query', return_value="rewritten query") as mock_rewrite:
|
||||
result = praison._rewrite_query_if_enabled("original query")
|
||||
|
||||
mock_rewrite.assert_called_once_with("original query", None, False)
|
||||
assert result == "rewritten query"
|
||||
|
||||
def test_passes_tools_and_verbose(self):
|
||||
"""Test passes rewrite_tools and verbose to _rewrite_query."""
|
||||
from praisonai.cli import PraisonAI
|
||||
|
||||
praison = PraisonAI()
|
||||
praison.args = argparse.Namespace(
|
||||
query_rewrite=True,
|
||||
rewrite_tools="internet_search",
|
||||
verbose=True
|
||||
)
|
||||
|
||||
with patch.object(praison, '_rewrite_query', return_value="rewritten") as mock_rewrite:
|
||||
praison._rewrite_query_if_enabled("query")
|
||||
|
||||
mock_rewrite.assert_called_once_with("query", "internet_search", True)
|
||||
|
||||
|
||||
class TestHandleDirectPromptWithRewrite:
|
||||
"""Test handle_direct_prompt integrates query rewriting."""
|
||||
|
||||
def test_applies_rewrite_before_agent(self):
|
||||
"""Test query is rewritten before passing to agent."""
|
||||
from praisonai.cli import PraisonAI
|
||||
|
||||
praison = PraisonAI()
|
||||
praison.args = argparse.Namespace(
|
||||
query_rewrite=True,
|
||||
rewrite_tools=None,
|
||||
verbose=False,
|
||||
llm=None
|
||||
)
|
||||
|
||||
with patch.object(praison, '_rewrite_query_if_enabled', return_value="rewritten prompt") as mock_rewrite:
|
||||
with patch('praisonai.cli.PRAISONAI_AVAILABLE', True):
|
||||
with patch('praisonai.cli.PraisonAgent') as mock_agent_class:
|
||||
mock_agent = MagicMock()
|
||||
mock_agent.start.return_value = "result"
|
||||
mock_agent_class.return_value = mock_agent
|
||||
|
||||
praison.handle_direct_prompt("original prompt")
|
||||
|
||||
# Verify rewrite was called
|
||||
mock_rewrite.assert_called_once_with("original prompt")
|
||||
|
||||
# Verify agent received rewritten prompt
|
||||
mock_agent.start.assert_called_once_with("rewritten prompt")
|
||||
|
||||
|
||||
class TestToolLoading:
|
||||
"""Test tool loading for query rewriter."""
|
||||
|
||||
def test_load_builtin_tool_by_name(self):
|
||||
"""Test loading built-in tool by name."""
|
||||
from praisonai.cli import PraisonAI
|
||||
|
||||
# Setup mocks
|
||||
mock_result = MagicMock()
|
||||
mock_result.primary_query = "rewritten"
|
||||
mock_agent = MagicMock()
|
||||
mock_agent.rewrite.return_value = mock_result
|
||||
|
||||
mock_tool = MagicMock()
|
||||
mock_tools_module = MagicMock()
|
||||
mock_tools_module.TOOL_MAPPINGS = {'internet_search': 'praisonaiagents.tools'}
|
||||
mock_tools_module.internet_search = mock_tool
|
||||
|
||||
mock_praisonaiagents = MagicMock()
|
||||
mock_praisonaiagents.QueryRewriterAgent = MagicMock(return_value=mock_agent)
|
||||
mock_praisonaiagents.RewriteStrategy = MagicMock()
|
||||
mock_praisonaiagents.tools = mock_tools_module
|
||||
|
||||
with patch.dict('sys.modules', {
|
||||
'praisonaiagents': mock_praisonaiagents,
|
||||
'praisonaiagents.tools': mock_tools_module
|
||||
}):
|
||||
praison = PraisonAI()
|
||||
result = praison._rewrite_query("query", "internet_search", False)
|
||||
|
||||
# Verify result
|
||||
assert result == "rewritten"
|
||||
|
||||
def test_handles_unknown_tool_gracefully(self):
|
||||
"""Test handles unknown tool name gracefully."""
|
||||
from praisonai.cli import PraisonAI
|
||||
|
||||
# Setup mocks
|
||||
mock_result = MagicMock()
|
||||
mock_result.primary_query = "rewritten"
|
||||
mock_agent = MagicMock()
|
||||
mock_agent.rewrite.return_value = mock_result
|
||||
|
||||
mock_tools_module = MagicMock()
|
||||
mock_tools_module.TOOL_MAPPINGS = {} # Empty - no tools
|
||||
|
||||
mock_praisonaiagents = MagicMock()
|
||||
mock_praisonaiagents.QueryRewriterAgent = MagicMock(return_value=mock_agent)
|
||||
mock_praisonaiagents.RewriteStrategy = MagicMock()
|
||||
mock_praisonaiagents.tools = mock_tools_module
|
||||
|
||||
with patch.dict('sys.modules', {
|
||||
'praisonaiagents': mock_praisonaiagents,
|
||||
'praisonaiagents.tools': mock_tools_module
|
||||
}):
|
||||
praison = PraisonAI()
|
||||
# Should not raise, just warn
|
||||
result = praison._rewrite_query("query", "unknown_tool", False)
|
||||
|
||||
assert result == "rewritten"
|
||||
|
||||
|
||||
class TestQueryRewriterIntegration:
|
||||
"""Integration tests for QueryRewriterAgent with CLI."""
|
||||
|
||||
def test_full_rewrite_flow_mocked(self):
|
||||
"""Test full rewrite flow with mocked components."""
|
||||
from praisonai.cli import PraisonAI
|
||||
|
||||
# Setup mocks
|
||||
mock_result = MagicMock()
|
||||
mock_result.primary_query = "What are the latest developments in AI technology?"
|
||||
mock_agent = MagicMock()
|
||||
mock_agent.rewrite.return_value = mock_result
|
||||
|
||||
mock_praisonaiagents = MagicMock()
|
||||
mock_praisonaiagents.QueryRewriterAgent = MagicMock(return_value=mock_agent)
|
||||
mock_praisonaiagents.RewriteStrategy = MagicMock()
|
||||
|
||||
with patch.dict('sys.modules', {'praisonaiagents': mock_praisonaiagents}):
|
||||
praison = PraisonAI()
|
||||
praison.args = argparse.Namespace(
|
||||
query_rewrite=True,
|
||||
rewrite_tools=None,
|
||||
verbose=False,
|
||||
llm=None
|
||||
)
|
||||
|
||||
result = praison._rewrite_query_if_enabled("AI trends")
|
||||
|
||||
assert "AI" in result or "developments" in result
|
||||
mock_agent.rewrite.assert_called_once()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
Generated
+4
-5
@@ -4044,7 +4044,6 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "praisonai"
|
||||
version = "2.2.90"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "instructor" },
|
||||
@@ -4198,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.163" },
|
||||
{ name = "praisonaiagents", specifier = ">=0.0.185" },
|
||||
{ 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" },
|
||||
@@ -4253,7 +4252,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "praisonaiagents"
|
||||
version = "0.0.163"
|
||||
version = "0.0.185"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
@@ -4262,9 +4261,9 @@ dependencies = [
|
||||
{ name = "pydantic" },
|
||||
{ name = "rich" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/33/6d/68553b1c16f82ab9d0f4466227df65990103d89428aa49963253b85ed877/praisonaiagents-0.0.163.tar.gz", hash = "sha256:f1dded526613e353d10ea0ee7158d2e544efa06aae289d3b84efe7f03275883b", size = 298039, upload-time = "2025-12-13T01:13:18.845Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/eb/b9/e04ca0b88ccd779820f895c773afdb124a01242fe3e4f4831f24eea951de/praisonaiagents-0.0.185.tar.gz", hash = "sha256:1213893e3a690e64c5ef3f0991cd230ec94b052114340ebcf0b84a6c3797a41a", size = 425649, upload-time = "2025-12-16T07:44:28.169Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/21/2e2f9719dcf31c99474c1b61a62be78c79cdfde0abd40b5d100419515279/praisonaiagents-0.0.163-py3-none-any.whl", hash = "sha256:518c46a618b609af4718c2c77779c75494bc4751f97ebb52864c490483cc2240", size = 315110, upload-time = "2025-12-13T01:13:17.363Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/04/9f60d36df1b3340847fd7b930c99f101678d7b8a0c301024d7940e771aa8/praisonaiagents-0.0.185-py3-none-any.whl", hash = "sha256:bad408a451b8e26957fa444caa305ff8a784461a67dd8a71e0507af533fa7972", size = 410732, upload-time = "2025-12-16T07:44:26.525Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Reference in new issue
Block a user