Compare commits

...
28 Commits
Author SHA1 Message Date
Mervin Praison b183ac5cbd Release v2.3.3 2025-12-15 14:55:30 +00:00
Mervin Praison 5f57e1d2f1 feat: add memory info display and improve memory integration with system prompts
- Add _display_memory_info() method to show memory stats in rich panel (long-term, short-term, entities, storage path)
- Disable system prompt caching when memory is enabled (dynamic context)
- Inject memory context into system prompt with "## Memory" section
- Display memory info on first chat when verbose=True
- Add integration tests for memory with OpenAI API (injection and persistence)
- Update README with zero
2025-12-15 14:52:09 +00:00
Mervin Praison 5114165944 feat: add zero-dependency file-based memory system for all agents
- Add FileMemory class with JSON-based persistent storage (no extra dependencies)
- Add _init_memory(), get_memory_context(), store_memory() methods to Agent class
- Support memory types: short_term, long_term, entity, episodic
- Add memory=True/False/"file"/"sqlite"/"chromadb"/"mem0" configuration options
- Add lazy import for Memory class to avoid dependency issues
- Update README with memory usage examples and provider comparison
2025-12-15 14:37:33 +00:00
Mervin Praison 389442ea04 feat: add Claude Memory Tool support for persistent cross-conversation memory
- Add claude_memory parameter to Agent and LLM classes
- Add ClaudeMemoryTool with file-based persistent memory in .praison/claude_memory/
- Add _supports_claude_memory(), _is_memory_tool_call(), _execute_memory_tool_call() methods
- Add automatic beta header injection for Anthropic models
- Add memory tool definition to completion params when enabled
- Add ClaudeMemoryTool to TOOL_MAPPINGS with class-level import
- Update
2025-12-15 13:48:35 +00:00
Mervin Praison 6d7cc9c3ba Release v2.3.2 2025-12-15 13:34:15 +00:00
Mervin Praison 690e4497cc feat: add --save support for direct prompts
- Save output to output/prompts/ folder with timestamped filename
- Works with all flags: --web-search, --expand-prompt, etc.

Usage:
  praisonai 'latest AI trends' --web-search --save
  praisonai 'write a poem' --expand-prompt --save
2025-12-15 13:33:28 +00:00
Mervin Praison 041f5f0b08 fix: add current date to PromptExpanderAgent for time-sensitive context
- Add datetime import and current_date to all expansion prompts
- Ensures expanded prompts reference the correct current date (e.g., December 15, 2025)
- Fixes issue where prompts defaulted to outdated year references
2025-12-15 13:26:13 +00:00
Mervin Praison bc0d9a6213 feat: add CLI flags for web-search, web-fetch, and prompt-caching
- Add --web-search flag for native web search (OpenAI, Gemini, Anthropic, xAI, Perplexity)
- Add --web-fetch flag for URL content retrieval (Anthropic only)
- Add --prompt-caching flag to reduce costs (OpenAI, Anthropic, Bedrock, Deepseek)
- Update README with CLI and programmatic usage examples

Usage:
  praisonai 'latest AI news' --web-search --llm openai/gpt-4o-search-preview
  praisonai 'summarize https://example.com' --web-fetch --llm anthropic/claude-sonnet-4-20250514
  praisonai 'analyze...' --prompt-caching --llm anthropic/claude-sonnet-4-20250514
2025-12-15 12:56:02 +00:00
Mervin Praison a4427fa135 Release v2.3.1 2025-12-15 12:45:22 +00:00
Mervin Praison 51e8c65c8c feat: refactor model capabilities to use LiteLLM helper functions
- Replace static model lists with LiteLLM's built-in capability checks (supports_response_schema, supports_function_calling, supports_parallel_function_calling, supports_web_search)
- Add supports_function_calling() and supports_parallel_function_calling() functions
- Keep static list for web_fetch (no LiteLLM helper available yet)
- Update documentation to reference LiteLLM as primary source
- Add Web Fetch feature to README features
2025-12-15 12:32:09 +00:00
Mervin Praison 285ed672c5 feat: add native web search and web fetch documentation to README
- Add Native Web Search section with supported providers (OpenAI, Anthropic, Gemini, xAI, Perplexity)
- Add Web Fetch section for Anthropic models with configuration options
- Add code examples for web_search and web_fetch parameters
- Document web_fetch options (max_uses, allowed_domains, citations)
2025-12-15 12:20:41 +00:00
Mervin Praison c03e51e337 feat: add native web fetch support for Anthropic Claude models
- Add web_fetch parameter to Agent and LLM classes
- Add model_capabilities.py with MODELS_SUPPORTING_WEB_FETCH list (Claude 3.5+, 4+)
- Add supports_web_fetch() function to check model capabilities
- Add web_fetch tool definition with configurable options (max_uses, allowed_domains, blocked_domains, citations, max_content_tokens)
- Add _model_supports_web_fetch() method to Agent class
- Add warning log for unsupported models when web_
2025-12-15 12:18:08 +00:00
Mervin Praison f3ba05f6aa feat: expand structured outputs support for OpenAI, Anthropic, and Gemini models
- Add comprehensive list of OpenAI models with structured outputs (gpt-4o-2024-08-06+, gpt-4.1+, gpt-5+, o-series)
- Add Anthropic Claude 4+ models with structured outputs support (Sonnet 4.5, Opus 4.1/4.5, Haiku 4.5)
- Add all Google Gemini models (2.5-pro, 2.5-flash, 2.0-flash, 1.5-pro/flash)
- Add auto-detection for newer model versions (gpt-4.1+, gpt-5+, o-series, Claude 4+, all Gemini)
- Add azure/ prefix support
2025-12-15 12:07:46 +00:00
Mervin Praison 9cea3edcd6 feat: add native web search support for OpenAI, Gemini, Anthropic, xAI, and Perplexity models
- Add web_search parameter to Agent and LLM classes
- Add model_capabilities.py with MODELS_SUPPORTING_WEB_SEARCH list
- Add supports_web_search() function to check model capabilities
- Add automatic DuckDuckGo fallback for unsupported models
- Add web_search_options to LiteLLM completion params
- Remove incompatible parameters (temperature, top_p, etc.) for web search models
- Update README with Native
2025-12-15 11:59:19 +00:00
Mervin Praison 02a3bd3597 chore: add uv cache clean before uv lock in release.py 2025-12-15 11:26:08 +00:00
Mervin Praison 128c77926d chore: add uv cache clean before uv lock in bump_and_release.py 2025-12-15 11:25:42 +00:00
Mervin Praison 3aa1b825e4 Release v2.3.0 2025-12-15 11:24:46 +00:00
Mervin Praison b4ee30d837 chore: bump praisonaiagents version to 0.0.170 2025-12-15 11:11:46 +00:00
Mervin Praison 825648aead docs: add PromptExpanderAgent to README 2025-12-15 11:11:04 +00:00
Mervin Praison 1532c31e72 feat: add PromptExpanderAgent for detailed prompt expansion
- Add PromptExpanderAgent class with expansion strategies (BASIC, DETAILED, STRUCTURED, CREATIVE, AUTO)
- Add ExpandStrategy enum and ExpandResult dataclass
- Add CLI flags --expand-prompt and --expand-tools
- Add 20 unit tests for PromptExpanderAgent
- Export from praisonaiagents package

Usage:
  praisonai 'write a movie script' --expand-prompt
  praisonai 'AI blog' --expand-prompt --expand-tools tools.py
2025-12-15 11:09:25 +00:00
Mervin Praison b313fefd86 fix: QueryRewriterAgent preserves task intent instead of converting to questions 2025-12-15 10:57:53 +00:00
Mervin Praison 435e1afb55 test: add OPENAI_API_KEY environment variable to comprehensive test workflow and verify its presence 2025-12-15 10:47:29 +00:00
Mervin Praison 4cb3c0565a test: add skipif decorator for OPENAI_API_KEY check in CrewAI environment test 2025-12-15 10:42:00 +00:00
Mervin Praison a7634b66dc Release v2.2.99 2025-12-15 10:36:34 +00:00
Mervin Praison 3f0eaccbcb Release v2.2.98 2025-12-15 10:28:42 +00:00
Mervin Praison 47713d933a test: convert test assertions to pytest format and add TaskOutput descriptions
- Replace return True/False with pytest assertions in test_context_agent.py
- Add pytest.mark.skip decorators for unimplemented ContextAgent methods
- Add missing description parameter to TaskOutput instances in test_validation_feedback.py
2025-12-15 10:25:23 +00:00
Mervin Praison c82bfccd1d docs: add custom tools documentation with decorator, BaseTool class, and pip installable package examples 2025-12-15 10:20:11 +00:00
Mervin Praison ef7d1264e2 feat: add plugin system with tool registry and BaseTool support for external tool packages 2025-12-15 10:10:06 +00:00
50 changed files with 8702 additions and 172 deletions

No files matched your search

+9 -4
View File
@@ -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: |
+2 -2
View File
@@ -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'
+2 -2
View File
@@ -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
+226
View File
@@ -55,6 +55,11 @@ PraisonAI is a production-ready Multi-AI Agents framework with self-reflection,
- 💯 100+ LLM Support
- 🔬 **Deep Research Agents** (OpenAI & Gemini)
- 🔄 **Query Rewriter Agent** (HyDE, Step-back, Multi-query)
- 🌐 **Native Web Search** (OpenAI, Gemini, Anthropic, xAI, Perplexity)
- 📥 **Web Fetch** (Retrieve full content from URLs - Anthropic)
- 💾 **Prompt Caching** (Reduce costs & latency - OpenAI, Anthropic, Bedrock, Deepseek)
- 🧠 **Claude Memory Tool** (Persistent cross-conversation memory - Anthropic Beta)
- 💾 **File-Based Memory** (Zero-dependency persistent memory for all agents)
## Using Python Code
@@ -171,6 +176,48 @@ result = agent.rewrite("What about cost?", chat_history=[...])
- **CONTEXTUAL**: Resolve references using conversation history
- **AUTO**: Automatically detect best strategy
### 5. Agent Memory (Zero Dependencies)
Enable persistent memory for agents - works out of the box without any extra packages.
```python
from praisonaiagents import Agent
# 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
)
# Store memories programmatically
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)
# Memory is automatically injected into conversations
result = agent.start("What do you know about me?")
# Agent will recall: "Your name is John and you prefer dark mode"
# Memory persists across sessions - create new agent with same user_id
agent2 = Agent(name="Assistant", memory=True, user_id="user123")
# agent2 automatically loads all previous memories!
```
**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
**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 |
## Using No Code
### Auto Mode:
@@ -643,6 +690,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.
@@ -660,6 +873,19 @@ uv pip install -r pyproject.toml --extra code
uv pip install -r pyproject.toml --extra "crewai,autogen"
```
### Bump and Release
```bash
# From project root - bumps version and releases in one command
python src/praisonai/scripts/bump_and_release.py 2.2.99
# With praisonaiagents dependency
python src/praisonai/scripts/bump_and_release.py 2.2.99 --agents 0.0.169
# Then publish
cd src/praisonai && uv publish
```
## Contributing
- Fork on GitHub: Use the "Fork" button on the repository page.
+1 -1
View File
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
flask \
"praisonai>=2.2.97" \
"praisonai>=2.3.3" \
"praisonai[api]" \
gunicorn \
markdown
+1 -1
View File
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=2.2.97" \
"praisonai>=2.3.3" \
"praisonai[chat]" \
"embedchain[github,youtube]"
+1 -1
View File
@@ -20,7 +20,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=2.2.97" \
"praisonai>=2.3.3" \
"praisonai[ui]" \
"praisonai[chat]" \
"praisonai[realtime]" \
+1 -1
View File
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=2.2.97" \
"praisonai>=2.3.3" \
"praisonai[ui]" \
"praisonai[crewai]"
+154
View File
@@ -347,6 +347,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"]
@@ -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",
]
@@ -31,9 +31,17 @@ from .agent.query_rewriter_agent import (
RewriteStrategy,
RewriteResult
)
from .agent.prompt_expander_agent import (
PromptExpanderAgent,
ExpandStrategy,
ExpandResult
)
from .agents.agents import PraisonAIAgents
from .task.task import Task
from .tools.tools import Tools
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
@@ -170,6 +178,17 @@ __all__ = [
'Agents',
'Tools',
'Task',
# Plugin system
'BaseTool',
'ToolResult',
'ToolValidationError',
'validate_tool',
'tool',
'FunctionTool',
'ToolRegistry',
'get_registry',
'register_tool',
'get_tool',
'TaskOutput',
'ReflectionOutput',
'AutoAgents',
@@ -214,7 +233,10 @@ __all__ = [
'Provider',
'QueryRewriterAgent',
'RewriteStrategy',
'RewriteResult'
'RewriteResult',
'PromptExpanderAgent',
'ExpandStrategy',
'ExpandResult'
]
# Add MCP to __all__ if available
@@ -20,6 +20,11 @@ from .query_rewriter_agent import (
RewriteStrategy,
RewriteResult
)
from .prompt_expander_agent import (
PromptExpanderAgent,
ExpandStrategy,
ExpandResult
)
__all__ = [
'Agent',
@@ -43,5 +48,8 @@ __all__ = [
'Provider',
'QueryRewriterAgent',
'RewriteStrategy',
'RewriteResult'
'RewriteResult',
'PromptExpanderAgent',
'ExpandStrategy',
'ExpandResult'
]
@@ -234,7 +234,11 @@ 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
):
"""Initialize an Agent instance.
@@ -400,7 +404,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 +443,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 +467,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 +483,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 +524,23 @@ Your Goal: {self.goal}
# Store user_id
self.user_id = user_id or "praison"
self.reasoning_steps = reasoning_steps
self.web_search = web_search
self.web_fetch = web_fetch
self.prompt_caching = prompt_caching
self.claude_memory = claude_memory
# 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 +606,212 @@ Your Goal: {self.goal}
self._agent_id = str(uuid.uuid4())
return self._agent_id
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_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 +1058,29 @@ 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 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 +1110,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 +1265,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 +1387,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 +1419,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()
@@ -0,0 +1,367 @@
"""
Prompt Expander Agent Module
This module provides the PromptExpanderAgent class for expanding short prompts
into detailed, comprehensive prompts for better task execution.
Unlike QueryRewriterAgent (which optimizes queries for search/retrieval),
PromptExpanderAgent focuses on enriching prompts for task execution.
Supported Expansion Strategies:
- **BASIC**: Simple expansion with clarity improvements
- **DETAILED**: Rich expansion with context, constraints, and examples
- **STRUCTURED**: Expansion with clear structure (task, format, requirements)
- **CREATIVE**: Expansion with creative flair and vivid language
- **AUTO**: Automatically selects the best strategy based on prompt analysis
Example:
from praisonaiagents import PromptExpanderAgent, ExpandStrategy
agent = PromptExpanderAgent(verbose=True)
# Basic expansion
result = agent.expand("write a movie script in 3 lines")
print(result.expanded_prompt)
# Detailed expansion
result = agent.expand("blog about AI", strategy=ExpandStrategy.DETAILED)
print(result.expanded_prompt)
# With tools for context gathering
from praisonaiagents.tools import internet_search
agent = PromptExpanderAgent(tools=[internet_search], verbose=True)
result = agent.expand("latest AI developments")
"""
import logging
from typing import List, Optional, Any, Dict
from dataclasses import dataclass, field
from enum import Enum
from datetime import datetime
class ExpandStrategy(Enum):
"""Enumeration of available prompt expansion strategies."""
BASIC = "basic"
DETAILED = "detailed"
STRUCTURED = "structured"
CREATIVE = "creative"
AUTO = "auto"
@dataclass
class ExpandResult:
"""Result of a prompt expansion operation."""
original_prompt: str
expanded_prompt: str
strategy_used: ExpandStrategy
metadata: Dict[str, Any] = field(default_factory=dict)
def __repr__(self):
return f"ExpandResult(strategy={self.strategy_used.value}, original_len={len(self.original_prompt)}, expanded_len={len(self.expanded_prompt)})"
class PromptExpanderAgent:
"""
Agent for expanding short prompts into detailed, comprehensive prompts.
This agent transforms brief task descriptions into rich, detailed prompts
that provide better context and guidance for task execution.
Key difference from QueryRewriterAgent:
- QueryRewriterAgent: Optimizes queries for search/retrieval (RAG)
- PromptExpanderAgent: Expands prompts for detailed task execution
Attributes:
name: Name of the agent
model: LLM model to use for expansion
verbose: Whether to print detailed logs
temperature: Temperature for LLM generation (higher = more creative)
tools: Optional tools for context gathering
Example:
agent = PromptExpanderAgent(model="gpt-4o-mini", verbose=True)
result = agent.expand("write a poem", strategy=ExpandStrategy.CREATIVE)
print(result.expanded_prompt)
"""
# Default prompts for each strategy
PROMPTS = {
"basic": """You are an expert at improving task prompts.
Current date: {current_date}
Given the following task prompt, expand it to be clearer and more actionable while preserving the original intent.
Guidelines:
- Keep it as a TASK/COMMAND (do NOT convert to a question)
- Add clarity without changing the core request
- Fix any ambiguity
- Keep it concise but complete
- Use the current date for any time-sensitive context
Original prompt: {prompt}
Expanded prompt:""",
"detailed": """You are an expert at creating comprehensive task prompts.
Current date: {current_date}
Given the following brief task prompt, expand it into a detailed, actionable prompt that provides rich context and clear guidance.
Guidelines:
- Keep it as a TASK/COMMAND (do NOT convert to a question)
- Add specific details about what to include
- Specify quality expectations
- Add relevant constraints or requirements
- Include format guidance if applicable
- Mention style or tone if relevant
- Use the current date for any time-sensitive context
Original prompt: {prompt}
Detailed expanded prompt:""",
"structured": """You are an expert at creating well-structured task prompts.
Current date: {current_date}
Given the following task prompt, expand it into a structured format with clear sections.
Guidelines:
- Keep it as a TASK/COMMAND (do NOT convert to a question)
- Use a clear structure:
* Task: What to do
* Format: Expected output format
* Requirements: Specific requirements
* Style: Tone or style guidance
* Constraints: Any limitations
- Be specific and actionable
- Use the current date for any time-sensitive context
Original prompt: {prompt}
Structured expanded prompt:""",
"creative": """You are an expert at crafting inspiring, creative task prompts.
Current date: {current_date}
Given the following task prompt, expand it with vivid language and creative direction while keeping it actionable.
Guidelines:
- Keep it as a TASK/COMMAND (do NOT convert to a question)
- Use evocative, inspiring language
- Paint a picture of the desired outcome
- Add creative direction and artistic guidance
- Make it exciting and engaging
- Preserve the core task intent
- Use the current date for any time-sensitive context
Original prompt: {prompt}
Creative expanded prompt:""",
"auto": """You are an expert at analyzing and expanding task prompts.
Current date: {current_date}
Analyze the following prompt and expand it appropriately based on its nature.
For short/vague prompts: Add significant detail and context
For task prompts: Preserve the action-oriented nature
For creative tasks: Add artistic direction
For technical tasks: Add precision and requirements
IMPORTANT: Keep it as a TASK/COMMAND. Never convert to a question.
Use the current date for any time-sensitive context.
Original prompt: {prompt}
{context}
Expanded prompt:"""
}
def __init__(
self,
name: str = "PromptExpanderAgent",
model: str = "gpt-4o-mini",
instructions: Optional[str] = None,
verbose: bool = False,
temperature: float = 0.7, # Higher for creativity
max_tokens: int = 1000,
tools: Optional[List[Any]] = None
):
"""
Initialize the PromptExpanderAgent.
Args:
name: Name of the agent
model: LLM model to use (default: gpt-4o-mini)
instructions: Custom instructions for the agent
verbose: Whether to print detailed logs
temperature: Temperature for LLM generation (default: 0.7 for creativity)
max_tokens: Maximum tokens for LLM response
tools: Optional list of tools for context gathering
"""
self.name = name
self.model = model
self.instructions = instructions
self.verbose = verbose
self.temperature = temperature
self.max_tokens = max_tokens
self.tools = tools or []
self._agent = None # Lazy initialized
# Setup logging
self.logger = logging.getLogger(__name__)
if self.verbose:
self.logger.setLevel(logging.DEBUG)
@property
def agent(self):
"""Lazy initialization of internal Agent."""
if self._agent is None:
from .agent import Agent
self._agent = Agent(
name=self.name,
role="Prompt Expansion Specialist",
goal="Expand brief prompts into detailed, actionable prompts for better task execution. Use available tools when needed to gather context.",
backstory="You are an expert at understanding user intent and transforming brief requests into comprehensive, detailed prompts.",
tools=self.tools,
llm=self.model,
verbose=self.verbose,
markdown=False
)
return self._agent
def _call_agent(self, prompt: str) -> str:
"""Call the internal Agent with the given prompt."""
try:
response = self.agent.chat(prompt)
return response if response else ""
except Exception as e:
self.logger.error(f"Agent call failed: {e}")
raise
def _is_short_prompt(self, prompt: str, threshold: int = 10) -> bool:
"""Check if prompt is considered short."""
return len(prompt.split()) < threshold
def _is_task_prompt(self, prompt: str) -> bool:
"""Check if prompt is a task/command (vs a question)."""
task_verbs = [
"write", "create", "generate", "build", "make", "design",
"develop", "implement", "compose", "draft", "produce",
"craft", "construct", "prepare", "draw", "paint", "code"
]
prompt_lower = prompt.lower().strip()
return any(prompt_lower.startswith(verb) for verb in task_verbs)
def _is_creative_task(self, prompt: str) -> bool:
"""Check if prompt is a creative task."""
creative_indicators = [
"poem", "story", "script", "song", "art", "creative",
"fiction", "novel", "essay", "blog", "article", "write"
]
prompt_lower = prompt.lower()
return any(ind in prompt_lower for ind in creative_indicators)
def _detect_strategy(self, prompt: str) -> ExpandStrategy:
"""Auto-detect the best expansion strategy for a prompt."""
# Very short prompts need detailed expansion
if self._is_short_prompt(prompt, threshold=5):
return ExpandStrategy.DETAILED
# Creative tasks benefit from creative expansion
if self._is_creative_task(prompt):
return ExpandStrategy.CREATIVE
# Task prompts get structured expansion
if self._is_task_prompt(prompt):
return ExpandStrategy.STRUCTURED
# Default to basic
return ExpandStrategy.BASIC
def expand(
self,
prompt: str,
strategy: ExpandStrategy = ExpandStrategy.AUTO,
context: Optional[str] = None
) -> ExpandResult:
"""
Expand a prompt using the specified strategy.
All operations go through an internal Agent which automatically handles
tool calling when tools are provided. The agent decides when to use tools
based on the prompt context.
Args:
prompt: The original user prompt
strategy: Expansion strategy to use
context: Additional context to help with expansion
Returns:
ExpandResult containing the expanded prompt
"""
if self.verbose:
self.logger.debug(f"Expanding prompt: '{prompt}' with strategy: {strategy.value}")
if self.tools:
print(f"[cyan]Agent has {len(self.tools)} tools available (will use if needed)...[/cyan]")
# Auto-detect strategy if needed
if strategy == ExpandStrategy.AUTO:
strategy = self._detect_strategy(prompt)
if self.verbose:
self.logger.debug(f"Auto-detected strategy: {strategy.value}")
# Build the expansion prompt
if strategy == ExpandStrategy.AUTO:
# Fallback if auto-detect returns AUTO
strategy = ExpandStrategy.DETAILED
# Get current date for time-sensitive context
current_date = datetime.now().strftime("%B %d, %Y")
expansion_prompt = self.PROMPTS[strategy.value].format(
prompt=prompt,
context=context or "",
current_date=current_date
)
# Call the agent
expanded = self._call_agent(expansion_prompt).strip()
# If expansion failed, return original
if not expanded:
expanded = prompt
return ExpandResult(
original_prompt=prompt,
expanded_prompt=expanded,
strategy_used=strategy,
metadata={
"original_length": len(prompt),
"expanded_length": len(expanded),
"has_context": bool(context),
"has_tools": bool(self.tools)
}
)
# Convenience methods for direct strategy access
def expand_basic(self, prompt: str, context: Optional[str] = None) -> ExpandResult:
"""Convenience method for basic expansion."""
return self.expand(prompt, strategy=ExpandStrategy.BASIC, context=context)
def expand_detailed(self, prompt: str, context: Optional[str] = None) -> ExpandResult:
"""Convenience method for detailed expansion."""
return self.expand(prompt, strategy=ExpandStrategy.DETAILED, context=context)
def expand_structured(self, prompt: str, context: Optional[str] = None) -> ExpandResult:
"""Convenience method for structured expansion."""
return self.expand(prompt, strategy=ExpandStrategy.STRUCTURED, context=context)
def expand_creative(self, prompt: str, context: Optional[str] = None) -> ExpandResult:
"""Convenience method for creative expansion."""
return self.expand(prompt, strategy=ExpandStrategy.CREATIVE, context=context)
@@ -110,20 +110,23 @@ class QueryRewriterAgent:
# Default prompts for each strategy
PROMPTS = {
"basic": """You are an expert at rewriting search queries for better retrieval.
"basic": """You are an expert at improving prompts and queries.
Given the following query, rewrite it to be clearer, more specific, and better suited for searching a knowledge base.
Given the following input, improve it while preserving its original intent and type.
Guidelines:
- If it's a TASK (e.g., "write X", "create Y", "generate Z"), keep it as a task - do NOT convert to a question
- If it's a QUESTION (e.g., "what is X?", "how does Y work?"), keep it as a question
- Expand abbreviations and acronyms
- Fix typos and grammatical errors
- Add relevant context if the query is too short
- Transform keyword searches into natural questions
- Keep the original intent intact
- Add relevant context if too short
- Keep the original intent and action intact
Query: {query}
IMPORTANT: If the input is a command/task to DO something, the output must also be a command/task to DO that thing.
Rewritten query:""",
Input: {query}
Improved version:""",
"hyde": """You are an expert at generating hypothetical documents.
@@ -198,21 +201,23 @@ Follow-up query: {query}
Standalone question:""",
"auto": """You are an expert at analyzing and rewriting queries.
"auto": """You are an expert at analyzing and improving prompts and queries.
Analyze the following query and determine the best rewriting strategy, then apply it.
Analyze the following input and improve it while preserving its original intent and type.
Query types to consider:
1. Short/keyword queries → Expand into full questions
2. Ambiguous queries → Clarify with context
3. Complex multi-part queries → Break into sub-queries
4. Follow-up queries → Resolve references from context
5. Well-formed queries → Minor optimization only
Input types to consider:
1. TASK/COMMAND (write, create, generate, build) → Keep as a task, make it clearer
2. QUESTION (what, how, why, when) → Keep as a question, make it more specific
3. Short/keyword inputs → Expand with context but preserve type
4. Complex multi-part inputs → Clarify structure
5. Well-formed inputs → Minor optimization only
Query: {query}
IMPORTANT: Never convert a task into a question. "Write X" should remain "Write X", not "How to write X?"
Input: {query}
{context}
Analysis and rewritten query:"""
Improved version:"""
}
def __init__(
+206 -1
View File
@@ -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,19 @@ This module provides memory management capabilities including:
- User memory for preferences/history
- Quality-based storage decisions
- Graph memory support via Mem0
Memory Providers:
- FileMemory: Zero-dependency JSON file-based storage (default)
- Memory: Full-featured with SQLite, ChromaDB, Mem0, MongoDB support
"""
from .memory import Memory
from .file_memory import FileMemory, create_memory
__all__ = ["Memory"]
# Lazy import for Memory to avoid dependency issues
def __getattr__(name):
if name == "Memory":
from .memory import Memory
return Memory
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
__all__ = ["FileMemory", "Memory", "create_memory"]
@@ -0,0 +1,857 @@
"""
File-based Memory System for PraisonAI Agents.
Zero-dependency memory implementation using JSON files.
Provides short-term, long-term, entity, and episodic memory
without requiring any external packages.
Storage Structure:
.praison/memory/{user_id}/
├── config.json # Memory configuration
├── short_term.json # Rolling buffer (recent context)
├── long_term.json # Persistent facts/knowledge
├── entities.json # Named entities
├── episodic/ # Date-based memories
│ └── {date}.json
└── summaries.json # LLM-generated summaries
"""
import json
import time
import fcntl
import hashlib
import logging
from pathlib import Path
from datetime import datetime, timedelta
from typing import Any, Dict, List, Optional
from dataclasses import dataclass, asdict, field
logger = logging.getLogger(__name__)
@dataclass
class MemoryItem:
"""A single memory item."""
id: str
content: str
metadata: Dict[str, Any] = field(default_factory=dict)
created_at: float = field(default_factory=time.time)
updated_at: float = field(default_factory=time.time)
access_count: int = 0
importance: float = 0.5 # 0.0 to 1.0
def to_dict(self) -> Dict[str, Any]:
return asdict(self)
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'MemoryItem':
return cls(**data)
@dataclass
class EntityItem:
"""An entity memory item (person, place, concept)."""
id: str
name: str
entity_type: str # person, place, organization, concept, etc.
attributes: Dict[str, Any] = field(default_factory=dict)
relationships: List[Dict[str, str]] = field(default_factory=list)
created_at: float = field(default_factory=time.time)
updated_at: float = field(default_factory=time.time)
def to_dict(self) -> Dict[str, Any]:
return asdict(self)
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'EntityItem':
return cls(**data)
class FileMemory:
"""
Zero-dependency file-based memory system.
Uses JSON files for persistence with support for:
- Short-term memory (rolling buffer)
- Long-term memory (persistent facts)
- Entity memory (people, places, concepts)
- Episodic memory (date-based interactions)
- Memory summarization (optional, requires LLM)
Example:
```python
memory = FileMemory(user_id="user123")
# Store short-term memory
memory.add_short_term("User prefers dark mode")
# Store long-term memory
memory.add_long_term("User's name is John", importance=0.9)
# Store entity
memory.add_entity("John", "person", {"role": "developer"})
# Search memories
results = memory.search("John")
# Get context for LLM
context = memory.get_context(query="What does John prefer?")
```
"""
DEFAULT_CONFIG = {
"short_term_limit": 100, # Max items in short-term memory
"long_term_limit": 1000, # Max items in long-term memory
"episodic_retention_days": 30, # Days to keep episodic memories
"enable_summarization": False, # Requires LLM
"summarization_threshold": 50, # Summarize after N items
"importance_threshold": 0.7, # Min importance for long-term
"auto_promote": True, # Auto-promote important short-term to long-term
}
def __init__(
self,
user_id: str = "default",
base_path: Optional[str] = None,
config: Optional[Dict[str, Any]] = None,
verbose: int = 0
):
"""
Initialize FileMemory.
Args:
user_id: Unique identifier for the user/session
base_path: Base directory for memory storage (default: .praison/memory)
config: Configuration overrides
verbose: Verbosity level (0=quiet, 1=info, 2+=debug)
"""
self.user_id = user_id
self.verbose = verbose
# Set up paths
if base_path:
self.base_path = Path(base_path)
else:
self.base_path = Path(".praison/memory")
self.user_path = self.base_path / user_id
self.episodic_path = self.user_path / "episodic"
# Create directories
self.user_path.mkdir(parents=True, exist_ok=True)
self.episodic_path.mkdir(parents=True, exist_ok=True)
# File paths
self.config_file = self.user_path / "config.json"
self.short_term_file = self.user_path / "short_term.json"
self.long_term_file = self.user_path / "long_term.json"
self.entities_file = self.user_path / "entities.json"
self.summaries_file = self.user_path / "summaries.json"
# Load or create config
self.config = self._load_config(config)
# Initialize memory stores
self._short_term: List[MemoryItem] = []
self._long_term: List[MemoryItem] = []
self._entities: Dict[str, EntityItem] = {}
self._summaries: List[Dict[str, Any]] = []
# Load existing data
self._load_all()
self._log(f"FileMemory initialized for user '{user_id}' at {self.user_path}")
def _log(self, msg: str, level: int = logging.INFO):
"""Log message if verbose."""
if self.verbose >= 1:
logger.log(level, msg)
def _generate_id(self, content: str) -> str:
"""Generate a unique ID for content."""
timestamp = str(time.time())
hash_input = f"{content}{timestamp}{self.user_id}"
return hashlib.sha256(hash_input.encode()).hexdigest()[:16]
# -------------------------------------------------------------------------
# File Operations
# -------------------------------------------------------------------------
def _read_json(self, filepath: Path, default: Any = None) -> Any:
"""Read JSON file with file locking."""
if not filepath.exists():
return default if default is not None else []
try:
with open(filepath, 'r', encoding='utf-8') as f:
fcntl.flock(f.fileno(), fcntl.LOCK_SH)
try:
data = json.load(f)
finally:
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
return data
except (json.JSONDecodeError, IOError) as e:
self._log(f"Error reading {filepath}: {e}", logging.WARNING)
return default if default is not None else []
def _write_json(self, filepath: Path, data: Any) -> bool:
"""Write JSON file with file locking."""
try:
with open(filepath, 'w', encoding='utf-8') as f:
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
try:
json.dump(data, f, indent=2, ensure_ascii=False)
finally:
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
return True
except IOError as e:
self._log(f"Error writing {filepath}: {e}", logging.ERROR)
return False
def _load_config(self, overrides: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""Load or create configuration."""
config = self.DEFAULT_CONFIG.copy()
# Load existing config
if self.config_file.exists():
saved_config = self._read_json(self.config_file, {})
config.update(saved_config)
# Apply overrides
if overrides:
config.update(overrides)
# Save config
self._write_json(self.config_file, config)
return config
def _load_all(self):
"""Load all memory data from files."""
# Load short-term
short_data = self._read_json(self.short_term_file, [])
self._short_term = [MemoryItem.from_dict(item) for item in short_data]
# Load long-term
long_data = self._read_json(self.long_term_file, [])
self._long_term = [MemoryItem.from_dict(item) for item in long_data]
# Load entities
entities_data = self._read_json(self.entities_file, {})
self._entities = {k: EntityItem.from_dict(v) for k, v in entities_data.items()}
# Load summaries
self._summaries = self._read_json(self.summaries_file, [])
self._log(f"Loaded {len(self._short_term)} short-term, {len(self._long_term)} long-term, {len(self._entities)} entities")
def _save_short_term(self):
"""Save short-term memory to file."""
data = [item.to_dict() for item in self._short_term]
self._write_json(self.short_term_file, data)
def _save_long_term(self):
"""Save long-term memory to file."""
data = [item.to_dict() for item in self._long_term]
self._write_json(self.long_term_file, data)
def _save_entities(self):
"""Save entities to file."""
data = {k: v.to_dict() for k, v in self._entities.items()}
self._write_json(self.entities_file, data)
def _save_summaries(self):
"""Save summaries to file."""
self._write_json(self.summaries_file, self._summaries)
# -------------------------------------------------------------------------
# Short-Term Memory
# -------------------------------------------------------------------------
def add_short_term(
self,
content: str,
metadata: Optional[Dict[str, Any]] = None,
importance: float = 0.5
) -> str:
"""
Add item to short-term memory.
Args:
content: The memory content
metadata: Optional metadata dict
importance: Importance score (0.0 to 1.0)
Returns:
The generated memory ID
"""
item = MemoryItem(
id=self._generate_id(content),
content=content,
metadata=metadata or {},
importance=importance
)
self._short_term.append(item)
# Enforce limit (rolling buffer)
limit = self.config["short_term_limit"]
if len(self._short_term) > limit:
# Before removing, check if should promote to long-term
if self.config["auto_promote"]:
self._auto_promote_to_long_term()
# Remove oldest items
self._short_term = self._short_term[-limit:]
self._save_short_term()
self._log(f"Added short-term memory: {content[:50]}...")
return item.id
def get_short_term(self, limit: Optional[int] = None) -> List[MemoryItem]:
"""Get short-term memories, most recent first."""
items = list(reversed(self._short_term))
if limit:
items = items[:limit]
return items
def _auto_promote_to_long_term(self):
"""Promote high-importance short-term memories to long-term."""
threshold = self.config["importance_threshold"]
promoted = []
for item in self._short_term:
if item.importance >= threshold:
# Check if not already in long-term
existing_ids = {m.id for m in self._long_term}
if item.id not in existing_ids:
self._long_term.append(item)
promoted.append(item.id)
if promoted:
self._save_long_term()
self._log(f"Auto-promoted {len(promoted)} items to long-term memory")
# -------------------------------------------------------------------------
# Long-Term Memory
# -------------------------------------------------------------------------
def add_long_term(
self,
content: str,
metadata: Optional[Dict[str, Any]] = None,
importance: float = 0.8
) -> str:
"""
Add item to long-term memory.
Args:
content: The memory content
metadata: Optional metadata dict
importance: Importance score (0.0 to 1.0)
Returns:
The generated memory ID
"""
item = MemoryItem(
id=self._generate_id(content),
content=content,
metadata=metadata or {},
importance=importance
)
self._long_term.append(item)
# Enforce limit
limit = self.config["long_term_limit"]
if len(self._long_term) > limit:
# Remove lowest importance items
self._long_term.sort(key=lambda x: x.importance, reverse=True)
self._long_term = self._long_term[:limit]
self._save_long_term()
self._log(f"Added long-term memory: {content[:50]}...")
return item.id
def get_long_term(self, limit: Optional[int] = None) -> List[MemoryItem]:
"""Get long-term memories, sorted by importance."""
items = sorted(self._long_term, key=lambda x: x.importance, reverse=True)
if limit:
items = items[:limit]
return items
# -------------------------------------------------------------------------
# Entity Memory
# -------------------------------------------------------------------------
def add_entity(
self,
name: str,
entity_type: str,
attributes: Optional[Dict[str, Any]] = None,
relationships: Optional[List[Dict[str, str]]] = None
) -> str:
"""
Add or update an entity.
Args:
name: Entity name
entity_type: Type (person, place, organization, concept)
attributes: Entity attributes
relationships: List of relationships [{type, target}]
Returns:
The entity ID
"""
entity_id = self._generate_id(f"{name}:{entity_type}")
# Check if entity exists
existing = self._find_entity_by_name(name, entity_type)
if existing:
# Update existing entity
existing.attributes.update(attributes or {})
if relationships:
existing.relationships.extend(relationships)
existing.updated_at = time.time()
entity_id = existing.id
else:
# Create new entity
entity = EntityItem(
id=entity_id,
name=name,
entity_type=entity_type,
attributes=attributes or {},
relationships=relationships or []
)
self._entities[entity_id] = entity
self._save_entities()
self._log(f"Added/updated entity: {name} ({entity_type})")
return entity_id
def get_entity(self, name: str, entity_type: Optional[str] = None) -> Optional[EntityItem]:
"""Get entity by name and optionally type."""
return self._find_entity_by_name(name, entity_type)
def get_all_entities(self, entity_type: Optional[str] = None) -> List[EntityItem]:
"""Get all entities, optionally filtered by type."""
entities = list(self._entities.values())
if entity_type:
entities = [e for e in entities if e.entity_type == entity_type]
return entities
def _find_entity_by_name(self, name: str, entity_type: Optional[str] = None) -> Optional[EntityItem]:
"""Find entity by name."""
name_lower = name.lower()
for entity in self._entities.values():
if entity.name.lower() == name_lower:
if entity_type is None or entity.entity_type == entity_type:
return entity
return None
# -------------------------------------------------------------------------
# Episodic Memory
# -------------------------------------------------------------------------
def add_episodic(
self,
content: str,
metadata: Optional[Dict[str, Any]] = None,
date: Optional[str] = None
) -> str:
"""
Add episodic memory (date-based).
Args:
content: The memory content
metadata: Optional metadata
date: Date string (YYYY-MM-DD), defaults to today
Returns:
The memory ID
"""
if date is None:
date = datetime.now().strftime("%Y-%m-%d")
episodic_file = self.episodic_path / f"{date}.json"
# Load existing episodic memories for this date
episodes = self._read_json(episodic_file, [])
# Create new item
item = MemoryItem(
id=self._generate_id(content),
content=content,
metadata=metadata or {}
)
episodes.append(item.to_dict())
self._write_json(episodic_file, episodes)
self._log(f"Added episodic memory for {date}: {content[:50]}...")
return item.id
def get_episodic(
self,
date: Optional[str] = None,
days_back: int = 7
) -> List[MemoryItem]:
"""
Get episodic memories.
Args:
date: Specific date (YYYY-MM-DD), or None for recent
days_back: Number of days to look back if date is None
Returns:
List of episodic memories
"""
if date:
episodic_file = self.episodic_path / f"{date}.json"
data = self._read_json(episodic_file, [])
return [MemoryItem.from_dict(item) for item in data]
# Get memories from last N days
all_episodes = []
for i in range(days_back):
check_date = (datetime.now() - timedelta(days=i)).strftime("%Y-%m-%d")
episodic_file = self.episodic_path / f"{check_date}.json"
if episodic_file.exists():
data = self._read_json(episodic_file, [])
all_episodes.extend([MemoryItem.from_dict(item) for item in data])
return all_episodes
def cleanup_episodic(self):
"""Remove old episodic memories based on retention policy."""
retention_days = self.config["episodic_retention_days"]
cutoff_date = datetime.now() - timedelta(days=retention_days)
removed = 0
for episodic_file in self.episodic_path.glob("*.json"):
try:
file_date = datetime.strptime(episodic_file.stem, "%Y-%m-%d")
if file_date < cutoff_date:
episodic_file.unlink()
removed += 1
except ValueError:
continue
if removed:
self._log(f"Cleaned up {removed} old episodic memory files")
return removed
# -------------------------------------------------------------------------
# Search & Retrieval
# -------------------------------------------------------------------------
def search(
self,
query: str,
memory_types: Optional[List[str]] = None,
limit: int = 10
) -> List[Dict[str, Any]]:
"""
Search across all memory types.
Args:
query: Search query
memory_types: List of types to search (short_term, long_term, entity, episodic)
limit: Maximum results
Returns:
List of matching memories with scores
"""
if memory_types is None:
memory_types = ["short_term", "long_term", "entity", "episodic"]
results = []
query_lower = query.lower()
query_words = set(query_lower.split())
def score_match(text: str) -> float:
"""Simple relevance scoring."""
text_lower = text.lower()
# Exact match
if query_lower in text_lower:
return 1.0
# Word overlap
text_words = set(text_lower.split())
overlap = len(query_words & text_words)
if overlap > 0:
return overlap / len(query_words) * 0.8
return 0.0
# Search short-term
if "short_term" in memory_types:
for item in self._short_term:
score = score_match(item.content)
if score > 0:
results.append({
"type": "short_term",
"item": item.to_dict(),
"score": score * item.importance
})
# Search long-term
if "long_term" in memory_types:
for item in self._long_term:
score = score_match(item.content)
if score > 0:
results.append({
"type": "long_term",
"item": item.to_dict(),
"score": score * item.importance
})
# Search entities
if "entity" in memory_types:
for entity in self._entities.values():
score = max(
score_match(entity.name),
score_match(str(entity.attributes))
)
if score > 0:
results.append({
"type": "entity",
"item": entity.to_dict(),
"score": score
})
# Search episodic (recent only)
if "episodic" in memory_types:
episodes = self.get_episodic(days_back=7)
for item in episodes:
score = score_match(item.content)
if score > 0:
results.append({
"type": "episodic",
"item": item.to_dict(),
"score": score
})
# Sort by score and limit
results.sort(key=lambda x: x["score"], reverse=True)
return results[:limit]
def get_context(
self,
query: Optional[str] = None,
include_short_term: bool = True,
include_long_term: bool = True,
include_entities: bool = True,
include_episodic: bool = False,
max_tokens: int = 2000
) -> str:
"""
Build context string for LLM from memories.
Args:
query: Optional query to focus context
include_short_term: Include short-term memories
include_long_term: Include long-term memories
include_entities: Include entity information
include_episodic: Include recent episodic memories
max_tokens: Approximate max tokens (chars / 4)
Returns:
Formatted context string
"""
context_parts = []
char_limit = max_tokens * 4 # Rough estimate
# If query provided, use search
if query:
memory_types = []
if include_short_term:
memory_types.append("short_term")
if include_long_term:
memory_types.append("long_term")
if include_entities:
memory_types.append("entity")
if include_episodic:
memory_types.append("episodic")
results = self.search(query, memory_types, limit=20)
if results:
context_parts.append("## Relevant Memories")
for r in results:
if r["type"] == "entity":
item = r["item"]
context_parts.append(f"- {item['name']} ({item['entity_type']}): {item['attributes']}")
else:
context_parts.append(f"- {r['item']['content']}")
else:
# No query - include recent/important memories
if include_long_term:
long_term = self.get_long_term(limit=10)
if long_term:
context_parts.append("## Important Facts")
for item in long_term:
context_parts.append(f"- {item.content}")
if include_entities:
entities = self.get_all_entities()
if entities:
context_parts.append("## Known Entities")
for entity in entities[:10]:
attrs = ", ".join(f"{k}={v}" for k, v in entity.attributes.items())
context_parts.append(f"- {entity.name} ({entity.entity_type}): {attrs}")
if include_short_term:
short_term = self.get_short_term(limit=10)
if short_term:
context_parts.append("## Recent Context")
for item in short_term:
context_parts.append(f"- {item.content}")
if include_episodic:
episodes = self.get_episodic(days_back=3)
if episodes:
context_parts.append("## Recent Interactions")
for item in episodes[:10]:
context_parts.append(f"- {item.content}")
# Join and truncate
context = "\n".join(context_parts)
if len(context) > char_limit:
context = context[:char_limit] + "\n... (truncated)"
return context
# -------------------------------------------------------------------------
# Summarization
# -------------------------------------------------------------------------
def add_summary(
self,
summary: str,
source_type: str,
source_count: int,
metadata: Optional[Dict[str, Any]] = None
):
"""
Add a summary (typically LLM-generated).
Args:
summary: The summary text
source_type: What was summarized (short_term, episodic, etc.)
source_count: Number of items summarized
metadata: Optional metadata
"""
self._summaries.append({
"id": self._generate_id(summary),
"summary": summary,
"source_type": source_type,
"source_count": source_count,
"metadata": metadata or {},
"created_at": time.time()
})
self._save_summaries()
self._log(f"Added summary for {source_count} {source_type} items")
def get_summaries(self, limit: int = 5) -> List[Dict[str, Any]]:
"""Get recent summaries."""
return list(reversed(self._summaries))[:limit]
# -------------------------------------------------------------------------
# Utility Methods
# -------------------------------------------------------------------------
def clear_short_term(self):
"""Clear all short-term memory."""
self._short_term = []
self._save_short_term()
self._log("Cleared short-term memory")
def clear_all(self):
"""Clear all memory for this user."""
self._short_term = []
self._long_term = []
self._entities = {}
self._summaries = []
self._save_short_term()
self._save_long_term()
self._save_entities()
self._save_summaries()
# Clear episodic
for f in self.episodic_path.glob("*.json"):
f.unlink()
self._log("Cleared all memory")
def get_stats(self) -> Dict[str, Any]:
"""Get memory statistics."""
episodic_count = sum(1 for _ in self.episodic_path.glob("*.json"))
return {
"user_id": self.user_id,
"short_term_count": len(self._short_term),
"long_term_count": len(self._long_term),
"entity_count": len(self._entities),
"episodic_days": episodic_count,
"summary_count": len(self._summaries),
"storage_path": str(self.user_path)
}
def export(self) -> Dict[str, Any]:
"""Export all memory data."""
return {
"user_id": self.user_id,
"config": self.config,
"short_term": [item.to_dict() for item in self._short_term],
"long_term": [item.to_dict() for item in self._long_term],
"entities": {k: v.to_dict() for k, v in self._entities.items()},
"summaries": self._summaries,
"exported_at": time.time()
}
def import_data(self, data: Dict[str, Any]):
"""Import memory data from export."""
if "short_term" in data:
self._short_term = [MemoryItem.from_dict(item) for item in data["short_term"]]
self._save_short_term()
if "long_term" in data:
self._long_term = [MemoryItem.from_dict(item) for item in data["long_term"]]
self._save_long_term()
if "entities" in data:
self._entities = {k: EntityItem.from_dict(v) for k, v in data["entities"].items()}
self._save_entities()
if "summaries" in data:
self._summaries = data["summaries"]
self._save_summaries()
self._log("Imported memory data")
# Convenience function for simple usage
def create_memory(
user_id: str = "default",
**kwargs
) -> FileMemory:
"""
Create a FileMemory instance.
Args:
user_id: User identifier
**kwargs: Additional configuration
Returns:
FileMemory instance
"""
return FileMemory(user_id=user_id, **kwargs)
@@ -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,9 @@ TOOL_MAPPINGS = {
'cot_save': ('.train.data.generatecot', 'GenerateCOT'), # Save single QA to file
'cot_upload_to_huggingface': ('.train.data.generatecot', 'GenerateCOT'), # Upload dataset to HuggingFace
'cot_tools': ('.train.data.generatecot', 'GenerateCOT'), # Full toolkit access
# Claude Memory Tool (Anthropic Beta)
'ClaudeMemoryTool': ('.claude_memory_tool', 'ClaudeMemoryTool'),
}
_instances = {} # Cache for class instances
@@ -184,6 +187,11 @@ def __getattr__(name: str) -> Any:
module_path, class_name = TOOL_MAPPINGS[name]
# Return class itself (not instance) for ClaudeMemoryTool
if name == 'ClaudeMemoryTool':
module = import_module(module_path, __package__)
return getattr(module, class_name)
if class_name is None:
# Direct function import
module = import_module(module_path, __package__)
@@ -0,0 +1,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,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)
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "praisonaiagents"
version = "0.0.168"
version = "0.0.178"
description = "Praison AI agents for completing complex tasks with Self Reflection Agents"
requires-python = ">=3.10"
authors = [
@@ -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,653 @@
"""
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)
@@ -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"])
@@ -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"])
+1 -1
View File
@@ -2422,7 +2422,7 @@ wheels = [
[[package]]
name = "praisonaiagents"
version = "0.0.168"
version = "0.0.178"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },
+923 -21
View File
@@ -1,34 +1,936 @@
# PraisonAI Package
<p align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/logo/dark.png" />
<source media="(prefers-color-scheme: light)" srcset="docs/logo/light.png" />
<img alt="PraisonAI Logo" src="docs/logo/light.png" />
</picture>
</p>
## Development
<p align="center">
<a href="https://github.com/MervinPraison/PraisonAI"><img src="https://static.pepy.tech/badge/PraisonAI" alt="Total Downloads" /></a>
<a href="https://github.com/MervinPraison/PraisonAI"><img src="https://img.shields.io/github/v/release/MervinPraison/PraisonAI" alt="Latest Stable Version" /></a>
<a href="https://github.com/MervinPraison/PraisonAI"><img src="https://img.shields.io/badge/License-MIT-yellow.svg" alt="License" /></a>
</p>
<div align="center">
# Praison AI
<a href="https://trendshift.io/repositories/9130" target="_blank"><img src="https://trendshift.io/api/badge/repositories/9130" alt="MervinPraison%2FPraisonAI | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
</div>
PraisonAI is a production-ready Multi-AI Agents framework with self-reflection, designed to create AI Agents to automate and solve problems ranging from simple tasks to complex challenges. By integrating PraisonAI Agents, AG2 (Formerly AutoGen), and CrewAI into a low-code solution, it streamlines the building and management of multi-agent LLM systems, emphasising simplicity, customisation, and effective human-agent collaboration.
<div align="center">
<a href="https://docs.praison.ai">
<p align="center">
<img src="https://img.shields.io/badge/📚_Documentation-Visit_docs.praison.ai-blue?style=for-the-badge&logo=bookstack&logoColor=white" alt="Documentation" />
</p>
</a>
</div>
## Key Features
- 🤖 Automated AI Agents Creation
- 🔄 Self Reflection AI Agents
- 🧠 Reasoning AI Agents
- 👁️ Multi Modal AI Agents
- 🤝 Multi Agent Collaboration
- 🎭 AI Agent Workflow
- 📚 Add Custom Knowledge
- 🧠 Agents with Short and Long Term Memory
- 📄 Chat with PDF Agents
- 💻 Code Interpreter Agents
- 📚 RAG Agents
- 🤔 Async & Parallel Processing
- 🔄 Auto Agents
- 🔢 Math Agents
- 🎯 Structured Output Agents
- 🔗 LangChain Integrated Agents
- 📞 Callback Agents
- 🤏 Mini AI Agents
- 🛠️ 100+ Custom Tools
- 📄 YAML Configuration
- 💯 100+ LLM Support
- 🔬 **Deep Research Agents** (OpenAI & Gemini)
- 🔄 **Query Rewriter Agent** (HyDE, Step-back, Multi-query)
- 🌐 **Native Web Search** (OpenAI, Gemini, Anthropic, xAI, Perplexity)
- 📥 **Web Fetch** (Retrieve full content from URLs - Anthropic)
- 💾 **Prompt Caching** (Reduce costs & latency - OpenAI, Anthropic, Bedrock, Deepseek)
- 🧠 **Claude Memory Tool** (Persistent cross-conversation memory - Anthropic Beta)
- 💾 **File-Based Memory** (Zero-dependency persistent memory for all agents)
## Using Python Code
Light weight package dedicated for coding:
```bash
# Install
uv sync
# Build
uv build
# Publish
uv publish
pip install praisonaiagents
```
## Version Bump
```bash
export OPENAI_API_KEY=xxxxxxxxxxxxxxxxxxxxxx
```
### 1. Single Agent
Create app.py file and add the code below:
```python
from praisonaiagents import Agent
agent = Agent(instructions="Your are a helpful AI assistant")
agent.start("Write a movie script about a robot in Mars")
```
Run:
```bash
python app.py
```
### 2. Multi Agents
Create app.py file and add the code below:
```python
from praisonaiagents import Agent, PraisonAIAgents
research_agent = Agent(instructions="Research about AI")
summarise_agent = Agent(instructions="Summarise research agent's findings")
agents = PraisonAIAgents(agents=[research_agent, summarise_agent])
agents.start()
```
Run:
```bash
python app.py
```
### 3. Deep Research Agent
Automated research with real-time streaming, web search, and citations using OpenAI or Gemini Deep Research APIs.
```python
from praisonaiagents import DeepResearchAgent
# OpenAI Deep Research
agent = DeepResearchAgent(
model="o4-mini-deep-research", # or "o3-deep-research"
verbose=True
)
result = agent.research("What are the latest AI trends in 2025?")
print(result.report)
print(f"Citations: {len(result.citations)}")
```
```python
# Gemini Deep Research
from praisonaiagents import DeepResearchAgent
agent = DeepResearchAgent(
model="deep-research-pro", # Auto-detected as Gemini
verbose=True
)
result = agent.research("Research quantum computing advances")
print(result.report)
```
**Features:**
- 🔍 Multi-provider support (OpenAI, Gemini, LiteLLM)
- 📡 Real-time streaming with reasoning summaries
- 📚 Structured citations with URLs
- 🛠️ Built-in tools: web search, code interpreter, MCP, file search
- 🔄 Automatic provider detection from model name
### 4. 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
### 5. Agent Memory (Zero Dependencies)
Enable persistent memory for agents - works out of the box without any extra packages.
```python
from praisonaiagents import Agent
# 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
)
# Store memories programmatically
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)
# Memory is automatically injected into conversations
result = agent.start("What do you know about me?")
# Agent will recall: "Your name is John and you prefer dark mode"
# Memory persists across sessions - create new agent with same user_id
agent2 = Agent(name="Assistant", memory=True, user_id="user123")
# agent2 automatically loads all previous memories!
```
**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
**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 |
## Using No Code
### Auto Mode:
```bash
pip install praisonai
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)
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"
```
## Using JavaScript Code
```bash
# From project root
python src/praisonai/scripts/bump_version.py 2.2.96
npm install praisonai
export OPENAI_API_KEY=xxxxxxxxxxxxxxxxxxxxxx
```
```javascript
const { Agent } = require('praisonai');
const agent = new Agent({ instructions: 'You are a helpful AI assistant' });
agent.start('Write a movie script about a robot in Mars');
```
![PraisonAI CLI Demo](docs/demo/praisonai-cli-demo.gif)
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=MervinPraison/PraisonAI&type=Date)](https://docs.praison.ai)
## AI Agents Flow
```mermaid
graph LR
%% Define the main flow
Start([▶ Start]) --> Agent1
Agent1 --> Process[⚙ Process]
Process --> Agent2
Agent2 --> Output([✓ Output])
Process -.-> Agent1
%% Define subgraphs for agents and their tasks
subgraph Agent1[ ]
Task1[📋 Task]
AgentIcon1[🤖 AI Agent]
Tools1[🔧 Tools]
Task1 --- AgentIcon1
AgentIcon1 --- Tools1
end
subgraph Agent2[ ]
Task2[📋 Task]
AgentIcon2[🤖 AI Agent]
Tools2[🔧 Tools]
Task2 --- AgentIcon2
AgentIcon2 --- Tools2
end
classDef input fill:#8B0000,stroke:#7C90A0,color:#fff
classDef process fill:#189AB4,stroke:#7C90A0,color:#fff
classDef tools fill:#2E8B57,stroke:#7C90A0,color:#fff
classDef transparent fill:none,stroke:none
class Start,Output,Task1,Task2 input
class Process,AgentIcon1,AgentIcon2 process
class Tools1,Tools2 tools
class Agent1,Agent2 transparent
```
## AI Agents with Tools
Create AI agents that can use tools to interact with external systems and perform actions.
```mermaid
flowchart TB
subgraph Tools
direction TB
T3[Internet Search]
T1[Code Execution]
T2[Formatting]
end
Input[Input] ---> Agents
subgraph Agents
direction LR
A1[Agent 1]
A2[Agent 2]
A3[Agent 3]
end
Agents ---> Output[Output]
T3 --> A1
T1 --> A2
T2 --> A3
style Tools fill:#189AB4,color:#fff
style Agents fill:#8B0000,color:#fff
style Input fill:#8B0000,color:#fff
style Output fill:#8B0000,color:#fff
```
## AI Agents with Memory
Create AI agents with memory capabilities for maintaining context and information across tasks.
```mermaid
flowchart TB
subgraph Memory
direction TB
STM[Short Term]
LTM[Long Term]
end
subgraph Store
direction TB
DB[(Vector DB)]
end
Input[Input] ---> Agents
subgraph Agents
direction LR
A1[Agent 1]
A2[Agent 2]
A3[Agent 3]
end
Agents ---> Output[Output]
Memory <--> Store
Store <--> A1
Store <--> A2
Store <--> A3
style Memory fill:#189AB4,color:#fff
style Store fill:#2E8B57,color:#fff
style Agents fill:#8B0000,color:#fff
style Input fill:#8B0000,color:#fff
style Output fill:#8B0000,color:#fff
```
## AI Agents with Different Processes
### Sequential Process
The simplest form of task execution where tasks are performed one after another.
```mermaid
graph LR
Input[Input] --> A1
subgraph Agents
direction LR
A1[Agent 1] --> A2[Agent 2] --> A3[Agent 3]
end
A3 --> Output[Output]
classDef input fill:#8B0000,stroke:#7C90A0,color:#fff
classDef process fill:#189AB4,stroke:#7C90A0,color:#fff
classDef transparent fill:none,stroke:none
class Input,Output input
class A1,A2,A3 process
class Agents transparent
```
### Hierarchical Process
Uses a manager agent to coordinate task execution and agent assignments.
```mermaid
graph TB
Input[Input] --> Manager
subgraph Agents
Manager[Manager Agent]
subgraph Workers
direction LR
W1[Worker 1]
W2[Worker 2]
W3[Worker 3]
end
Manager --> W1
Manager --> W2
Manager --> W3
end
W1 --> Manager
W2 --> Manager
W3 --> Manager
Manager --> Output[Output]
classDef input fill:#8B0000,stroke:#7C90A0,color:#fff
classDef process fill:#189AB4,stroke:#7C90A0,color:#fff
classDef transparent fill:none,stroke:none
class Input,Output input
class Manager,W1,W2,W3 process
class Agents,Workers transparent
```
### Workflow Process
Advanced process type supporting complex task relationships and conditional execution.
```mermaid
graph LR
Input[Input] --> Start
subgraph Workflow
direction LR
Start[Start] --> C1{Condition}
C1 --> |Yes| A1[Agent 1]
C1 --> |No| A2[Agent 2]
A1 --> Join
A2 --> Join
Join --> A3[Agent 3]
end
A3 --> Output[Output]
classDef input fill:#8B0000,stroke:#7C90A0,color:#fff
classDef process fill:#189AB4,stroke:#7C90A0,color:#fff
classDef decision fill:#2E8B57,stroke:#7C90A0,color:#fff
classDef transparent fill:none,stroke:none
class Input,Output input
class Start,A1,A2,A3,Join process
class C1 decision
class Workflow transparent
```
#### Agentic Routing Workflow
Create AI agents that can dynamically route tasks to specialized LLM instances.
```mermaid
flowchart LR
In[In] --> Router[LLM Call Router]
Router --> LLM1[LLM Call 1]
Router --> LLM2[LLM Call 2]
Router --> LLM3[LLM Call 3]
LLM1 --> Out[Out]
LLM2 --> Out
LLM3 --> Out
style In fill:#8B0000,color:#fff
style Router fill:#2E8B57,color:#fff
style LLM1 fill:#2E8B57,color:#fff
style LLM2 fill:#2E8B57,color:#fff
style LLM3 fill:#2E8B57,color:#fff
style Out fill:#8B0000,color:#fff
```
#### Agentic Orchestrator Worker
Create AI agents that orchestrate and distribute tasks among specialized workers.
```mermaid
flowchart LR
In[In] --> Router[LLM Call Router]
Router --> LLM1[LLM Call 1]
Router --> LLM2[LLM Call 2]
Router --> LLM3[LLM Call 3]
LLM1 --> Synthesizer[Synthesizer]
LLM2 --> Synthesizer
LLM3 --> Synthesizer
Synthesizer --> Out[Out]
style In fill:#8B0000,color:#fff
style Router fill:#2E8B57,color:#fff
style LLM1 fill:#2E8B57,color:#fff
style LLM2 fill:#2E8B57,color:#fff
style LLM3 fill:#2E8B57,color:#fff
style Synthesizer fill:#2E8B57,color:#fff
style Out fill:#8B0000,color:#fff
```
#### Agentic Autonomous Workflow
Create AI agents that can autonomously monitor, act, and adapt based on environment feedback.
```mermaid
flowchart LR
Human[Human] <--> LLM[LLM Call]
LLM -->|ACTION| Environment[Environment]
Environment -->|FEEDBACK| LLM
LLM --> Stop[Stop]
style Human fill:#8B0000,color:#fff
style LLM fill:#2E8B57,color:#fff
style Environment fill:#8B0000,color:#fff
style Stop fill:#333,color:#fff
```
#### Agentic Parallelization
Create AI agents that can execute tasks in parallel for improved performance.
```mermaid
flowchart LR
In[In] --> LLM2[LLM Call 2]
In --> LLM1[LLM Call 1]
In --> LLM3[LLM Call 3]
LLM1 --> Aggregator[Aggregator]
LLM2 --> Aggregator
LLM3 --> Aggregator
Aggregator --> Out[Out]
style In fill:#8B0000,color:#fff
style LLM1 fill:#2E8B57,color:#fff
style LLM2 fill:#2E8B57,color:#fff
style LLM3 fill:#2E8B57,color:#fff
style Aggregator fill:#fff,color:#000
style Out fill:#8B0000,color:#fff
```
#### Agentic Prompt Chaining
Create AI agents with sequential prompt chaining for complex workflows.
```mermaid
flowchart LR
In[In] --> LLM1[LLM Call 1] --> Gate{Gate}
Gate -->|Pass| LLM2[LLM Call 2] -->|Output 2| LLM3[LLM Call 3] --> Out[Out]
Gate -->|Fail| Exit[Exit]
style In fill:#8B0000,color:#fff
style LLM1 fill:#2E8B57,color:#fff
style LLM2 fill:#2E8B57,color:#fff
style LLM3 fill:#2E8B57,color:#fff
style Out fill:#8B0000,color:#fff
style Exit fill:#8B0000,color:#fff
```
#### Agentic Evaluator Optimizer
Create AI agents that can generate and optimize solutions through iterative feedback.
```mermaid
flowchart LR
In[In] --> Generator[LLM Call Generator]
Generator -->|SOLUTION| Evaluator[LLM Call Evaluator] -->|ACCEPTED| Out[Out]
Evaluator -->|REJECTED + FEEDBACK| Generator
style In fill:#8B0000,color:#fff
style Generator fill:#2E8B57,color:#fff
style Evaluator fill:#2E8B57,color:#fff
style Out fill:#8B0000,color:#fff
```
#### Repetitive Agents
Create AI agents that can efficiently handle repetitive tasks through automated loops.
```mermaid
flowchart LR
In[Input] --> LoopAgent[("Looping Agent")]
LoopAgent --> Task[Task]
Task --> |Next iteration| LoopAgent
Task --> |Done| Out[Output]
style In fill:#8B0000,color:#fff
style LoopAgent fill:#2E8B57,color:#fff,shape:circle
style Task fill:#2E8B57,color:#fff
style Out fill:#8B0000,color:#fff
```
## Adding Models
<div align="center">
<a href="https://docs.praison.ai/models">
<p align="center">
<img src="https://img.shields.io/badge/%F0%9F%93%9A_Models-Visit_docs.praison.ai-blue?style=for-the-badge&logo=bookstack&logoColor=white" alt="Models" />
</p>
</a>
</div>
## Ollama Integration
```bash
export OPENAI_BASE_URL=http://localhost:11434/v1
```
## Groq Integration
Replace xxxx with Groq API KEY:
```bash
export OPENAI_API_KEY=xxxxxxxxxxx
export OPENAI_BASE_URL=https://api.groq.com/openai/v1
```
## No Code Options
## Agents Playbook
### Simple Playbook Example
Create `agents.yaml` file and add the code below:
```yaml
framework: praisonai
topic: Artificial Intelligence
roles:
screenwriter:
backstory: "Skilled in crafting scripts with engaging dialogue about {topic}."
goal: Create scripts from concepts.
role: Screenwriter
tasks:
scriptwriting_task:
description: "Develop scripts with compelling characters and dialogue about {topic}."
expected_output: "Complete script ready for production."
```
*To run the playbook:*
```bash
praisonai agents.yaml
```
## Use 100+ Models
- https://docs.praison.ai/models/
<div align="center">
<a href="https://docs.praison.ai">
<p align="center">
<img src="https://img.shields.io/badge/📚_Documentation-Visit_docs.praison.ai-blue?style=for-the-badge&logo=bookstack&logoColor=white" alt="Documentation" />
</p>
</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.
### Using uv
```bash
# Install uv if you haven't already
pip install uv
# Install from requirements
uv pip install -r pyproject.toml
# Install with extras
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_version.py 2.2.96 --agents 0.0.167
```
## Release
```bash
# From project root (runs uv lock, build, git tag, gh release)
python src/praisonai/scripts/release.py
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.
- Clone your fork: `git clone https://github.com/yourusername/praisonAI.git`
- Create a branch: `git checkout -b new-feature`
- Make changes and commit: `git commit -am "Add some feature"`
- Push to your fork: `git push origin new-feature`
- Submit a pull request via GitHub's web interface.
- Await feedback from project maintainers.
## Other Features
- 🔄 Use CrewAI or AG2 (Formerly AutoGen) Framework
- 💻 Chat with ENTIRE Codebase
- 🎨 Interactive UIs
- 📄 YAML-based Configuration
- 🛠️ Custom Tool Integration
- 🔍 Internet Search Capability (using Crawl4AI and Tavily)
- 🖼️ Vision Language Model (VLM) Support
- 🎙️ Real-time Voice Interaction
## Video Tutorials
| Topic | Video |
|-------|--------|
| AI Agents with Self Reflection | [![Self Reflection](https://img.youtube.com/vi/vLXobEN2Vc8/0.jpg)](https://www.youtube.com/watch?v=vLXobEN2Vc8) |
| Reasoning Data Generating Agent | [![Reasoning Data](https://img.youtube.com/vi/fUT332Y2zA8/0.jpg)](https://www.youtube.com/watch?v=fUT332Y2zA8) |
| AI Agents with Reasoning | [![Reasoning](https://img.youtube.com/vi/KNDVWGN3TpM/0.jpg)](https://www.youtube.com/watch?v=KNDVWGN3TpM) |
| Multimodal AI Agents | [![Multimodal](https://img.youtube.com/vi/hjAWmUT1qqY/0.jpg)](https://www.youtube.com/watch?v=hjAWmUT1qqY) |
| AI Agents Workflow | [![Workflow](https://img.youtube.com/vi/yWTH44QPl2A/0.jpg)](https://www.youtube.com/watch?v=yWTH44QPl2A) |
| Async AI Agents | [![Async](https://img.youtube.com/vi/VhVQfgo00LE/0.jpg)](https://www.youtube.com/watch?v=VhVQfgo00LE) |
| Mini AI Agents | [![Mini](https://img.youtube.com/vi/OkvYp5aAGSg/0.jpg)](https://www.youtube.com/watch?v=OkvYp5aAGSg) |
| AI Agents with Memory | [![Memory](https://img.youtube.com/vi/1hVfVxvPnnQ/0.jpg)](https://www.youtube.com/watch?v=1hVfVxvPnnQ) |
| Repetitive Agents | [![Repetitive](https://img.youtube.com/vi/dAYGxsjDOPg/0.jpg)](https://www.youtube.com/watch?v=dAYGxsjDOPg) |
| Introduction | [![Introduction](https://img.youtube.com/vi/Fn1lQjC0GO0/0.jpg)](https://www.youtube.com/watch?v=Fn1lQjC0GO0) |
| Tools Overview | [![Tools Overview](https://img.youtube.com/vi/XaQRgRpV7jo/0.jpg)](https://www.youtube.com/watch?v=XaQRgRpV7jo) |
| Custom Tools | [![Custom Tools](https://img.youtube.com/vi/JSU2Rndh06c/0.jpg)](https://www.youtube.com/watch?v=JSU2Rndh06c) |
| Firecrawl Integration | [![Firecrawl](https://img.youtube.com/vi/UoqUDcLcOYo/0.jpg)](https://www.youtube.com/watch?v=UoqUDcLcOYo) |
| User Interface | [![UI](https://img.youtube.com/vi/tg-ZjNl3OCg/0.jpg)](https://www.youtube.com/watch?v=tg-ZjNl3OCg) |
| Crawl4AI Integration | [![Crawl4AI](https://img.youtube.com/vi/KAvuVUh0XU8/0.jpg)](https://www.youtube.com/watch?v=KAvuVUh0XU8) |
| Chat Interface | [![Chat](https://img.youtube.com/vi/sw3uDqn2h1Y/0.jpg)](https://www.youtube.com/watch?v=sw3uDqn2h1Y) |
| Code Interface | [![Code](https://img.youtube.com/vi/_5jQayO-MQY/0.jpg)](https://www.youtube.com/watch?v=_5jQayO-MQY) |
| Mem0 Integration | [![Mem0](https://img.youtube.com/vi/KIGSgRxf1cY/0.jpg)](https://www.youtube.com/watch?v=KIGSgRxf1cY) |
| Training | [![Training](https://img.youtube.com/vi/aLawE8kwCrI/0.jpg)](https://www.youtube.com/watch?v=aLawE8kwCrI) |
| Realtime Voice Interface | [![Realtime](https://img.youtube.com/vi/frRHfevTCSw/0.jpg)](https://www.youtube.com/watch?v=frRHfevTCSw) |
| Call Interface | [![Call](https://img.youtube.com/vi/m1cwrUG2iAk/0.jpg)](https://www.youtube.com/watch?v=m1cwrUG2iAk) |
| Reasoning Extract Agents | [![Reasoning Extract](https://img.youtube.com/vi/2PPamsADjJA/0.jpg)](https://www.youtube.com/watch?v=2PPamsADjJA) |
+2 -2
View File
@@ -3,8 +3,8 @@ class Praisonai < Formula
desc "AI tools for various AI applications"
homepage "https://github.com/MervinPraison/PraisonAI"
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v2.2.97.tar.gz"
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v2.2.97.tar.gz | shasum -a 256`.split.first
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v2.3.3.tar.gz"
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v2.3.3.tar.gz | shasum -a 256`.split.first
license "MIT"
depends_on "[email protected]"
+142
View File
@@ -579,9 +579,14 @@ class PraisonAI:
parser.add_argument("--research", action="store_true", help="Run deep research on a topic")
parser.add_argument("--query-rewrite", action="store_true", help="Rewrite query for better results (works with any command)")
parser.add_argument("--rewrite-tools", type=str, help="Tools for query rewriter (e.g., 'internet_search' or path to tools.py)")
parser.add_argument("--expand-prompt", action="store_true", help="Expand short prompt into detailed prompt (works with any command)")
parser.add_argument("--expand-tools", type=str, help="Tools for prompt expander (e.g., 'internet_search' or path to tools.py)")
parser.add_argument("--tools", "-t", type=str, help="Path to tools.py file for research agent")
parser.add_argument("--save", "-s", action="store_true", help="Save research output to file (output/research/)")
parser.add_argument("--verbose", "-v", action="store_true", help="Enable verbose output for research")
parser.add_argument("--web-search", action="store_true", help="Enable native web search (OpenAI, Gemini, Anthropic, xAI, Perplexity)")
parser.add_argument("--web-fetch", action="store_true", help="Enable web fetch to retrieve URL content (Anthropic only)")
parser.add_argument("--prompt-caching", action="store_true", help="Enable prompt caching to reduce costs (OpenAI, Anthropic, Bedrock, Deepseek)")
# If we're in a test environment, parse with empty args to avoid pytest interference
if in_test_env:
@@ -839,12 +844,135 @@ class PraisonAI:
verbose = getattr(self.args, 'verbose', False)
return self._rewrite_query(query, rewrite_tools, verbose)
def _expand_prompt(self, prompt: str, expand_tools: str = None, verbose: bool = False) -> str:
"""
Expand prompt using PromptExpanderAgent.
Args:
prompt: The prompt to expand
expand_tools: Tool names (comma-separated) or path to tools.py
verbose: Enable verbose output
Returns:
Expanded prompt or original if expansion fails
"""
try:
from praisonaiagents import PromptExpanderAgent, ExpandStrategy
from rich import print
print("[bold cyan]Expanding prompt for detailed execution...[/bold cyan]")
# Load expand tools if specified
expand_tools_list = []
if expand_tools:
if os.path.isfile(expand_tools):
# Load from file
try:
import inspect
import importlib.util
spec = importlib.util.spec_from_file_location("expand_tools_module", expand_tools)
if spec and spec.loader:
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
for name, obj in inspect.getmembers(module):
if inspect.isfunction(obj) and not name.startswith('_'):
expand_tools_list.append(obj)
if expand_tools_list:
print(f"[cyan]Loaded {len(expand_tools_list)} tools for prompt expander[/cyan]")
except Exception as e:
print(f"[yellow]Warning: Failed to load expand tools: {e}[/yellow]")
else:
# Treat as comma-separated tool names
try:
from praisonaiagents.tools import TOOL_MAPPINGS
import praisonaiagents.tools as tools_module
tool_names = [t.strip() for t in expand_tools.split(',')]
for tool_name in tool_names:
if tool_name in TOOL_MAPPINGS:
try:
tool = getattr(tools_module, tool_name)
expand_tools_list.append(tool)
except Exception as e:
print(f"[yellow]Warning: Failed to load expand tool '{tool_name}': {e}[/yellow]")
else:
print(f"[yellow]Warning: Unknown expand tool '{tool_name}'[/yellow]")
if expand_tools_list:
print(f"[cyan]Using expand tools: {', '.join(tool_names)}[/cyan]")
except ImportError:
print("[yellow]Warning: Could not import tools module[/yellow]")
expander = PromptExpanderAgent(
model="gpt-4o-mini",
verbose=verbose,
tools=expand_tools_list if expand_tools_list else None
)
result = expander.expand(prompt, strategy=ExpandStrategy.AUTO)
expanded = result.expanded_prompt
print(f"[cyan]Original:[/cyan] {prompt}")
print(f"[cyan]Expanded:[/cyan] {expanded}")
return expanded
except ImportError:
from rich import print
print("[yellow]Warning: PromptExpanderAgent not available, using original prompt[/yellow]")
return prompt
except Exception as e:
from rich import print
print(f"[yellow]Warning: Prompt expansion failed ({e}), using original prompt[/yellow]")
return prompt
def _expand_prompt_if_enabled(self, prompt: str) -> str:
"""
Expand prompt using PromptExpanderAgent if --expand-prompt is enabled.
Returns the expanded prompt or original if expansion is disabled/fails.
"""
if not hasattr(self, 'args') or not getattr(self.args, 'expand_prompt', False):
return prompt
expand_tools = getattr(self.args, 'expand_tools', None)
verbose = getattr(self.args, 'verbose', False)
return self._expand_prompt(prompt, expand_tools, verbose)
def _save_output(self, prompt: str, result: str):
"""
Save output to output/prompts/ folder.
Args:
prompt: The original prompt
result: The output result to save
"""
from datetime import datetime
from rich import print
# Create output directory
output_dir = os.path.join(os.getcwd(), "output", "prompts")
os.makedirs(output_dir, exist_ok=True)
# Generate filename with timestamp
timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
# Create a safe filename from prompt (first 30 chars)
safe_prompt = "".join(c if c.isalnum() or c in " -_" else "" for c in prompt[:30]).strip().replace(" ", "_")
filename = f"{timestamp}_{safe_prompt}.md"
filepath = os.path.join(output_dir, filename)
# Write output
with open(filepath, "w", encoding="utf-8") as f:
f.write(f"# Prompt\n\n{prompt}\n\n")
f.write(f"# Output\n\n{result}\n")
print(f"[green]✅ Output saved to: {filepath}[/green]")
def handle_direct_prompt(self, prompt):
"""
Handle direct prompt by creating a single agent and running it.
"""
# Apply query rewriting if enabled
prompt = self._rewrite_query_if_enabled(prompt)
# Apply prompt expansion if enabled
prompt = self._expand_prompt_if_enabled(prompt)
if PRAISONAI_AVAILABLE:
agent_config = {
@@ -858,8 +986,22 @@ class PraisonAI:
if hasattr(self, 'args') and self.args.llm:
agent_config["llm"] = self.args.llm
# Add feature flags if enabled
if hasattr(self, 'args'):
if getattr(self.args, 'web_search', False):
agent_config["web_search"] = True
if getattr(self.args, 'web_fetch', False):
agent_config["web_fetch"] = True
if getattr(self.args, 'prompt_caching', False):
agent_config["prompt_caching"] = True
agent = PraisonAgent(**agent_config)
result = agent.start(prompt)
# Save output if --save is enabled
if hasattr(self, 'args') and getattr(self.args, 'save', False):
self._save_output(prompt, result)
return result
elif CREWAI_AVAILABLE:
from crewai import Agent, Task, Crew
+1 -1
View File
@@ -57,7 +57,7 @@ class CloudDeployer:
file.write("FROM python:3.11-slim\n")
file.write("WORKDIR /app\n")
file.write("COPY . .\n")
file.write("RUN pip install flask praisonai==2.2.97 gunicorn markdown\n")
file.write("RUN pip install flask praisonai==2.3.3 gunicorn markdown\n")
file.write("EXPOSE 8080\n")
file.write('CMD ["gunicorn", "-b", "0.0.0.0:8080", "api:app"]\n')
+1 -1
View File
@@ -1 +1 @@
__version__ = "2.2.97"
__version__ = "2.3.3"
+1 -1
View File
@@ -12,7 +12,7 @@ dependencies = [
"rich>=13.7",
"markdown>=3.5",
"pyparsing>=3.0.0",
"praisonaiagents>=0.0.168",
"praisonaiagents>=0.0.178",
"python-dotenv>=0.19.0",
"instructor>=1.3.3",
"PyYAML>=6.0",
+222
View File
@@ -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()
+15 -3
View File
@@ -62,11 +62,23 @@ def main():
print(f"\n🚀 Releasing PraisonAI {tag}\n")
# 1. uv lock
print("📦 Running uv lock...")
# 1. Copy root README.md to package dir for PyPI
print("📄 Copying README.md...")
root_readme = root / "README.md"
pkg_readme = praisonai_dir / "README.md"
if root_readme.exists():
import shutil
shutil.copy(root_readme, pkg_readme)
print(f" ✅ Copied {root_readme} -> {pkg_readme}")
# 2. Clear uv cache and run uv lock
print("\n🧹 Clearing uv cache...")
run(["uv", "cache", "clean"], cwd=praisonai_dir)
print("\n📦 Running uv lock...")
run(["uv", "lock"], cwd=praisonai_dir)
# 2. uv build
# 3. uv build
print("\n🔨 Running uv build...")
run(["uv", "build"], cwd=praisonai_dir)
@@ -72,10 +72,10 @@ roles:
except Exception as e:
pytest.fail(f"CrewAI real test failed: {e}")
@pytest.mark.skipif(not os.getenv("OPENAI_API_KEY"), reason="OPENAI_API_KEY not set")
def test_crewai_environment_check(self):
"""Verify CrewAI environment is properly configured"""
# Check API key is available
assert os.getenv("OPENAI_API_KEY"), "OPENAI_API_KEY required for real tests"
# Check API key is available (already checked by skipif)
# Check CrewAI can be imported
try:
+5 -2
View File
@@ -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):
+4 -4
View File
@@ -4197,7 +4197,7 @@ requires-dist = [
{ name = "praisonai-tools", marker = "extra == 'autogen'", specifier = ">=0.0.22" },
{ name = "praisonai-tools", marker = "extra == 'autogen-v4'", specifier = ">=0.0.22" },
{ name = "praisonai-tools", marker = "extra == 'crewai'", specifier = ">=0.0.22" },
{ name = "praisonaiagents", specifier = ">=0.0.168" },
{ name = "praisonaiagents", specifier = ">=0.0.178" },
{ name = "pyautogen", marker = "extra == 'autogen'", specifier = "==0.2.29" },
{ name = "pydantic", marker = "extra == 'chat'", specifier = "<=2.10.1" },
{ name = "pydantic", marker = "extra == 'code'", specifier = "<=2.10.1" },
@@ -4252,7 +4252,7 @@ wheels = [
[[package]]
name = "praisonaiagents"
version = "0.0.168"
version = "0.0.178"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiohttp" },
@@ -4261,9 +4261,9 @@ dependencies = [
{ name = "pydantic" },
{ name = "rich" },
]
sdist = { url = "https://files.pythonhosted.org/packages/71/a9/d4e0effb144fefe14d724dea9765cf27935d6bd9cd1c76b80ac8961d2f86/praisonaiagents-0.0.168.tar.gz", hash = "sha256:744156db568d595004d8b29a72a214dbad6a9a4702b6f72eff8f08aba77515b1", size = 304818, upload-time = "2025-12-15T08:10:54.72Z" }
sdist = { url = "https://files.pythonhosted.org/packages/db/40/9c7355184b107ab09db83525b6ac8633b27c2cd12104902c7f95cdef6e61/praisonaiagents-0.0.178.tar.gz", hash = "sha256:9427be0e26c08f9a6c5e356e52991ee43a531a8088bd45ecb0a27f4e4b602eaa", size = 352905, upload-time = "2025-12-15T14:52:03.258Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7b/42/3077b1892c3ec0cce059a17dd8517e8f7b4b7944115e3d6025eeb91f633c/praisonaiagents-0.0.168-py3-none-any.whl", hash = "sha256:3e7095da3ebbe1d7c23613418b3200f3d42198ae2ccde08dd753cf548a549e93", size = 322039, upload-time = "2025-12-15T08:10:53.526Z" },
{ url = "https://files.pythonhosted.org/packages/5b/4f/46b7030a57852127a0f185b0b2a9340df277d3396dff7ad86f2f8e580122/praisonaiagents-0.0.178-py3-none-any.whl", hash = "sha256:91a032a0da8574662269b7266c41667ae905434388f1e9bc74265f18df5596a7", size = 354385, upload-time = "2025-12-15T14:52:01.915Z" },
]
[[package]]