mirror of
https://github.com/MervinPraison/PraisonAI.git
synced 2026-11-07 17:33:33 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9cbab01785 | ||
|
|
de9d4bd3c6 | ||
|
|
aba2265e5e | ||
|
|
ebb9b24bff | ||
|
|
759ef2c655 | ||
|
|
cb4318f6e8 | ||
|
|
d9bb869d5e | ||
|
|
34b6aeb76b | ||
|
|
078b7801e1 | ||
|
|
a970e849b4 | ||
|
|
8e68f4e71c | ||
|
|
308758cdc2 | ||
|
|
f8fa61b082 | ||
|
|
955d236924 | ||
|
|
d9d63c3a13 | ||
|
|
5bee6eb470 | ||
|
|
7cb1676b7c | ||
|
|
e2e9c3f9ee | ||
|
|
cbe8beeca6 |
No files matched your search
+3
-1
@@ -80,4 +80,6 @@ CopilotKit*
|
||||
issue_details.json
|
||||
|
||||
# MCP Registry tokens
|
||||
.mcpregistry_*
|
||||
.mcpregistry_*
|
||||
|
||||
*.log
|
||||
@@ -261,6 +261,16 @@ npm install praisonai
|
||||
| ↳ 100+ LLM Support | [Example](examples/python/providers/openai/openai_gpt4_example.py) | [📖](https://docs.praison.ai/models) |
|
||||
| ↳ Callback Agents | [Example](examples/python/general/advanced-callback-systems.py) | [📖](https://docs.praison.ai/features/callbacks) |
|
||||
| ↳ Hooks | [Example](#9-hooks) | [📖](https://docs.praison.ai/features/hooks) |
|
||||
| ↳ Middleware System | [Example](examples/middleware/basic_middleware.py) | [📖](https://docs.praison.ai/features/middleware) |
|
||||
| ↳ Configurable Model | [Example](examples/middleware/configurable_model.py) | [📖](https://docs.praison.ai/features/configurable-model) |
|
||||
| ↳ Rate Limiter | [Example](examples/middleware/rate_limiter.py) | [📖](https://docs.praison.ai/features/rate-limiter) |
|
||||
| ↳ Injected Tool State | [Example](examples/middleware/injected_state.py) | [📖](https://docs.praison.ai/features/injected-state) |
|
||||
| ↳ Shadow Git Checkpoints | [Example](#10-shadow-git-checkpoints) | [📖](https://docs.praison.ai/features/checkpoints) |
|
||||
| ↳ Background Tasks | [Example](examples/background/basic_background.py) | [📖](https://docs.praison.ai/features/background-tasks) |
|
||||
| ↳ Policy Engine | [Example](examples/policy/basic_policy.py) | [📖](https://docs.praison.ai/features/policy-engine) |
|
||||
| ↳ Thinking Budgets | [Example](examples/thinking/basic_thinking.py) | [📖](https://docs.praison.ai/features/thinking-budgets) |
|
||||
| ↳ Output Styles | [Example](examples/output/basic_output.py) | [📖](https://docs.praison.ai/features/output-styles) |
|
||||
| ↳ Context Compaction | [Example](examples/compaction/basic_compaction.py) | [📖](https://docs.praison.ai/features/context-compaction) |
|
||||
| **📊 Monitoring & Management** | | |
|
||||
| ↳ Sessions Management | [Example](examples/python/sessions/comprehensive-session-management.py) | [📖](https://docs.praison.ai/features/sessions) |
|
||||
| ↳ Auto-Save Sessions | [Example](#session-management-python) | [📖](https://docs.praison.ai/docs/cli/session) |
|
||||
@@ -278,6 +288,12 @@ npm install praisonai
|
||||
| ↳ Git Integration | [Example](examples/python/cli/git_integration_example.py) | [📖](https://docs.praison.ai/docs/cli/git-integration) |
|
||||
| ↳ Sandbox Execution | [Example](examples/python/cli/sandbox_execution_example.py) | [📖](https://docs.praison.ai/docs/cli/sandbox-execution) |
|
||||
| ↳ CLI Compare | [Example](examples/compare/cli_compare_basic.py) | [📖](https://docs.praison.ai/docs/cli/compare) |
|
||||
| ↳ Profile/Benchmark | [Example](#profile-benchmark) | [📖](https://docs.praison.ai/docs/cli/profile) |
|
||||
| ↳ Auto Mode | [Example](#auto-mode) | [📖](https://docs.praison.ai/docs/cli/auto) |
|
||||
| ↳ Init | [Example](#init) | [📖](https://docs.praison.ai/docs/cli/init) |
|
||||
| ↳ File Input | [Example](#file-input) | [📖](https://docs.praison.ai/docs/cli/file-input) |
|
||||
| ↳ Final Agent | [Example](#final-agent) | [📖](https://docs.praison.ai/docs/cli/final-agent) |
|
||||
| ↳ Max Tokens | [Example](#max-tokens) | [📖](https://docs.praison.ai/docs/cli/max-tokens) |
|
||||
| **🧪 Evaluation** | | |
|
||||
| ↳ Accuracy Evaluation | [Example](examples/eval/accuracy_example.py) | [📖](https://docs.praison.ai/docs/cli/eval) |
|
||||
| ↳ Performance Evaluation | [Example](examples/eval/performance_example.py) | [📖](https://docs.praison.ai/docs/cli/eval) |
|
||||
@@ -857,21 +873,258 @@ callbacks:
|
||||
|
||||
### 9. Hooks
|
||||
|
||||
Configure in `.praison/hooks.json`:
|
||||
Intercept and modify agent behavior at various lifecycle points:
|
||||
|
||||
```python
|
||||
from praisonaiagents.memory import HooksManager
|
||||
from praisonaiagents.hooks import (
|
||||
HookRegistry, HookRunner, HookEvent, HookResult,
|
||||
BeforeToolInput
|
||||
)
|
||||
|
||||
hooks = HooksManager()
|
||||
# Create a hook registry
|
||||
registry = HookRegistry()
|
||||
|
||||
# Register Python hooks
|
||||
hooks.register("pre_write_code", lambda ctx: print(f"Writing {ctx['file']}"))
|
||||
# Log all tool calls
|
||||
@registry.on(HookEvent.BEFORE_TOOL)
|
||||
def log_tools(event_data: BeforeToolInput) -> HookResult:
|
||||
print(f"Tool: {event_data.tool_name}")
|
||||
return HookResult.allow()
|
||||
|
||||
# Block dangerous operations
|
||||
@registry.on(HookEvent.BEFORE_TOOL)
|
||||
def security_check(event_data: BeforeToolInput) -> HookResult:
|
||||
if "delete" in event_data.tool_name.lower():
|
||||
return HookResult.deny("Delete operations blocked")
|
||||
return HookResult.allow()
|
||||
|
||||
# Execute hooks
|
||||
result = hooks.execute("pre_write_code", {"file": "main.py"})
|
||||
runner = HookRunner(registry)
|
||||
```
|
||||
|
||||
### 10. Field Names Reference (A-I-G-S)
|
||||
**CLI Commands:**
|
||||
```bash
|
||||
praisonai hooks list # List registered hooks
|
||||
praisonai hooks test before_tool # Test hooks for an event
|
||||
praisonai hooks run "echo test" # Run a command hook
|
||||
praisonai hooks validate hooks.json # Validate configuration
|
||||
```
|
||||
|
||||
|
||||
### 10. Shadow Git Checkpoints
|
||||
|
||||
File-level undo/restore using shadow git:
|
||||
|
||||
```python
|
||||
from praisonaiagents.checkpoints import CheckpointService
|
||||
|
||||
service = CheckpointService(workspace_dir="./my_project")
|
||||
await service.initialize()
|
||||
|
||||
# Save checkpoint before changes
|
||||
result = await service.save("Before refactoring")
|
||||
|
||||
# Make changes...
|
||||
|
||||
# Restore if needed
|
||||
await service.restore(result.checkpoint.id)
|
||||
|
||||
# View diff
|
||||
diff = await service.diff()
|
||||
```
|
||||
|
||||
**CLI Commands:**
|
||||
```bash
|
||||
praisonai checkpoint save "Before changes" # Save checkpoint
|
||||
praisonai checkpoint list # List checkpoints
|
||||
praisonai checkpoint diff # Show changes
|
||||
praisonai checkpoint restore abc123 # Restore to checkpoint
|
||||
```
|
||||
|
||||
**Links:**
|
||||
- [📖 Coding Docs](https://docs.praison.ai/features/checkpoints)
|
||||
- [📖 CLI Docs](https://docs.praison.ai/docs/cli/checkpoint)
|
||||
- [💻 Example](examples/checkpoints/basic_checkpoints.py)
|
||||
|
||||
---
|
||||
|
||||
### 11. Background Tasks
|
||||
|
||||
Run agent tasks asynchronously without blocking:
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
from praisonaiagents.background import BackgroundRunner, BackgroundConfig
|
||||
|
||||
async def main():
|
||||
config = BackgroundConfig(max_concurrent_tasks=3)
|
||||
runner = BackgroundRunner(config=config)
|
||||
|
||||
async def my_task(name: str) -> str:
|
||||
await asyncio.sleep(2)
|
||||
return f"Task {name} completed"
|
||||
|
||||
task = await runner.submit(my_task, args=("example",), name="my_task")
|
||||
await task.wait(timeout=10.0)
|
||||
print(task.result)
|
||||
|
||||
asyncio.run(main())
|
||||
```
|
||||
|
||||
**CLI Commands:**
|
||||
```bash
|
||||
praisonai background list # List running tasks
|
||||
praisonai background status <id> # Check task status
|
||||
praisonai background cancel <id> # Cancel a task
|
||||
praisonai background clear # Clear completed tasks
|
||||
```
|
||||
|
||||
**Links:**
|
||||
- [📖 Coding Docs](https://docs.praison.ai/features/background-tasks)
|
||||
- [📖 CLI Docs](https://docs.praison.ai/docs/cli/background)
|
||||
- [💻 Example](examples/background/basic_background.py)
|
||||
|
||||
---
|
||||
|
||||
### 12. Policy Engine
|
||||
|
||||
Control what agents can and cannot do with policy-based execution:
|
||||
|
||||
```python
|
||||
from praisonaiagents.policy import (
|
||||
PolicyEngine, Policy, PolicyRule, PolicyAction
|
||||
)
|
||||
|
||||
engine = PolicyEngine()
|
||||
|
||||
policy = Policy(
|
||||
name="no_delete",
|
||||
rules=[
|
||||
PolicyRule(
|
||||
action=PolicyAction.DENY,
|
||||
resource="tool:delete_*",
|
||||
reason="Delete operations blocked"
|
||||
)
|
||||
]
|
||||
)
|
||||
engine.add_policy(policy)
|
||||
|
||||
result = engine.check("tool:delete_file", {})
|
||||
print(f"Allowed: {result.allowed}")
|
||||
```
|
||||
|
||||
**CLI Commands:**
|
||||
```bash
|
||||
praisonai policy list # List policies
|
||||
praisonai policy check "tool:name" # Check if allowed
|
||||
praisonai policy init # Create template
|
||||
```
|
||||
|
||||
**Links:**
|
||||
- [📖 Coding Docs](https://docs.praison.ai/features/policy-engine)
|
||||
- [📖 CLI Docs](https://docs.praison.ai/docs/cli/policy)
|
||||
- [💻 Example](examples/policy/basic_policy.py)
|
||||
|
||||
---
|
||||
|
||||
### 13. Thinking Budgets
|
||||
|
||||
Configure token budgets for extended thinking:
|
||||
|
||||
```python
|
||||
from praisonaiagents.thinking import ThinkingBudget, ThinkingTracker
|
||||
|
||||
# Use predefined levels
|
||||
budget = ThinkingBudget.high() # 16,000 tokens
|
||||
|
||||
# Track usage
|
||||
tracker = ThinkingTracker()
|
||||
session = tracker.start_session(budget_tokens=16000)
|
||||
tracker.end_session(session, tokens_used=12000)
|
||||
|
||||
summary = tracker.get_summary()
|
||||
print(f"Utilization: {summary['average_utilization']:.1%}")
|
||||
```
|
||||
|
||||
**CLI Commands:**
|
||||
```bash
|
||||
praisonai thinking status # Show current budget
|
||||
praisonai thinking set high # Set budget level
|
||||
praisonai thinking stats # Show usage statistics
|
||||
```
|
||||
|
||||
**Links:**
|
||||
- [📖 Coding Docs](https://docs.praison.ai/features/thinking-budgets)
|
||||
- [📖 CLI Docs](https://docs.praison.ai/docs/cli/thinking)
|
||||
- [💻 Example](examples/thinking/basic_thinking.py)
|
||||
|
||||
---
|
||||
|
||||
### 14. Output Styles
|
||||
|
||||
Configure how agents format their responses:
|
||||
|
||||
```python
|
||||
from praisonaiagents.output import OutputStyle, OutputFormatter
|
||||
|
||||
# Use preset styles
|
||||
style = OutputStyle.concise()
|
||||
formatter = OutputFormatter(style)
|
||||
|
||||
# Format output
|
||||
text = "# Hello\n\nThis is **bold** text."
|
||||
plain = formatter.format(text)
|
||||
print(plain)
|
||||
```
|
||||
|
||||
**CLI Commands:**
|
||||
```bash
|
||||
praisonai output status # Show current style
|
||||
praisonai output set concise # Set output style
|
||||
```
|
||||
|
||||
**Links:**
|
||||
- [📖 Coding Docs](https://docs.praison.ai/features/output-styles)
|
||||
- [📖 CLI Docs](https://docs.praison.ai/docs/cli/output-style)
|
||||
- [💻 Example](examples/output/basic_output.py)
|
||||
|
||||
---
|
||||
|
||||
### 15. Context Compaction
|
||||
|
||||
Automatically manage context window size:
|
||||
|
||||
```python
|
||||
from praisonaiagents.compaction import (
|
||||
ContextCompactor, CompactionStrategy
|
||||
)
|
||||
|
||||
compactor = ContextCompactor(
|
||||
max_tokens=4000,
|
||||
strategy=CompactionStrategy.SLIDING,
|
||||
preserve_recent=3
|
||||
)
|
||||
|
||||
messages = [...] # Your conversation history
|
||||
compacted, result = compactor.compact(messages)
|
||||
|
||||
print(f"Compression: {result.compression_ratio:.1%}")
|
||||
```
|
||||
|
||||
**CLI Commands:**
|
||||
```bash
|
||||
praisonai compaction status # Show settings
|
||||
praisonai compaction set sliding # Set strategy
|
||||
praisonai compaction stats # Show statistics
|
||||
```
|
||||
|
||||
**Links:**
|
||||
- [📖 Coding Docs](https://docs.praison.ai/features/context-compaction)
|
||||
- [📖 CLI Docs](https://docs.praison.ai/docs/cli/compaction)
|
||||
- [💻 Example](examples/compaction/basic_compaction.py)
|
||||
|
||||
---
|
||||
|
||||
### 16. Field Names Reference (A-I-G-S)
|
||||
|
||||
PraisonAI accepts both old (agents.yaml) and new (workflow.yaml) field names. Use the **canonical names** for new projects:
|
||||
|
||||
@@ -905,7 +1158,7 @@ steps: # Define steps (not 'tasks')
|
||||
|
||||
> **Note:** The parser accepts both old and new names. Run `praisonai workflow validate <file.yaml>` to see suggestions for canonical names.
|
||||
|
||||
### 11. Extended agents.yaml with Workflow Patterns
|
||||
### 12. Extended agents.yaml with Workflow Patterns
|
||||
|
||||
**Feature Parity:** Both `agents.yaml` and `workflow.yaml` now support the same features:
|
||||
- All workflow patterns (route, parallel, loop, repeat)
|
||||
@@ -981,6 +1234,124 @@ Run with the same simple command:
|
||||
praisonai agents.yaml
|
||||
```
|
||||
|
||||
### 12. MCP (Model Context Protocol)
|
||||
|
||||
PraisonAI supports MCP Protocol Revision 2025-11-25 with multiple transports.
|
||||
|
||||
#### MCP Client (Consume MCP Servers)
|
||||
```python
|
||||
from praisonaiagents import Agent, MCP
|
||||
|
||||
# stdio - Local NPX/Python servers
|
||||
agent = Agent(tools=MCP("npx @modelcontextprotocol/server-memory"))
|
||||
|
||||
# Streamable HTTP - Production servers
|
||||
agent = Agent(tools=MCP("https://api.example.com/mcp"))
|
||||
|
||||
# WebSocket - Real-time bidirectional
|
||||
agent = Agent(tools=MCP("wss://api.example.com/mcp", auth_token="token"))
|
||||
|
||||
# SSE (Legacy) - Backward compatibility
|
||||
agent = Agent(tools=MCP("http://localhost:8080/sse"))
|
||||
|
||||
# With environment variables
|
||||
agent = Agent(
|
||||
tools=MCP(
|
||||
command="npx",
|
||||
args=["-y", "@modelcontextprotocol/server-brave-search"],
|
||||
env={"BRAVE_API_KEY": "your-key"}
|
||||
)
|
||||
)
|
||||
|
||||
# Multiple MCP servers + regular functions
|
||||
def my_custom_tool(query: str) -> str:
|
||||
"""Custom tool function."""
|
||||
return f"Result: {query}"
|
||||
|
||||
agent = Agent(
|
||||
name="MultiToolAgent",
|
||||
instructions="Agent with multiple MCP servers",
|
||||
tools=[
|
||||
MCP("uvx mcp-server-time"), # Time tools
|
||||
MCP("npx @modelcontextprotocol/server-memory"), # Memory tools
|
||||
my_custom_tool # Regular function
|
||||
]
|
||||
)
|
||||
```
|
||||
|
||||
#### MCP Server (Expose Tools as MCP Server)
|
||||
|
||||
Expose your Python functions as MCP tools for Claude Desktop, Cursor, and other MCP clients:
|
||||
|
||||
```python
|
||||
from praisonaiagents.mcp import ToolsMCPServer
|
||||
|
||||
def search_web(query: str, max_results: int = 5) -> dict:
|
||||
"""Search the web for information."""
|
||||
return {"results": [f"Result for {query}"]}
|
||||
|
||||
def calculate(expression: str) -> dict:
|
||||
"""Evaluate a mathematical expression."""
|
||||
return {"result": eval(expression)}
|
||||
|
||||
# Create and run MCP server
|
||||
server = ToolsMCPServer(name="my-tools")
|
||||
server.register_tools([search_web, calculate])
|
||||
server.run() # stdio for Claude Desktop
|
||||
# server.run_sse(host="0.0.0.0", port=8080) # SSE for web clients
|
||||
```
|
||||
|
||||
#### MCP Features
|
||||
| Feature | Description |
|
||||
|---------|-------------|
|
||||
| Session Management | Automatic Mcp-Session-Id handling |
|
||||
| Protocol Versioning | Mcp-Protocol-Version header |
|
||||
| Resumability | SSE stream recovery via Last-Event-ID |
|
||||
| Security | Origin validation, DNS rebinding prevention |
|
||||
| WebSocket | Auto-reconnect with exponential backoff |
|
||||
|
||||
### 13. A2A (Agent2Agent Protocol)
|
||||
|
||||
PraisonAI supports the [A2A Protocol](https://a2a-protocol.org) for agent-to-agent communication, enabling your agents to be discovered and collaborate with other AI agents.
|
||||
|
||||
#### A2A Server (Expose Agent as A2A Server)
|
||||
```python
|
||||
from praisonaiagents import Agent, A2A
|
||||
from fastapi import FastAPI
|
||||
|
||||
# Create an agent with tools
|
||||
def search_web(query: str) -> str:
|
||||
"""Search the web for information."""
|
||||
return f"Results for: {query}"
|
||||
|
||||
agent = Agent(
|
||||
name="Research Assistant",
|
||||
role="Research Analyst",
|
||||
goal="Help users research topics",
|
||||
tools=[search_web]
|
||||
)
|
||||
|
||||
# Expose as A2A Server
|
||||
a2a = A2A(agent=agent, url="http://localhost:8000/a2a")
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(a2a.get_router())
|
||||
|
||||
# Run: uvicorn app:app --reload
|
||||
# Agent Card: GET /.well-known/agent.json
|
||||
# Status: GET /status
|
||||
```
|
||||
|
||||
#### A2A Features
|
||||
| Feature | Description |
|
||||
|---------|-------------|
|
||||
| Agent Card | JSON metadata for agent discovery |
|
||||
| Skills Extraction | Auto-generate skills from tools |
|
||||
| Task Management | Stateful task lifecycle |
|
||||
| Streaming | SSE streaming for real-time updates |
|
||||
|
||||
> **Documentation**: [docs.praison.ai/a2a](https://docs.praison.ai/a2a) | **Examples**: [examples/python/a2a](https://github.com/MervinPraison/PraisonAI/tree/main/examples/python/a2a)
|
||||
|
||||
---
|
||||
|
||||
## 🎯 CLI / No-Code Interface
|
||||
@@ -1730,124 +2101,6 @@ agent = Agent(
|
||||
| Web Fetch | Anthropic |
|
||||
| Prompt Caching | OpenAI (auto), Anthropic, Bedrock, Deepseek |
|
||||
|
||||
## MCP (Model Context Protocol)
|
||||
|
||||
PraisonAI supports MCP Protocol Revision 2025-11-25 with multiple transports.
|
||||
|
||||
### MCP Client (Consume MCP Servers)
|
||||
```python
|
||||
from praisonaiagents import Agent, MCP
|
||||
|
||||
# stdio - Local NPX/Python servers
|
||||
agent = Agent(tools=MCP("npx @modelcontextprotocol/server-memory"))
|
||||
|
||||
# Streamable HTTP - Production servers
|
||||
agent = Agent(tools=MCP("https://api.example.com/mcp"))
|
||||
|
||||
# WebSocket - Real-time bidirectional
|
||||
agent = Agent(tools=MCP("wss://api.example.com/mcp", auth_token="token"))
|
||||
|
||||
# SSE (Legacy) - Backward compatibility
|
||||
agent = Agent(tools=MCP("http://localhost:8080/sse"))
|
||||
|
||||
# With environment variables
|
||||
agent = Agent(
|
||||
tools=MCP(
|
||||
command="npx",
|
||||
args=["-y", "@modelcontextprotocol/server-brave-search"],
|
||||
env={"BRAVE_API_KEY": "your-key"}
|
||||
)
|
||||
)
|
||||
|
||||
# Multiple MCP servers + regular functions
|
||||
def my_custom_tool(query: str) -> str:
|
||||
"""Custom tool function."""
|
||||
return f"Result: {query}"
|
||||
|
||||
agent = Agent(
|
||||
name="MultiToolAgent",
|
||||
instructions="Agent with multiple MCP servers",
|
||||
tools=[
|
||||
MCP("uvx mcp-server-time"), # Time tools
|
||||
MCP("npx @modelcontextprotocol/server-memory"), # Memory tools
|
||||
my_custom_tool # Regular function
|
||||
]
|
||||
)
|
||||
```
|
||||
|
||||
### MCP Server (Expose Tools as MCP Server)
|
||||
|
||||
Expose your Python functions as MCP tools for Claude Desktop, Cursor, and other MCP clients:
|
||||
|
||||
```python
|
||||
from praisonaiagents.mcp import ToolsMCPServer
|
||||
|
||||
def search_web(query: str, max_results: int = 5) -> dict:
|
||||
"""Search the web for information."""
|
||||
return {"results": [f"Result for {query}"]}
|
||||
|
||||
def calculate(expression: str) -> dict:
|
||||
"""Evaluate a mathematical expression."""
|
||||
return {"result": eval(expression)}
|
||||
|
||||
# Create and run MCP server
|
||||
server = ToolsMCPServer(name="my-tools")
|
||||
server.register_tools([search_web, calculate])
|
||||
server.run() # stdio for Claude Desktop
|
||||
# server.run_sse(host="0.0.0.0", port=8080) # SSE for web clients
|
||||
```
|
||||
|
||||
### MCP Features
|
||||
| Feature | Description |
|
||||
|---------|-------------|
|
||||
| Session Management | Automatic Mcp-Session-Id handling |
|
||||
| Protocol Versioning | Mcp-Protocol-Version header |
|
||||
| Resumability | SSE stream recovery via Last-Event-ID |
|
||||
| Security | Origin validation, DNS rebinding prevention |
|
||||
| WebSocket | Auto-reconnect with exponential backoff |
|
||||
|
||||
## A2A (Agent2Agent Protocol)
|
||||
|
||||
PraisonAI supports the [A2A Protocol](https://a2a-protocol.org) for agent-to-agent communication, enabling your agents to be discovered and collaborate with other AI agents.
|
||||
|
||||
### A2A Server (Expose Agent as A2A Server)
|
||||
```python
|
||||
from praisonaiagents import Agent, A2A
|
||||
from fastapi import FastAPI
|
||||
|
||||
# Create an agent with tools
|
||||
def search_web(query: str) -> str:
|
||||
"""Search the web for information."""
|
||||
return f"Results for: {query}"
|
||||
|
||||
agent = Agent(
|
||||
name="Research Assistant",
|
||||
role="Research Analyst",
|
||||
goal="Help users research topics",
|
||||
tools=[search_web]
|
||||
)
|
||||
|
||||
# Expose as A2A Server
|
||||
a2a = A2A(agent=agent, url="http://localhost:8000/a2a")
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(a2a.get_router())
|
||||
|
||||
# Run: uvicorn app:app --reload
|
||||
# Agent Card: GET /.well-known/agent.json
|
||||
# Status: GET /status
|
||||
```
|
||||
|
||||
### A2A Features
|
||||
| Feature | Description |
|
||||
|---------|-------------|
|
||||
| Agent Card | JSON metadata for agent discovery |
|
||||
| Skills Extraction | Auto-generate skills from tools |
|
||||
| Task Management | Stateful task lifecycle |
|
||||
| Streaming | SSE streaming for real-time updates |
|
||||
|
||||
> **Documentation**: [docs.praison.ai/a2a](https://docs.praison.ai/a2a) | **Examples**: [examples/python/a2a](https://github.com/MervinPraison/PraisonAI/tree/main/examples/python/a2a)
|
||||
|
||||
## CLI Features
|
||||
|
||||
| Feature | Docs |
|
||||
@@ -2475,6 +2728,103 @@ PraisonAI provides zero-dependency persistent memory for agents. For detailed ex
|
||||
|
||||
---
|
||||
|
||||
## 💾 Persistence (Databases)
|
||||
|
||||
Enable automatic conversation persistence with 2 lines of code:
|
||||
|
||||
```python
|
||||
from praisonaiagents import Agent, db
|
||||
|
||||
agent = Agent(
|
||||
name="Assistant",
|
||||
db=db(database_url="postgresql://localhost/mydb"), # db(...) shortcut
|
||||
session_id="my-session" # Optional: defaults to per-hour ID (YYYYMMDDHH)
|
||||
)
|
||||
agent.chat("Hello!") # Auto-persists messages, runs, traces
|
||||
```
|
||||
|
||||
### Persistence CLI Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `praisonai persistence doctor` | Validate DB connectivity |
|
||||
| `praisonai persistence run` | Run agent with persistence |
|
||||
| `praisonai persistence resume` | Resume existing session |
|
||||
| `praisonai persistence export` | Export session to JSONL |
|
||||
| `praisonai persistence import` | Import session from JSONL |
|
||||
| `praisonai persistence migrate` | Apply schema migrations |
|
||||
| `praisonai persistence status` | Show schema status |
|
||||
|
||||
### Databases Table
|
||||
|
||||
| Database | Store Type | Install | Example | Docs |
|
||||
|----------|------------|---------|---------|------|
|
||||
| PostgreSQL | Conversation | `pip install "praisonai[tools]"` | [simple_db_agent.py](examples/persistence/simple_db_agent.py) | [docs](https://docs.praison.ai/docs/databases/postgres) |
|
||||
| MySQL | Conversation | `pip install "praisonai[tools]"` | - | [docs](https://docs.praison.ai/docs/databases/overview) |
|
||||
| SQLite | Conversation | `pip install "praisonai[tools]"` | - | [docs](https://docs.praison.ai/docs/databases/overview) |
|
||||
| SingleStore | Conversation | `pip install "praisonai[tools]"` | - | [docs](https://docs.praison.ai/docs/databases/overview) |
|
||||
| Supabase | Conversation | `pip install "praisonai[tools]"` | - | [docs](https://docs.praison.ai/docs/databases/overview) |
|
||||
| SurrealDB | Conversation | `pip install "praisonai[tools]"` | - | [docs](https://docs.praison.ai/docs/databases/overview) |
|
||||
| Qdrant | Knowledge | `pip install "praisonai[tools]"` | [knowledge_qdrant.py](examples/persistence/knowledge_qdrant.py) | [docs](https://docs.praison.ai/docs/databases/qdrant) |
|
||||
| ChromaDB | Knowledge | `pip install "praisonai[tools]"` | - | [docs](https://docs.praison.ai/docs/databases/overview) |
|
||||
| Pinecone | Knowledge | `pip install pinecone` | [pinecone_wow.py](examples/vector/pinecone_wow.py) | [docs](https://docs.praison.ai/docs/databases/pinecone) |
|
||||
| Weaviate | Knowledge | `pip install weaviate-client` | [weaviate_wow.py](examples/vector/weaviate_wow.py) | [docs](https://docs.praison.ai/docs/databases/weaviate) |
|
||||
| LanceDB | Knowledge | `pip install lancedb` | [lancedb_real_wow.py](examples/vector/lancedb_real_wow.py) | [docs](https://docs.praison.ai/docs/databases/lancedb) |
|
||||
| Milvus | Knowledge | `pip install "praisonai[tools]"` | - | [docs](https://docs.praison.ai/docs/databases/overview) |
|
||||
| PGVector | Knowledge | `pip install psycopg2-binary` | [pgvector_real_wow.py](examples/vector/pgvector_real_wow.py) | [docs](https://docs.praison.ai/docs/databases/pgvector) |
|
||||
| Redis Vector | Knowledge | `pip install "praisonai[tools]"` | - | [docs](https://docs.praison.ai/docs/databases/overview) |
|
||||
| Cassandra | Knowledge | `pip install "praisonai[tools]"` | - | [docs](https://docs.praison.ai/docs/databases/overview) |
|
||||
| ClickHouse | Knowledge | `pip install "praisonai[tools]"` | - | [docs](https://docs.praison.ai/docs/databases/overview) |
|
||||
| Redis | State | `pip install "praisonai[tools]"` | [state_redis.py](examples/persistence/state_redis.py) | [docs](https://docs.praison.ai/docs/databases/redis) |
|
||||
| MongoDB | State | `pip install "praisonai[tools]"` | - | [docs](https://docs.praison.ai/docs/databases/overview) |
|
||||
| DynamoDB | State | `pip install "praisonai[tools]"` | - | [docs](https://docs.praison.ai/docs/databases/overview) |
|
||||
| Firestore | State | `pip install "praisonai[tools]"` | - | [docs](https://docs.praison.ai/docs/databases/overview) |
|
||||
| Upstash | State | `pip install "praisonai[tools]"` | - | [docs](https://docs.praison.ai/docs/databases/overview) |
|
||||
| Memory | State | `pip install "praisonai[tools]"` | - | [docs](https://docs.praison.ai/docs/databases/overview) |
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Tools Table
|
||||
|
||||
Install all tools with: `pip install "praisonai[tools]"`
|
||||
|
||||
| Tool | Category | Import | Docs |
|
||||
|------|----------|--------|------|
|
||||
| Tavily | Web Search | `from praisonai_tools import TavilyTool` | [docs](https://docs.praison.ai/docs/tools/external/tavily) |
|
||||
| DuckDuckGo | Web Search | `from praisonai_tools import DuckDuckGoTool` | [docs](https://docs.praison.ai/docs/tools/external/duckduckgo) |
|
||||
| Exa | Web Search | `from praisonai_tools import ExaTool` | [docs](https://docs.praison.ai/docs/tools/external/exa) |
|
||||
| Serper | Web Search | `from praisonai_tools import SerperTool` | [docs](https://docs.praison.ai/docs/tools/external/serper) |
|
||||
| Jina | Web Reader | `from praisonai_tools import JinaTool` | [docs](https://docs.praison.ai/docs/tools/external/jina) |
|
||||
| Firecrawl | Web Scraping | `from praisonai_tools import FirecrawlTool` | [docs](https://docs.praison.ai/docs/tools/external/firecrawl) |
|
||||
| Crawl4AI | Web Scraping | `from praisonai_tools import Crawl4AITool` | [docs](https://docs.praison.ai/docs/tools/external/crawl4ai) |
|
||||
| Wikipedia | Knowledge | `from praisonai_tools import WikipediaTool` | [docs](https://docs.praison.ai/docs/tools/external/wikipedia) |
|
||||
| ArXiv | Research | `from praisonai_tools import ArxivTool` | [docs](https://docs.praison.ai/docs/tools/external/arxiv) |
|
||||
| HackerNews | News | `from praisonai_tools import HackerNewsTool` | [docs](https://docs.praison.ai/docs/tools/external/hackernews) |
|
||||
| YouTube | Media | `from praisonai_tools import YouTubeTool` | [docs](https://docs.praison.ai/docs/tools/external/youtube) |
|
||||
| Weather | Data | `from praisonai_tools import WeatherTool` | [docs](https://docs.praison.ai/docs/tools/external/weather) |
|
||||
| PostgreSQL | Database | `from praisonai_tools import PostgresTool` | [docs](https://docs.praison.ai/docs/tools/external/postgres) |
|
||||
| MySQL | Database | `from praisonai_tools import MySQLTool` | [docs](https://docs.praison.ai/docs/tools/external/mysql) |
|
||||
| SQLite | Database | `from praisonai_tools import SQLiteTool` | [docs](https://docs.praison.ai/docs/tools/external/sqlite) |
|
||||
| MongoDB | Database | `from praisonai_tools import MongoDBTool` | [docs](https://docs.praison.ai/docs/tools/external/mongodb) |
|
||||
| Redis | Database | `from praisonai_tools import RedisTool` | [docs](https://docs.praison.ai/docs/tools/external/redis) |
|
||||
| Qdrant | Vector DB | `from praisonai_tools import QdrantTool` | [docs](https://docs.praison.ai/docs/tools/external/qdrant) |
|
||||
| GitHub | DevOps | `from praisonai_tools import GitHubTool` | [docs](https://docs.praison.ai/docs/tools/external/github) |
|
||||
| Slack | Communication | `from praisonai_tools import SlackTool` | [docs](https://docs.praison.ai/docs/tools/external/slack) |
|
||||
| Discord | Communication | `from praisonai_tools import DiscordTool` | [docs](https://docs.praison.ai/docs/tools/external/discord) |
|
||||
| Telegram | Communication | `from praisonai_tools import TelegramTool` | [docs](https://docs.praison.ai/docs/tools/external/telegram) |
|
||||
| Email | Communication | `from praisonai_tools import EmailTool` | [docs](https://docs.praison.ai/docs/tools/external/email) |
|
||||
| Notion | Productivity | `from praisonai_tools import NotionTool` | [docs](https://docs.praison.ai/docs/tools/external/notion) |
|
||||
| File | File System | `from praisonai_tools import FileTool` | [docs](https://docs.praison.ai/docs/tools/external/file) |
|
||||
| Shell | System | `from praisonai_tools import ShellTool` | [docs](https://docs.praison.ai/docs/tools/external/shell) |
|
||||
| Python | Code | `from praisonai_tools import PythonTool` | [docs](https://docs.praison.ai/docs/tools/external/python) |
|
||||
| JSON | Data | `from praisonai_tools import JSONTool` | [docs](https://docs.praison.ai/docs/tools/external/json) |
|
||||
| CSV | Data | `from praisonai_tools import CSVTool` | [docs](https://docs.praison.ai/docs/tools/external/csv) |
|
||||
| Calculator | Math | `from praisonai_tools import CalculatorTool` | [docs](https://docs.praison.ai/docs/tools/external/calculator) |
|
||||
|
||||
> See [full tools documentation](https://docs.praison.ai/docs/tools/tools) for all 100+ available tools.
|
||||
|
||||
---
|
||||
|
||||
## 🎓 Video Tutorials
|
||||
|
||||
Learn PraisonAI through our comprehensive video series:
|
||||
|
||||
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
|
||||
# Install Python packages (using latest versions)
|
||||
RUN pip install --no-cache-dir \
|
||||
praisonai_tools \
|
||||
"praisonai>=2.3.79" \
|
||||
"praisonai>=2.5.0" \
|
||||
"praisonai[chat]" \
|
||||
"embedchain[github,youtube]"
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ RUN mkdir -p /root/.praison
|
||||
# Install Python packages (using latest versions)
|
||||
RUN pip install --no-cache-dir \
|
||||
praisonai_tools \
|
||||
"praisonai>=2.3.79" \
|
||||
"praisonai>=2.5.0" \
|
||||
"praisonai[ui]" \
|
||||
"praisonai[chat]" \
|
||||
"praisonai[realtime]" \
|
||||
|
||||
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
|
||||
# Install Python packages (using latest versions)
|
||||
RUN pip install --no-cache-dir \
|
||||
praisonai_tools \
|
||||
"praisonai>=2.3.79" \
|
||||
"praisonai>=2.5.0" \
|
||||
"praisonai[ui]" \
|
||||
"praisonai[crewai]"
|
||||
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Basic Background Tasks Example for PraisonAI Agents.
|
||||
|
||||
This example demonstrates how to use the background task system to:
|
||||
1. Run tasks asynchronously
|
||||
2. Track task progress
|
||||
3. Cancel running tasks
|
||||
4. Handle task completion
|
||||
|
||||
Usage:
|
||||
python basic_background.py
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
|
||||
from praisonaiagents.background import BackgroundRunner, BackgroundConfig, TaskStatus
|
||||
|
||||
|
||||
async def main():
|
||||
print("=" * 60)
|
||||
print("Background Tasks Demo")
|
||||
print("=" * 60)
|
||||
|
||||
# Create a background runner with config
|
||||
config = BackgroundConfig(max_concurrent_tasks=3)
|
||||
runner = BackgroundRunner(config=config)
|
||||
|
||||
# Define some example tasks
|
||||
def slow_task(duration: float, name: str) -> str:
|
||||
"""A slow task that simulates work."""
|
||||
print(f" 🔄 Task '{name}' starting (will take {duration}s)")
|
||||
time.sleep(duration)
|
||||
return f"Task '{name}' completed after {duration}s"
|
||||
|
||||
async def async_task(duration: float, name: str) -> str:
|
||||
"""An async task that simulates work."""
|
||||
print(f" 🔄 Async task '{name}' starting (will take {duration}s)")
|
||||
await asyncio.sleep(duration)
|
||||
return f"Async task '{name}' completed after {duration}s"
|
||||
|
||||
# Submit tasks
|
||||
print("\n--- Submitting Tasks ---")
|
||||
|
||||
task1 = await runner.submit(slow_task, args=(1.0, "quick-job"), name="task1")
|
||||
print(f"✅ Submitted task 1: {task1.id[:8]}")
|
||||
|
||||
task2 = await runner.submit(slow_task, args=(2.0, "medium-job"), name="task2")
|
||||
print(f"✅ Submitted task 2: {task2.id[:8]}")
|
||||
|
||||
task3 = await runner.submit(async_task, args=(1.5, "async-job"), name="task3")
|
||||
print(f"✅ Submitted async task 3: {task3.id[:8]}")
|
||||
|
||||
# List all tasks
|
||||
print("\n--- Task Status ---")
|
||||
for task in runner.tasks:
|
||||
print(f" [{task.id[:8]}] {task.status.value}")
|
||||
|
||||
# Wait for task 1 to complete
|
||||
print("\n--- Waiting for Task 1 ---")
|
||||
await task1.wait(timeout=5.0)
|
||||
print(f"✅ Task 1 result: {task1.result}")
|
||||
|
||||
# Check status of all tasks
|
||||
print("\n--- Updated Task Status ---")
|
||||
for task in [task1, task2, task3]:
|
||||
status = "✅" if task.status == TaskStatus.COMPLETED else "🔄"
|
||||
print(f" {status} [{task.id[:8]}] {task.status.value}")
|
||||
|
||||
# Wait for all remaining tasks
|
||||
print("\n--- Waiting for All Tasks ---")
|
||||
await task2.wait(timeout=10.0)
|
||||
await task3.wait(timeout=10.0)
|
||||
|
||||
# Final status
|
||||
print("\n--- Final Results ---")
|
||||
for task in [task1, task2, task3]:
|
||||
if task.result:
|
||||
print(f" ✅ {task.result}")
|
||||
|
||||
# Cleanup
|
||||
runner.clear_completed()
|
||||
print("\n✅ Cleared completed tasks")
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("Demo Complete!")
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,110 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Basic Checkpoints Example for PraisonAI Agents.
|
||||
|
||||
This example demonstrates how to use the shadow git checkpointing system to:
|
||||
1. Save checkpoints before making changes
|
||||
2. List available checkpoints
|
||||
3. View diffs between checkpoints
|
||||
4. Restore to a previous checkpoint
|
||||
|
||||
Usage:
|
||||
python basic_checkpoints.py
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from praisonaiagents.checkpoints import CheckpointService
|
||||
|
||||
|
||||
async def main():
|
||||
# Create a temporary workspace for demonstration
|
||||
with tempfile.TemporaryDirectory() as workspace:
|
||||
print("=" * 60)
|
||||
print("Shadow Git Checkpointing Demo")
|
||||
print("=" * 60)
|
||||
print(f"\nWorkspace: {workspace}")
|
||||
|
||||
# Create some initial files
|
||||
test_file = Path(workspace) / "example.py"
|
||||
test_file.write_text("# Initial content\nprint('Hello, World!')\n")
|
||||
print(f"\n✅ Created initial file: {test_file.name}")
|
||||
|
||||
# Initialize the checkpoint service
|
||||
service = CheckpointService(workspace_dir=workspace)
|
||||
|
||||
try:
|
||||
# Initialize the shadow git repository
|
||||
print("\n--- Initializing Checkpoint Service ---")
|
||||
await service.initialize()
|
||||
print("✅ Shadow git repository initialized")
|
||||
|
||||
# Save first checkpoint
|
||||
print("\n--- Saving Checkpoint 1: Initial State ---")
|
||||
result1 = await service.save("Initial state")
|
||||
print(f"✅ Checkpoint saved: {result1.checkpoint.id[:8]}")
|
||||
print(f" Message: {result1.checkpoint.message}")
|
||||
|
||||
# Make some changes
|
||||
print("\n--- Making Changes ---")
|
||||
test_file.write_text("# Modified content\nprint('Hello, PraisonAI!')\nprint('Checkpoints are awesome!')\n")
|
||||
print("✅ Modified example.py")
|
||||
|
||||
# Create a new file
|
||||
new_file = Path(workspace) / "utils.py"
|
||||
new_file.write_text("def helper():\n return 'I help!'\n")
|
||||
print("✅ Created utils.py")
|
||||
|
||||
# Save second checkpoint
|
||||
print("\n--- Saving Checkpoint 2: After Changes ---")
|
||||
result2 = await service.save("Added features")
|
||||
print(f"✅ Checkpoint saved: {result2.checkpoint.id[:8]}")
|
||||
|
||||
# List all checkpoints
|
||||
print("\n--- Listing All Checkpoints ---")
|
||||
checkpoints = await service.list_checkpoints()
|
||||
for i, cp in enumerate(checkpoints, 1):
|
||||
print(f" {i}. [{cp.id[:8]}] {cp.message} ({cp.timestamp})")
|
||||
|
||||
# Show diff
|
||||
print("\n--- Current Diff (uncommitted changes) ---")
|
||||
diff = await service.diff()
|
||||
if diff.files:
|
||||
for f in diff.files:
|
||||
print(f" {f.status}: {f.path}")
|
||||
else:
|
||||
print(" No uncommitted changes")
|
||||
|
||||
# Make more changes to show diff
|
||||
test_file.write_text("# Even more changes\nprint('This will be reverted')\n")
|
||||
print("\n✅ Made more changes to example.py")
|
||||
|
||||
diff = await service.diff()
|
||||
print("\n--- New Diff ---")
|
||||
if diff.files:
|
||||
for f in diff.files:
|
||||
print(f" {f.status}: {f.path}")
|
||||
|
||||
# Restore to checkpoint 2
|
||||
print(f"\n--- Restoring to Checkpoint: {result2.checkpoint.id[:8]} ---")
|
||||
await service.restore(result2.checkpoint.id)
|
||||
print("✅ Restored successfully!")
|
||||
|
||||
# Verify restoration
|
||||
content = test_file.read_text()
|
||||
print(f"\n--- Verified Content After Restore ---")
|
||||
print(content)
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n❌ Error: {e}")
|
||||
raise
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("Demo Complete!")
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,93 @@
|
||||
# Persistence CLI Commands
|
||||
|
||||
## Quick Reference
|
||||
|
||||
```bash
|
||||
# Check database connectivity
|
||||
praisonai persistence doctor \
|
||||
--conversation-url postgresql://localhost:5432/praisonai \
|
||||
--state-url redis://localhost:6379
|
||||
|
||||
# Run agent with persistence
|
||||
praisonai persistence run \
|
||||
--conversation-url postgresql://localhost:5432/praisonai \
|
||||
--session-id my-session
|
||||
|
||||
# Resume a session
|
||||
praisonai persistence resume \
|
||||
--conversation-url postgresql://localhost:5432/praisonai \
|
||||
--session-id my-session
|
||||
|
||||
# Export session to file
|
||||
praisonai persistence export \
|
||||
--conversation-url postgresql://localhost:5432/praisonai \
|
||||
--session-id my-session \
|
||||
--output session_backup.jsonl
|
||||
|
||||
# Import session from file
|
||||
praisonai persistence import \
|
||||
--conversation-url postgresql://localhost:5432/praisonai \
|
||||
--file session_backup.jsonl
|
||||
|
||||
# Check schema status
|
||||
praisonai persistence status \
|
||||
--conversation-url postgresql://localhost:5432/praisonai \
|
||||
--state-url redis://localhost:6379
|
||||
|
||||
# Apply migrations
|
||||
praisonai persistence migrate \
|
||||
--conversation-url postgresql://localhost:5432/praisonai \
|
||||
--state-url redis://localhost:6379
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Instead of CLI flags, you can use environment variables:
|
||||
|
||||
```bash
|
||||
export PRAISON_CONVERSATION_URL=postgresql://localhost:5432/praisonai
|
||||
export PRAISON_STATE_URL=redis://localhost:6379
|
||||
export PRAISON_KNOWLEDGE_URL=http://localhost:6333
|
||||
|
||||
# Then run without URL flags
|
||||
praisonai persistence doctor
|
||||
praisonai persistence status
|
||||
```
|
||||
|
||||
## Docker Setup
|
||||
|
||||
```bash
|
||||
# Start all services
|
||||
docker run -d --name praison-postgres \
|
||||
-e POSTGRES_PASSWORD=praison123 \
|
||||
-e POSTGRES_DB=praisonai \
|
||||
-p 5432:5432 postgres:16
|
||||
|
||||
docker run -d --name praison-redis \
|
||||
-p 6379:6379 redis:7
|
||||
|
||||
docker run -d --name praison-qdrant \
|
||||
-p 6333:6333 -p 6334:6334 qdrant/qdrant
|
||||
|
||||
# Verify
|
||||
docker ps
|
||||
```
|
||||
|
||||
## Example Workflow
|
||||
|
||||
```bash
|
||||
# 1. Start services
|
||||
docker-compose up -d
|
||||
|
||||
# 2. Check connectivity
|
||||
praisonai persistence doctor
|
||||
|
||||
# 3. Run agent
|
||||
praisonai persistence run --session-id demo-session
|
||||
|
||||
# 4. Export for backup
|
||||
praisonai persistence export --session-id demo-session --output backup.jsonl
|
||||
|
||||
# 5. Import to another environment
|
||||
praisonai persistence import --file backup.jsonl
|
||||
```
|
||||
@@ -0,0 +1,124 @@
|
||||
# PraisonAI Persistence CLI - End-to-End Guide
|
||||
|
||||
This guide demonstrates the CLI commands for database persistence.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
```bash
|
||||
pip install "praisonai[tools]"
|
||||
|
||||
# Start Docker containers
|
||||
docker run -d --name praison-postgres -p 5432:5432 \
|
||||
-e POSTGRES_PASSWORD=praison123 \
|
||||
-e POSTGRES_DB=praisonai \
|
||||
postgres:16
|
||||
|
||||
docker run -d --name praison-qdrant -p 6333:6333 qdrant/qdrant
|
||||
docker run -d --name praison-redis -p 6379:6379 redis:7
|
||||
```
|
||||
|
||||
## 1. Doctor Command - Validate Connectivity
|
||||
|
||||
```bash
|
||||
# Check all stores
|
||||
praisonai persistence doctor \
|
||||
--conversation-url "postgresql://postgres:praison123@localhost:5432/praisonai" \
|
||||
--knowledge-url "http://localhost:6333" \
|
||||
--state-url "redis://localhost:6379"
|
||||
```
|
||||
|
||||
**Expected Output:**
|
||||
```
|
||||
==================================================
|
||||
PraisonAI Persistence Doctor
|
||||
==================================================
|
||||
|
||||
[Conversation Store] Testing: postgresql://postgres:****@localhost:5432/praisonai
|
||||
✅ Connected (postgres)
|
||||
|
||||
[Knowledge Store] Testing: http://localhost:6333
|
||||
✅ Connected (qdrant)
|
||||
|
||||
[State Store] Testing: redis://localhost:6379
|
||||
✅ Connected (redis)
|
||||
|
||||
==================================================
|
||||
Results: 3/3 stores connected successfully
|
||||
==================================================
|
||||
```
|
||||
|
||||
## 2. Run Command - Execute Agent with Persistence
|
||||
|
||||
```bash
|
||||
# Run agent with persistence (dry-run first)
|
||||
praisonai persistence run \
|
||||
--session-id "cli-demo-001" \
|
||||
--conversation-url "postgresql://postgres:praison123@localhost:5432/praisonai" \
|
||||
--dry-run \
|
||||
"Hello, my name is Bob"
|
||||
|
||||
# Actually run it
|
||||
praisonai persistence run \
|
||||
--session-id "cli-demo-001" \
|
||||
--conversation-url "postgresql://postgres:praison123@localhost:5432/praisonai" \
|
||||
"Hello, my name is Bob"
|
||||
```
|
||||
|
||||
**Expected Output:**
|
||||
```
|
||||
[Session: cli-demo-001]
|
||||
Agent: Hello Bob! Nice to meet you. How can I help you today?
|
||||
```
|
||||
|
||||
## 3. Resume Command - Continue a Session
|
||||
|
||||
```bash
|
||||
# Show history
|
||||
praisonai persistence resume \
|
||||
--session-id "cli-demo-001" \
|
||||
--conversation-url "postgresql://postgres:praison123@localhost:5432/praisonai" \
|
||||
--show-history
|
||||
|
||||
# Continue conversation
|
||||
praisonai persistence resume \
|
||||
--session-id "cli-demo-001" \
|
||||
--conversation-url "postgresql://postgres:praison123@localhost:5432/praisonai" \
|
||||
--continue "What's my name?"
|
||||
```
|
||||
|
||||
**Expected Output:**
|
||||
```
|
||||
[Session: cli-demo-001]
|
||||
Messages in history: 2
|
||||
|
||||
--- Conversation History ---
|
||||
[USER] Hello, my name is Bob
|
||||
[ASSISTANT] Hello Bob! Nice to meet you...
|
||||
--- End History ---
|
||||
|
||||
Agent: Your name is Bob!
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Instead of passing URLs on command line, use environment variables:
|
||||
|
||||
```bash
|
||||
export PRAISON_CONVERSATION_URL="postgresql://postgres:praison123@localhost:5432/praisonai"
|
||||
export PRAISON_KNOWLEDGE_URL="http://localhost:6333"
|
||||
export PRAISON_STATE_URL="redis://localhost:6379"
|
||||
export OPENAI_API_KEY="your-key"
|
||||
|
||||
# Now commands are simpler
|
||||
praisonai persistence doctor --all
|
||||
praisonai persistence run --session-id "my-session" "Hello!"
|
||||
```
|
||||
|
||||
## Full CLI Reference
|
||||
|
||||
```
|
||||
praisonai persistence --help
|
||||
praisonai persistence doctor --help
|
||||
praisonai persistence run --help
|
||||
praisonai persistence resume --help
|
||||
```
|
||||
@@ -0,0 +1,146 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Basic Context Compaction Example for PraisonAI Agents.
|
||||
|
||||
This example demonstrates how to use context compaction to:
|
||||
1. Manage conversation context length
|
||||
2. Use different compaction strategies
|
||||
3. Track compaction results
|
||||
4. Preserve important messages
|
||||
|
||||
Usage:
|
||||
python basic_compaction.py
|
||||
"""
|
||||
|
||||
from praisonaiagents.compaction import (
|
||||
ContextCompactor, CompactionConfig, CompactionStrategy, CompactionResult
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 60)
|
||||
print("Context Compaction Demo")
|
||||
print("=" * 60)
|
||||
|
||||
# ==========================================================================
|
||||
# Sample Messages
|
||||
# ==========================================================================
|
||||
messages = [
|
||||
{"role": "system", "content": "You are a helpful AI assistant."},
|
||||
{"role": "user", "content": "Hello! Can you help me with Python?"},
|
||||
{"role": "assistant", "content": "Of course! I'd be happy to help you with Python. What would you like to know?"},
|
||||
{"role": "user", "content": "How do I read a file in Python?"},
|
||||
{"role": "assistant", "content": "To read a file in Python, you can use the built-in open() function. Here's an example:\n\nwith open('filename.txt', 'r') as f:\n content = f.read()\n\nThis opens the file, reads its content, and automatically closes it."},
|
||||
{"role": "user", "content": "What about writing to a file?"},
|
||||
{"role": "assistant", "content": "To write to a file, use the 'w' mode:\n\nwith open('filename.txt', 'w') as f:\n f.write('Hello, World!')\n\nUse 'a' mode to append instead of overwriting."},
|
||||
{"role": "user", "content": "Can you explain list comprehensions?"},
|
||||
{"role": "assistant", "content": "List comprehensions are a concise way to create lists. Instead of:\n\nresult = []\nfor x in range(10):\n result.append(x * 2)\n\nYou can write:\n\nresult = [x * 2 for x in range(10)]"},
|
||||
]
|
||||
|
||||
print(f"\n--- Original Messages ---")
|
||||
print(f" Message count: {len(messages)}")
|
||||
|
||||
# ==========================================================================
|
||||
# Basic Compaction
|
||||
# ==========================================================================
|
||||
print("\n--- Basic Compaction (Truncate Strategy) ---")
|
||||
|
||||
compactor = ContextCompactor(
|
||||
max_tokens=50, # Low limit to trigger compaction
|
||||
strategy=CompactionStrategy.TRUNCATE,
|
||||
preserve_system=True,
|
||||
preserve_recent=2
|
||||
)
|
||||
|
||||
# Check stats before
|
||||
stats = compactor.get_stats(messages)
|
||||
print(f" Total tokens: {stats['total_tokens']}")
|
||||
print(f" Max tokens: {stats['max_tokens']}")
|
||||
print(f" Needs compaction: {stats['needs_compaction']}")
|
||||
|
||||
# Compact
|
||||
compacted, result = compactor.compact(messages)
|
||||
|
||||
print(f"\n After compaction:")
|
||||
print(f" Messages: {len(messages)} -> {len(compacted)}")
|
||||
print(f" Tokens: {result.original_tokens} -> {result.compacted_tokens}")
|
||||
print(f" Saved: {result.tokens_saved} tokens")
|
||||
print(f" Compression: {result.compression_ratio:.1%}")
|
||||
|
||||
# ==========================================================================
|
||||
# Sliding Window Strategy
|
||||
# ==========================================================================
|
||||
print("\n--- Sliding Window Strategy ---")
|
||||
|
||||
compactor = ContextCompactor(
|
||||
max_tokens=50,
|
||||
strategy=CompactionStrategy.SLIDING,
|
||||
preserve_system=True,
|
||||
preserve_recent=3
|
||||
)
|
||||
|
||||
compacted, result = compactor.compact(messages)
|
||||
|
||||
print(f" Messages kept: {result.messages_kept}")
|
||||
print(f" Messages removed: {result.messages_removed}")
|
||||
print(f" Strategy: {result.strategy_used.value}")
|
||||
|
||||
# ==========================================================================
|
||||
# Summarize Strategy
|
||||
# ==========================================================================
|
||||
print("\n--- Summarize Strategy ---")
|
||||
|
||||
compactor = ContextCompactor(
|
||||
max_tokens=50,
|
||||
strategy=CompactionStrategy.SUMMARIZE,
|
||||
preserve_system=True,
|
||||
preserve_recent=2
|
||||
)
|
||||
|
||||
compacted, result = compactor.compact(messages)
|
||||
|
||||
print(f" Messages kept: {result.messages_kept}")
|
||||
print(f" Strategy: {result.strategy_used.value}")
|
||||
|
||||
# Show what messages remain
|
||||
print(f"\n Remaining message roles:")
|
||||
for msg in compacted:
|
||||
role = msg.get("role", "unknown")
|
||||
content_preview = msg.get("content", "")[:50]
|
||||
print(f" [{role}] {content_preview}...")
|
||||
|
||||
# ==========================================================================
|
||||
# No Compaction Needed
|
||||
# ==========================================================================
|
||||
print("\n--- No Compaction Needed ---")
|
||||
|
||||
compactor = ContextCompactor(
|
||||
max_tokens=10000, # High limit
|
||||
strategy=CompactionStrategy.TRUNCATE
|
||||
)
|
||||
|
||||
compacted, result = compactor.compact(messages)
|
||||
|
||||
print(f" Was compacted: {result.was_compacted}")
|
||||
print(f" Messages unchanged: {len(compacted) == len(messages)}")
|
||||
|
||||
# ==========================================================================
|
||||
# Result Serialization
|
||||
# ==========================================================================
|
||||
print("\n--- Result Serialization ---")
|
||||
|
||||
compactor = ContextCompactor(max_tokens=50)
|
||||
_, result = compactor.compact(messages)
|
||||
|
||||
data = result.to_dict()
|
||||
print(f" Original tokens: {data['original_tokens']}")
|
||||
print(f" Compacted tokens: {data['compacted_tokens']}")
|
||||
print(f" Compression ratio: {data['compression_ratio']:.2f}")
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("Demo Complete!")
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,142 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Basic Hooks Example for PraisonAI Agents.
|
||||
|
||||
This example demonstrates how to use the hooks system to:
|
||||
1. Log tool calls before execution
|
||||
2. Block dangerous operations
|
||||
3. Add context to agent responses
|
||||
4. Monitor session lifecycle
|
||||
|
||||
Usage:
|
||||
python basic_hooks.py
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.hooks import (
|
||||
HookRegistry, HookRunner, HookEvent, HookResult,
|
||||
BeforeToolInput, AfterToolInput
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
# Create a hook registry
|
||||
registry = HookRegistry()
|
||||
|
||||
# ==========================================================================
|
||||
# Hook 1: Log all tool calls
|
||||
# ==========================================================================
|
||||
@registry.on(HookEvent.BEFORE_TOOL)
|
||||
def log_tool_calls(event_data: BeforeToolInput) -> HookResult:
|
||||
"""Log every tool call before execution."""
|
||||
print(f"\n📝 [LOG] Tool: {event_data.tool_name}")
|
||||
print(f" Input: {event_data.tool_input}")
|
||||
return HookResult.allow()
|
||||
|
||||
# ==========================================================================
|
||||
# Hook 2: Block dangerous file operations (using function logic)
|
||||
# ==========================================================================
|
||||
@registry.on(HookEvent.BEFORE_TOOL)
|
||||
def block_delete_operations(event_data: BeforeToolInput) -> HookResult:
|
||||
"""Block any delete or remove operations."""
|
||||
dangerous_keywords = ['delete', 'remove', 'rm', 'unlink']
|
||||
tool_lower = event_data.tool_name.lower()
|
||||
|
||||
if any(kw in tool_lower for kw in dangerous_keywords):
|
||||
print(f"\n🚫 [BLOCKED] Dangerous operation: {event_data.tool_name}")
|
||||
return HookResult.deny(
|
||||
f"Operation '{event_data.tool_name}' is blocked by security policy"
|
||||
)
|
||||
return HookResult.allow()
|
||||
|
||||
# ==========================================================================
|
||||
# Hook 3: Require confirmation for write operations
|
||||
# ==========================================================================
|
||||
@registry.on(HookEvent.BEFORE_TOOL)
|
||||
def confirm_write_operations(event_data: BeforeToolInput) -> HookResult:
|
||||
"""Request confirmation for write operations."""
|
||||
write_keywords = ['write', 'save', 'create', 'update']
|
||||
tool_lower = event_data.tool_name.lower()
|
||||
|
||||
if any(kw in tool_lower for kw in write_keywords):
|
||||
print(f"\n⚠️ [CONFIRM] Write operation: {event_data.tool_name}")
|
||||
# In a real scenario, you might prompt the user here
|
||||
# For this example, we'll auto-approve
|
||||
return HookResult.allow("Auto-approved for demo")
|
||||
return HookResult.allow()
|
||||
|
||||
# ==========================================================================
|
||||
# Hook 4: Add timing information after tool execution
|
||||
# ==========================================================================
|
||||
@registry.on(HookEvent.AFTER_TOOL)
|
||||
def log_tool_completion(event_data: AfterToolInput) -> HookResult:
|
||||
"""Log tool completion with timing."""
|
||||
print(f"\n✅ [DONE] Tool: {event_data.tool_name}")
|
||||
print(f" Duration: {event_data.execution_time_ms:.2f}ms")
|
||||
if event_data.tool_error:
|
||||
print(f" Error: {event_data.tool_error}")
|
||||
return HookResult.allow()
|
||||
|
||||
# ==========================================================================
|
||||
# Test the hooks
|
||||
# ==========================================================================
|
||||
print("=" * 60)
|
||||
print("Testing Hooks System")
|
||||
print("=" * 60)
|
||||
|
||||
runner = HookRunner(registry)
|
||||
|
||||
# Test 1: Normal tool call (should be logged and allowed)
|
||||
print("\n--- Test 1: Normal read operation ---")
|
||||
input1 = BeforeToolInput(
|
||||
session_id="test",
|
||||
cwd="/tmp",
|
||||
event_name="before_tool",
|
||||
timestamp="2024-01-01T00:00:00",
|
||||
tool_name="read_file",
|
||||
tool_input={"path": "/tmp/test.txt"}
|
||||
)
|
||||
results = asyncio.run(runner.execute(HookEvent.BEFORE_TOOL, input1))
|
||||
print(f"Result: {'ALLOWED' if not HookRunner.is_blocked(results) else 'BLOCKED'}")
|
||||
|
||||
# Test 2: Delete operation (should be blocked)
|
||||
print("\n--- Test 2: Delete operation ---")
|
||||
input2 = BeforeToolInput(
|
||||
session_id="test",
|
||||
cwd="/tmp",
|
||||
event_name="before_tool",
|
||||
timestamp="2024-01-01T00:00:00",
|
||||
tool_name="delete_file",
|
||||
tool_input={"path": "/important/data.txt"}
|
||||
)
|
||||
results = asyncio.run(runner.execute(HookEvent.BEFORE_TOOL, input2))
|
||||
print(f"Result: {'ALLOWED' if not HookRunner.is_blocked(results) else 'BLOCKED'}")
|
||||
if HookRunner.is_blocked(results):
|
||||
print(f"Reason: {HookRunner.get_blocking_reason(results)}")
|
||||
|
||||
# Test 3: Write operation (should be confirmed)
|
||||
print("\n--- Test 3: Write operation ---")
|
||||
input3 = BeforeToolInput(
|
||||
session_id="test",
|
||||
cwd="/tmp",
|
||||
event_name="before_tool",
|
||||
timestamp="2024-01-01T00:00:00",
|
||||
tool_name="write_file",
|
||||
tool_input={"path": "/tmp/output.txt", "content": "Hello"}
|
||||
)
|
||||
results = asyncio.run(runner.execute(HookEvent.BEFORE_TOOL, input3))
|
||||
print(f"Result: {'ALLOWED' if not HookRunner.is_blocked(results) else 'BLOCKED'}")
|
||||
|
||||
# Show registered hooks
|
||||
print("\n" + "=" * 60)
|
||||
print("Registered Hooks:")
|
||||
print("=" * 60)
|
||||
for event, hooks in registry.list_hooks().items():
|
||||
print(f"\n{event}:")
|
||||
for hook in hooks:
|
||||
print(f" - {hook['name']} (matcher: {hook['matcher'] or '*'})")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,55 @@
|
||||
"""
|
||||
Basic Middleware Example - PraisonAI Agents
|
||||
|
||||
Demonstrates before/after hooks and wrap decorators for model and tool calls.
|
||||
"""
|
||||
|
||||
from praisonaiagents import Agent, tool
|
||||
from praisonaiagents.hooks import (
|
||||
before_model, after_model, wrap_model_call,
|
||||
before_tool, after_tool, wrap_tool_call,
|
||||
InvocationContext, ModelRequest, ModelResponse
|
||||
)
|
||||
|
||||
# Simple tool for demonstration
|
||||
@tool
|
||||
def get_weather(city: str) -> str:
|
||||
"""Get weather for a city."""
|
||||
return f"Sunny, 22°C in {city}"
|
||||
|
||||
# Before model hook - adds context
|
||||
@before_model
|
||||
def add_context(request: ModelRequest) -> ModelRequest:
|
||||
print(f"[before_model] Adding context to request")
|
||||
return request
|
||||
|
||||
# After model hook - logs response
|
||||
@after_model
|
||||
def log_response(response: ModelResponse) -> ModelResponse:
|
||||
print(f"[after_model] Response received")
|
||||
return response
|
||||
|
||||
# Wrap tool call - retry on error
|
||||
@wrap_tool_call
|
||||
def retry_on_error(tool_call, call_next):
|
||||
print(f"[wrap_tool_call] Executing tool")
|
||||
try:
|
||||
return call_next(tool_call)
|
||||
except Exception as e:
|
||||
print(f"[wrap_tool_call] Retrying after error: {e}")
|
||||
return call_next(tool_call)
|
||||
|
||||
# Create agent with hooks
|
||||
agent = Agent(
|
||||
name="WeatherBot",
|
||||
instructions="You help with weather queries.",
|
||||
tools=[get_weather],
|
||||
hooks=[add_context, log_response, retry_on_error]
|
||||
)
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Test the tool directly
|
||||
result = get_weather("London")
|
||||
print(f"Weather: {result}")
|
||||
|
||||
print("\n✓ Middleware example complete")
|
||||
@@ -0,0 +1,31 @@
|
||||
"""
|
||||
Configurable Model Example - PraisonAI Agents
|
||||
|
||||
Demonstrates runtime model switching without recreating the agent.
|
||||
"""
|
||||
|
||||
from praisonaiagents import Agent
|
||||
|
||||
# Create agent with configurable model enabled
|
||||
agent = Agent(
|
||||
name="FlexBot",
|
||||
instructions="You are a helpful assistant.",
|
||||
llm="gpt-4o-mini",
|
||||
llm_config={"configurable": True}
|
||||
)
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Default model call
|
||||
print("Using default model (gpt-4o-mini)...")
|
||||
# response = agent.chat("Say hello in 5 words")
|
||||
|
||||
# Override model per-call
|
||||
print("\nOverriding to different model...")
|
||||
# response = agent.chat("Say hello in 5 words", config={"model": "gpt-4o"})
|
||||
|
||||
# Override temperature
|
||||
print("\nOverriding temperature...")
|
||||
# response = agent.chat("Say hello creatively", config={"temperature": 0.9})
|
||||
|
||||
print("\n✓ Configurable model example complete")
|
||||
print("Note: Uncomment agent.chat() calls to run with API key")
|
||||
@@ -0,0 +1,50 @@
|
||||
"""
|
||||
Injected State Example - PraisonAI Agents
|
||||
|
||||
Demonstrates injecting agent state into tools without exposing it in the schema.
|
||||
"""
|
||||
|
||||
from praisonaiagents import Agent, tool
|
||||
from praisonaiagents.tools import Injected
|
||||
from praisonaiagents.tools.injected import AgentState, with_injection_context
|
||||
|
||||
# Tool with injected state - state param is NOT in the public schema
|
||||
@tool
|
||||
def show_context(query: str, state: Injected[dict]) -> str:
|
||||
"""Show the current agent context."""
|
||||
session_id = state.get('session_id', 'unknown')
|
||||
agent_id = state.get('agent_id', 'unknown')
|
||||
return f"Query: {query}, Session: {session_id}, Agent: {agent_id}"
|
||||
|
||||
# Create agent with the tool
|
||||
agent = Agent(
|
||||
name="ContextBot",
|
||||
instructions="You help show context information.",
|
||||
tools=[show_context],
|
||||
session_id="my-session-123"
|
||||
)
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Verify injected param is not in schema
|
||||
schema = show_context.get_schema()
|
||||
params = schema['function']['parameters']['properties']
|
||||
print(f"Schema params: {list(params.keys())}")
|
||||
assert 'state' not in params, "state should NOT be in schema"
|
||||
print("✓ 'state' correctly excluded from schema")
|
||||
|
||||
# Test with manual injection context
|
||||
mock_state = AgentState(
|
||||
agent_id="test-agent",
|
||||
run_id="run-1",
|
||||
session_id="session-abc"
|
||||
)
|
||||
|
||||
with with_injection_context(mock_state):
|
||||
result = show_context(query="hello")
|
||||
print(f"Result: {result}")
|
||||
|
||||
# Test via agent.execute_tool
|
||||
result = agent.execute_tool("show_context", {"query": "test"})
|
||||
print(f"Agent result: {result}")
|
||||
|
||||
print("\n✓ Injected state example complete")
|
||||
@@ -0,0 +1,29 @@
|
||||
"""
|
||||
Rate Limiter Example - PraisonAI Agents
|
||||
|
||||
Demonstrates token bucket rate limiting for LLM API calls.
|
||||
"""
|
||||
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.llm import RateLimiter
|
||||
|
||||
# Create rate limiter: 60 requests per minute
|
||||
limiter = RateLimiter(requests_per_minute=60, burst=5)
|
||||
|
||||
# Create agent with rate limiter
|
||||
agent = Agent(
|
||||
name="RateLimitedBot",
|
||||
instructions="You are a helpful assistant.",
|
||||
rate_limiter=limiter
|
||||
)
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(f"Rate limiter: {limiter}")
|
||||
print(f"Available tokens: {limiter.available_tokens}")
|
||||
|
||||
# Demonstrate rate limiting
|
||||
for i in range(3):
|
||||
limiter.acquire()
|
||||
print(f"Request {i+1} acquired, tokens left: {limiter.available_tokens:.1f}")
|
||||
|
||||
print("\n✓ Rate limiter example complete")
|
||||
@@ -0,0 +1,122 @@
|
||||
"""
|
||||
Multi-Agent Persistence Example
|
||||
|
||||
Demonstrates how multiple agents can share or isolate conversation
|
||||
history using the persistence layer.
|
||||
"""
|
||||
|
||||
from praisonai.persistence.factory import create_conversation_store, create_state_store
|
||||
from praisonai.persistence.conversation.base import ConversationSession, ConversationMessage
|
||||
import uuid
|
||||
|
||||
|
||||
def main():
|
||||
# Create stores
|
||||
conv_store = create_conversation_store("sqlite", path=":memory:")
|
||||
state_store = create_state_store("memory")
|
||||
|
||||
print("=== Multi-Agent Persistence Demo ===\n")
|
||||
|
||||
# Scenario 1: Shared session (agents collaborate)
|
||||
print("--- Scenario 1: Shared Session ---")
|
||||
shared_session_id = f"shared-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
session = ConversationSession(
|
||||
session_id=shared_session_id,
|
||||
user_id="user-123",
|
||||
name="Shared Conversation"
|
||||
)
|
||||
conv_store.create_session(session)
|
||||
|
||||
# Agent 1 (Researcher) adds context
|
||||
conv_store.add_message(shared_session_id, ConversationMessage(
|
||||
role="assistant",
|
||||
content="[Researcher] I found relevant information about the topic.",
|
||||
metadata={"agent_id": "researcher", "agent_role": "research"}
|
||||
))
|
||||
|
||||
# Agent 2 (Writer) uses the context
|
||||
conv_store.add_message(shared_session_id, ConversationMessage(
|
||||
role="assistant",
|
||||
content="[Writer] Based on the research, here's a summary...",
|
||||
metadata={"agent_id": "writer", "agent_role": "writing"}
|
||||
))
|
||||
|
||||
# Agent 3 (Editor) reviews
|
||||
conv_store.add_message(shared_session_id, ConversationMessage(
|
||||
role="assistant",
|
||||
content="[Editor] I've reviewed and polished the content.",
|
||||
metadata={"agent_id": "editor", "agent_role": "editing"}
|
||||
))
|
||||
|
||||
messages = conv_store.get_messages(shared_session_id)
|
||||
print(f"Shared session has {len(messages)} messages from different agents")
|
||||
for msg in messages:
|
||||
agent = msg.metadata.get("agent_id", "unknown") if msg.metadata else "unknown"
|
||||
print(f" - {agent}: {msg.content[:50]}...")
|
||||
|
||||
# Scenario 2: Isolated sessions (agents work independently)
|
||||
print("\n--- Scenario 2: Isolated Sessions ---")
|
||||
|
||||
agent_sessions = {}
|
||||
for agent_name in ["planner", "executor", "validator"]:
|
||||
session_id = f"{agent_name}-{uuid.uuid4().hex[:8]}"
|
||||
agent_sessions[agent_name] = session_id
|
||||
|
||||
conv_store.create_session(ConversationSession(
|
||||
session_id=session_id,
|
||||
agent_id=agent_name,
|
||||
name=f"{agent_name.title()} Session"
|
||||
))
|
||||
|
||||
# Each agent has its own conversation
|
||||
conv_store.add_message(session_id, ConversationMessage(
|
||||
role="user",
|
||||
content=f"Task for {agent_name}"
|
||||
))
|
||||
conv_store.add_message(session_id, ConversationMessage(
|
||||
role="assistant",
|
||||
content=f"[{agent_name.title()}] Processing task..."
|
||||
))
|
||||
|
||||
print("Created isolated sessions for each agent:")
|
||||
for agent, sid in agent_sessions.items():
|
||||
msgs = conv_store.get_messages(sid)
|
||||
print(f" - {agent}: {len(msgs)} messages")
|
||||
|
||||
# Scenario 3: Shared state across agents
|
||||
print("\n--- Scenario 3: Shared State ---")
|
||||
|
||||
# Agents share state for coordination
|
||||
state_store.set("workflow:status", {"phase": "research", "progress": 0})
|
||||
state_store.set("workflow:context", {"topic": "AI Agents", "deadline": "2024-12-31"})
|
||||
|
||||
# Agent 1 updates progress
|
||||
state = state_store.get("workflow:status")
|
||||
state["progress"] = 33
|
||||
state["phase"] = "writing"
|
||||
state_store.set("workflow:status", state)
|
||||
print(f"Agent 1 updated state: {state}")
|
||||
|
||||
# Agent 2 reads and updates
|
||||
state = state_store.get("workflow:status")
|
||||
state["progress"] = 66
|
||||
state["phase"] = "editing"
|
||||
state_store.set("workflow:status", state)
|
||||
print(f"Agent 2 updated state: {state}")
|
||||
|
||||
# Agent 3 completes
|
||||
state = state_store.get("workflow:status")
|
||||
state["progress"] = 100
|
||||
state["phase"] = "complete"
|
||||
state_store.set("workflow:status", state)
|
||||
print(f"Agent 3 completed: {state}")
|
||||
|
||||
# Cleanup
|
||||
conv_store.close()
|
||||
state_store.close()
|
||||
print("\n✅ Multi-agent persistence demo complete!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Multi-Agent Shared Session - DB Persistence Test"""
|
||||
import sys
|
||||
sys.path.insert(0, 'src/praisonai')
|
||||
from praisonai.persistence import create_conversation_store
|
||||
from praisonai.persistence.conversation.base import ConversationSession, ConversationMessage
|
||||
|
||||
# Create shared session for multiple agents
|
||||
store = create_conversation_store("sqlite", path="/tmp/multi_agent.db")
|
||||
session_id = "shared-session-123"
|
||||
|
||||
# Create session
|
||||
session = ConversationSession(session_id=session_id, agent_id="multi")
|
||||
try:
|
||||
store.create_session(session)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Agent 1 writes
|
||||
msg1 = ConversationMessage(session_id=session_id, role="user", content="Agent1: Hello")
|
||||
store.add_message(session_id, msg1)
|
||||
msg2 = ConversationMessage(session_id=session_id, role="assistant", content="Agent1: Hi there!")
|
||||
store.add_message(session_id, msg2)
|
||||
|
||||
# Agent 2 writes to same session
|
||||
msg3 = ConversationMessage(session_id=session_id, role="user", content="Agent2: What did Agent1 say?")
|
||||
store.add_message(session_id, msg3)
|
||||
msg4 = ConversationMessage(session_id=session_id, role="assistant", content="Agent2: Agent1 said Hello")
|
||||
store.add_message(session_id, msg4)
|
||||
|
||||
# Verify both agents' messages are in session
|
||||
messages = store.get_messages(session_id)
|
||||
print(f"Total messages in shared session: {len(messages)}")
|
||||
assert len(messages) >= 4, "Should have messages from both agents"
|
||||
|
||||
# Verify content
|
||||
contents = [m.content for m in messages]
|
||||
assert any("Agent1" in c for c in contents), "Should have Agent1 messages"
|
||||
assert any("Agent2" in c for c in contents), "Should have Agent2 messages"
|
||||
|
||||
print("PASSED: Multi-agent shared session persistence")
|
||||
@@ -0,0 +1,16 @@
|
||||
"""Multi-Agent Shared Session Demo"""
|
||||
from praisonaiagents import Agent
|
||||
from praisonai.persistence import create_conversation_store
|
||||
|
||||
store = create_conversation_store("sqlite", path="/tmp/multi_agent.db")
|
||||
|
||||
agent1 = Agent(name="Researcher", llm="gpt-4o-mini", db=store, session_id="shared-session")
|
||||
agent2 = Agent(name="Writer", llm="gpt-4o-mini", db=store, session_id="shared-session")
|
||||
|
||||
r1 = agent1.chat("Research topic: AI in healthcare. Key finding: reduces diagnosis time by 50%")
|
||||
print(f"Researcher: {r1[:50]}...")
|
||||
|
||||
r2 = agent2.chat("Based on the research, what was the key finding?")
|
||||
print(f"Writer: {r2[:50]}...")
|
||||
assert "50" in r2 or "diagnosis" in r2.lower(), "Writer should see Researcher's context"
|
||||
print("PASSED: Multi-agent shared session works")
|
||||
@@ -0,0 +1,48 @@
|
||||
"""
|
||||
Basic Observability Usage Example
|
||||
|
||||
This example demonstrates how to use the observability module
|
||||
with PraisonAI agents.
|
||||
|
||||
Usage:
|
||||
# Set your provider's API key first
|
||||
export LANGFUSE_PUBLIC_KEY=pk-lf-xxx
|
||||
export LANGFUSE_SECRET_KEY=sk-lf-xxx
|
||||
|
||||
python basic_usage.py
|
||||
"""
|
||||
|
||||
from praisonai_tools.observability import obs
|
||||
from praisonaiagents import Agent
|
||||
|
||||
# Initialize observability (auto-detects provider from env vars)
|
||||
obs.init()
|
||||
|
||||
# Or specify a provider explicitly
|
||||
# obs.init(provider="langfuse", project_name="my-project")
|
||||
|
||||
# Create an agent
|
||||
agent = Agent(
|
||||
instructions="You are a helpful assistant.",
|
||||
model="gpt-4o-mini",
|
||||
)
|
||||
|
||||
# Use trace context manager for complete workflow tracing
|
||||
with obs.trace("chat-session", session_id="user-123"):
|
||||
# Use span context manager for individual operations
|
||||
with obs.span("user-query", kind=obs.SpanKind.AGENT):
|
||||
response = agent.chat("What is the capital of France?")
|
||||
print(response)
|
||||
|
||||
# Log LLM calls manually if needed
|
||||
obs.log_llm_call(
|
||||
model="gpt-4o-mini",
|
||||
input_messages="What is 2+2?",
|
||||
output="4",
|
||||
input_tokens=10,
|
||||
output_tokens=1,
|
||||
)
|
||||
|
||||
# Check diagnostics
|
||||
print("\nDiagnostics:")
|
||||
print(obs.doctor())
|
||||
@@ -0,0 +1,48 @@
|
||||
"""
|
||||
Langfuse Integration Example
|
||||
|
||||
This example shows how to use Langfuse for LLM observability.
|
||||
|
||||
Setup:
|
||||
1. Sign up at https://langfuse.com/
|
||||
2. Get your API keys from the project settings
|
||||
3. Set environment variables:
|
||||
export LANGFUSE_PUBLIC_KEY=pk-lf-xxx
|
||||
export LANGFUSE_SECRET_KEY=sk-lf-xxx
|
||||
4. Install dependencies:
|
||||
pip install opentelemetry-sdk opentelemetry-exporter-otlp
|
||||
|
||||
Usage:
|
||||
python langfuse_example.py
|
||||
"""
|
||||
|
||||
import os
|
||||
from praisonai_tools.observability import obs
|
||||
from praisonaiagents import Agent
|
||||
|
||||
# Initialize Langfuse
|
||||
success = obs.init(
|
||||
provider="langfuse",
|
||||
project_name="praisonai-demo",
|
||||
)
|
||||
|
||||
if not success:
|
||||
print("Failed to initialize Langfuse. Check your API keys.")
|
||||
print("Required: LANGFUSE_PUBLIC_KEY, LANGFUSE_SECRET_KEY")
|
||||
exit(1)
|
||||
|
||||
print("Langfuse initialized successfully!")
|
||||
print(f"View traces at: https://cloud.langfuse.com/")
|
||||
|
||||
# Create agent
|
||||
agent = Agent(
|
||||
instructions="You are a helpful coding assistant.",
|
||||
model="gpt-4o-mini",
|
||||
)
|
||||
|
||||
# Run with tracing
|
||||
with obs.trace("coding-session", user_id="developer-1"):
|
||||
response = agent.chat("Write a Python function to calculate fibonacci numbers")
|
||||
print(response)
|
||||
|
||||
print("\nCheck Langfuse dashboard for traces!")
|
||||
@@ -0,0 +1,22 @@
|
||||
"""MLflow Local Observability Demo (no API key needed)"""
|
||||
import mlflow
|
||||
import time
|
||||
|
||||
mlflow.set_tracking_uri("file:///tmp/mlflow_demo")
|
||||
mlflow.set_experiment("praisonai-demo")
|
||||
|
||||
with mlflow.start_run(run_name="agent-trace"):
|
||||
mlflow.log_param("agent_name", "Assistant")
|
||||
mlflow.log_param("model", "gpt-4o-mini")
|
||||
|
||||
# Simulate agent work
|
||||
start = time.time()
|
||||
time.sleep(0.1)
|
||||
latency = time.time() - start
|
||||
|
||||
mlflow.log_metric("latency_ms", latency * 1000)
|
||||
mlflow.log_metric("tokens_used", 150)
|
||||
|
||||
print(f"Logged run to MLflow: latency={latency*1000:.1f}ms")
|
||||
|
||||
print("PASSED: MLflow local observability works")
|
||||
@@ -0,0 +1,53 @@
|
||||
"""
|
||||
Multi-Agent Tracing Example
|
||||
|
||||
This example shows how to trace multi-agent workflows with
|
||||
parent/child span relationships.
|
||||
|
||||
Usage:
|
||||
# Set your provider's API key
|
||||
export LANGFUSE_PUBLIC_KEY=pk-lf-xxx
|
||||
export LANGFUSE_SECRET_KEY=sk-lf-xxx
|
||||
|
||||
python multi_agent_tracing.py
|
||||
"""
|
||||
|
||||
from praisonai_tools.observability import obs
|
||||
from praisonai_tools.observability.base import SpanKind
|
||||
from praisonaiagents import Agent, PraisonAIAgents
|
||||
|
||||
# Initialize observability
|
||||
obs.init()
|
||||
|
||||
# Create agents
|
||||
researcher = Agent(
|
||||
name="Researcher",
|
||||
instructions="You research topics and provide detailed information.",
|
||||
model="gpt-4o-mini",
|
||||
)
|
||||
|
||||
writer = Agent(
|
||||
name="Writer",
|
||||
instructions="You write clear, engaging content based on research.",
|
||||
model="gpt-4o-mini",
|
||||
)
|
||||
|
||||
# Trace the multi-agent workflow
|
||||
with obs.trace("content-creation", metadata={"workflow": "research-write"}):
|
||||
|
||||
# Research phase
|
||||
with obs.span("research-phase", kind=SpanKind.AGENT) as research_span:
|
||||
research_span.attributes["agent"] = "Researcher"
|
||||
research_result = researcher.chat("Research the benefits of AI in healthcare")
|
||||
|
||||
# Writing phase
|
||||
with obs.span("writing-phase", kind=SpanKind.AGENT) as write_span:
|
||||
write_span.attributes["agent"] = "Writer"
|
||||
write_span.attributes["input_length"] = len(str(research_result))
|
||||
final_content = writer.chat(f"Write a blog post based on: {research_result}")
|
||||
|
||||
print("Final Content:")
|
||||
print(final_content)
|
||||
|
||||
# Check trace info
|
||||
print("\nDiagnostics:", obs.doctor())
|
||||
@@ -0,0 +1,79 @@
|
||||
"""
|
||||
Qdrant Knowledge Store Example for PraisonAI.
|
||||
|
||||
Demonstrates vector insert + search through the knowledge store.
|
||||
|
||||
Requirements:
|
||||
pip install "praisonai[tools]"
|
||||
|
||||
Docker Setup:
|
||||
docker run -d --name praison-qdrant -p 6333:6333 qdrant/qdrant
|
||||
|
||||
Run:
|
||||
python knowledge_qdrant.py
|
||||
|
||||
Expected Output:
|
||||
=== Qdrant Knowledge Store Demo ===
|
||||
Created collection: demo_knowledge
|
||||
Inserted 3 documents
|
||||
Search results: 2 documents found
|
||||
"""
|
||||
|
||||
from praisonai.persistence.factory import create_knowledge_store
|
||||
from praisonai.persistence.knowledge.base import KnowledgeDocument
|
||||
import random
|
||||
|
||||
# Create Qdrant knowledge store
|
||||
store = create_knowledge_store("qdrant", url="http://localhost:6333")
|
||||
|
||||
collection_name = "demo_knowledge"
|
||||
dimension = 384 # Embedding dimension
|
||||
|
||||
# Create collection
|
||||
print("=== Qdrant Knowledge Store Demo ===")
|
||||
if store.collection_exists(collection_name):
|
||||
store.delete_collection(collection_name)
|
||||
|
||||
store.create_collection(collection_name, dimension=dimension)
|
||||
print(f"Created collection: {collection_name}")
|
||||
|
||||
# Create sample documents with embeddings
|
||||
random.seed(42)
|
||||
documents = [
|
||||
KnowledgeDocument(
|
||||
id="doc-1",
|
||||
content="Python is a versatile programming language used for web development, data science, and AI.",
|
||||
embedding=[random.random() for _ in range(dimension)],
|
||||
metadata={"category": "programming", "language": "python"}
|
||||
),
|
||||
KnowledgeDocument(
|
||||
id="doc-2",
|
||||
content="Machine learning enables computers to learn patterns from data without explicit programming.",
|
||||
embedding=[random.random() for _ in range(dimension)],
|
||||
metadata={"category": "ai", "topic": "ml"}
|
||||
),
|
||||
KnowledgeDocument(
|
||||
id="doc-3",
|
||||
content="PostgreSQL is a powerful open-source relational database system.",
|
||||
embedding=[random.random() for _ in range(dimension)],
|
||||
metadata={"category": "database", "type": "sql"}
|
||||
),
|
||||
]
|
||||
|
||||
# Insert documents
|
||||
ids = store.insert(collection_name, documents)
|
||||
print(f"Inserted {len(ids)} documents")
|
||||
|
||||
# Search for similar documents
|
||||
query_embedding = [random.random() for _ in range(dimension)]
|
||||
results = store.search(collection_name, query_embedding, limit=2)
|
||||
print(f"Search results: {len(results)} documents found")
|
||||
|
||||
for doc in results:
|
||||
print(f" - {doc.id}: {doc.content[:50]}...")
|
||||
|
||||
# Cleanup
|
||||
store.delete_collection(collection_name)
|
||||
store.close()
|
||||
|
||||
print("\n=== Demo Complete ===")
|
||||
@@ -0,0 +1,43 @@
|
||||
"""
|
||||
Minimal Agent with Database Persistence
|
||||
|
||||
This is the simplest way to add persistence to your agent.
|
||||
Messages, runs, and traces are automatically saved.
|
||||
|
||||
Run:
|
||||
python minimal_agent_db.py
|
||||
|
||||
Expected output:
|
||||
- Agent responds to your question
|
||||
- Data is persisted to PostgreSQL
|
||||
- Session can be resumed later with same session_id
|
||||
"""
|
||||
|
||||
from praisonaiagents import Agent, db
|
||||
|
||||
# Create database connection (PostgreSQL + Redis)
|
||||
my_db = db(
|
||||
database_url="postgresql://postgres:praison123@localhost:5432/praisonai",
|
||||
state_url="redis://localhost:6379" # Optional: for state/tracing
|
||||
)
|
||||
|
||||
# Create agent with persistence
|
||||
agent = Agent(
|
||||
name="Assistant",
|
||||
instructions="You are a helpful assistant. Be concise.",
|
||||
db=my_db,
|
||||
# session_id is optional - defaults to per-hour ID (YYYYMMDDHH-hash)
|
||||
# Set explicitly for session continuity across runs:
|
||||
session_id="my-persistent-session"
|
||||
)
|
||||
|
||||
# Chat - messages are automatically persisted
|
||||
response = agent.chat("What is the capital of France?")
|
||||
print(f"Response: {response}")
|
||||
|
||||
# Verify persistence
|
||||
data = my_db.export_session("my-persistent-session")
|
||||
print(f"\nMessages stored: {len(data.get('messages', []))}")
|
||||
|
||||
# Clean up
|
||||
my_db.close()
|
||||
@@ -0,0 +1,61 @@
|
||||
"""
|
||||
PostgreSQL with Runs and Traces
|
||||
|
||||
Demonstrates full persistence with run tracking and tracing.
|
||||
|
||||
Run:
|
||||
python postgres_runs_traces.py
|
||||
|
||||
Expected output:
|
||||
- Agent responds with tool usage
|
||||
- Runs and traces are persisted
|
||||
- Export shows full session data
|
||||
"""
|
||||
|
||||
from praisonaiagents import Agent, db
|
||||
|
||||
def get_weather(city: str) -> str:
|
||||
"""Get weather for a city."""
|
||||
return f"Weather in {city}: Sunny, 72°F"
|
||||
|
||||
# Create database with PostgreSQL + Redis
|
||||
my_db = db(
|
||||
database_url="postgresql://postgres:praison123@localhost:5432/praisonai",
|
||||
state_url="redis://localhost:6379"
|
||||
)
|
||||
|
||||
# Create agent with tools
|
||||
agent = Agent(
|
||||
name="WeatherBot",
|
||||
instructions="You help with weather. Use the get_weather tool when asked.",
|
||||
tools=[get_weather],
|
||||
db=my_db,
|
||||
session_id="weather-session-001"
|
||||
)
|
||||
|
||||
# First chat - triggers run tracking
|
||||
print("=== First Question ===")
|
||||
response1 = agent.chat("What's the weather in Tokyo?")
|
||||
print(f"Response: {response1}\n")
|
||||
|
||||
# Second chat - same session, new run
|
||||
print("=== Second Question ===")
|
||||
response2 = agent.chat("How about New York?")
|
||||
print(f"Response: {response2}\n")
|
||||
|
||||
# Export session data
|
||||
print("=== Session Data ===")
|
||||
data = my_db.export_session("weather-session-001")
|
||||
print(f"Session ID: {data.get('session_id')}")
|
||||
print(f"Messages: {len(data.get('messages', []))}")
|
||||
print(f"Agent: {data.get('agent_name')}")
|
||||
|
||||
# Show messages
|
||||
print("\n=== Messages ===")
|
||||
for msg in data.get('messages', []):
|
||||
role = msg.get('role', 'unknown')
|
||||
content = msg.get('content', '')[:60]
|
||||
print(f" [{role}] {content}...")
|
||||
|
||||
my_db.close()
|
||||
print("\n✅ Done")
|
||||
@@ -0,0 +1,83 @@
|
||||
"""
|
||||
Redis State Store
|
||||
|
||||
Use Redis for state management, tracing, and caching.
|
||||
|
||||
Run:
|
||||
python redis_state.py
|
||||
|
||||
Expected output:
|
||||
- State operations (set/get/delete)
|
||||
- Tracing operations
|
||||
"""
|
||||
|
||||
from praisonaiagents import db
|
||||
|
||||
# Create Redis-only database (for state/tracing)
|
||||
redis_db = db.RedisDB(host="localhost", port=6379)
|
||||
|
||||
print("=== Redis State Store ===")
|
||||
|
||||
# Test state operations
|
||||
print("\n1. State Operations:")
|
||||
redis_db._init_stores() # Initialize stores
|
||||
|
||||
# Set a value
|
||||
redis_db._state_store.set("user:123:preferences", {
|
||||
"theme": "dark",
|
||||
"language": "en"
|
||||
})
|
||||
print(" Set user preferences")
|
||||
|
||||
# Get the value
|
||||
prefs = redis_db._state_store.get("user:123:preferences")
|
||||
print(f" Got preferences: {prefs}")
|
||||
|
||||
# Set with TTL (expires in 60 seconds)
|
||||
redis_db._state_store.set("temp:token", "abc123", ttl=60)
|
||||
print(" Set temporary token (60s TTL)")
|
||||
|
||||
# Delete
|
||||
redis_db._state_store.delete("user:123:preferences")
|
||||
print(" Deleted preferences")
|
||||
|
||||
# Test tracing
|
||||
print("\n2. Tracing Operations:")
|
||||
|
||||
# Start a trace
|
||||
trace_id = "trace-example-001"
|
||||
redis_db.on_trace_start(
|
||||
trace_id=trace_id,
|
||||
session_id="example-session",
|
||||
agent_name="ExampleAgent",
|
||||
metadata={"purpose": "demo"}
|
||||
)
|
||||
print(f" Started trace: {trace_id}")
|
||||
|
||||
# Add spans
|
||||
span_id = "span-llm-001"
|
||||
redis_db.on_span_start(
|
||||
span_id=span_id,
|
||||
trace_id=trace_id,
|
||||
name="llm_call",
|
||||
attributes={"model": "gpt-4", "temperature": 0.7}
|
||||
)
|
||||
print(f" Started span: {span_id}")
|
||||
|
||||
redis_db.on_span_end(
|
||||
span_id=span_id,
|
||||
status="ok",
|
||||
attributes={"tokens": 150, "latency_ms": 450}
|
||||
)
|
||||
print(f" Ended span: {span_id}")
|
||||
|
||||
# End trace
|
||||
redis_db.on_trace_end(
|
||||
trace_id=trace_id,
|
||||
status="ok",
|
||||
metadata={"total_spans": 1}
|
||||
)
|
||||
print(f" Ended trace: {trace_id}")
|
||||
|
||||
redis_db.close()
|
||||
print("\n✅ Done")
|
||||
@@ -0,0 +1,66 @@
|
||||
"""
|
||||
Session Resume Example
|
||||
|
||||
Demonstrates how to resume a conversation from a previous session.
|
||||
|
||||
Run:
|
||||
python session_resume.py
|
||||
|
||||
Expected output:
|
||||
- First run: Creates new session
|
||||
- Second run: Resumes with history
|
||||
"""
|
||||
|
||||
from praisonaiagents import Agent, db
|
||||
|
||||
# Create database
|
||||
my_db = db(
|
||||
database_url="postgresql://postgres:praison123@localhost:5432/praisonai",
|
||||
state_url="redis://localhost:6379"
|
||||
)
|
||||
|
||||
SESSION_ID = "resume-demo-session"
|
||||
|
||||
# Create agent with explicit session_id
|
||||
agent = Agent(
|
||||
name="MemoryBot",
|
||||
instructions="You remember our conversation. Be helpful and concise.",
|
||||
db=my_db,
|
||||
session_id=SESSION_ID
|
||||
)
|
||||
|
||||
# Check if this is a new or resumed session
|
||||
data = my_db.export_session(SESSION_ID)
|
||||
existing_messages = len(data.get('messages', []))
|
||||
|
||||
if existing_messages == 0:
|
||||
print("=== NEW SESSION ===")
|
||||
print("This is a new conversation.\n")
|
||||
|
||||
response = agent.chat("My name is Alice and I love Python programming.")
|
||||
print(f"Response: {response}\n")
|
||||
|
||||
response = agent.chat("What's my favorite programming language?")
|
||||
print(f"Response: {response}\n")
|
||||
else:
|
||||
print(f"=== RESUMED SESSION ({existing_messages} previous messages) ===")
|
||||
print("Continuing previous conversation.\n")
|
||||
|
||||
# Show previous messages
|
||||
print("Previous messages:")
|
||||
for msg in data.get('messages', [])[:4]:
|
||||
role = msg.get('role', 'unknown')
|
||||
content = msg.get('content', '')[:60]
|
||||
print(f" [{role}] {content}...")
|
||||
print()
|
||||
|
||||
# Continue conversation
|
||||
response = agent.chat("What's my name and what do I like?")
|
||||
print(f"Response: {response}\n")
|
||||
|
||||
# Show final state
|
||||
final_data = my_db.export_session(SESSION_ID)
|
||||
print(f"Total messages now: {len(final_data.get('messages', []))}")
|
||||
|
||||
my_db.close()
|
||||
print("✅ Done")
|
||||
@@ -0,0 +1,58 @@
|
||||
"""
|
||||
Simple Database Persistence Example for PraisonAI Agents.
|
||||
|
||||
This example shows how to enable automatic conversation persistence
|
||||
with just 2 lines of code.
|
||||
|
||||
Requirements:
|
||||
pip install "praisonai[tools]"
|
||||
|
||||
Docker Setup:
|
||||
docker run -d --name praison-postgres -p 5432:5432 \
|
||||
-e POSTGRES_PASSWORD=praison123 \
|
||||
-e POSTGRES_DB=praisonai \
|
||||
postgres:16
|
||||
|
||||
Environment:
|
||||
export OPENAI_API_KEY=your-key
|
||||
|
||||
Run:
|
||||
python simple_agent_postgres.py
|
||||
|
||||
Expected Output:
|
||||
=== First Run ===
|
||||
Agent: Nice to meet you, Alice! Python is a great choice...
|
||||
Session ID: demo-session-001
|
||||
Messages persisted: 2
|
||||
"""
|
||||
|
||||
from praisonaiagents import Agent, db
|
||||
|
||||
# Create database adapter (simplified import)
|
||||
db_instance = db(
|
||||
database_url="postgresql://postgres:praison123@localhost:5432/praisonai"
|
||||
)
|
||||
|
||||
# Create agent with persistence enabled
|
||||
agent = Agent(
|
||||
name="Assistant",
|
||||
instructions="You are a helpful assistant. Keep responses brief.",
|
||||
db=db_instance,
|
||||
session_id="demo-session-001", # Same session_id = resume conversation
|
||||
verbose=False
|
||||
)
|
||||
|
||||
# Chat with persistence
|
||||
print("=== First Run ===")
|
||||
response = agent.chat("My name is Alice and I love Python programming.")
|
||||
print(f"Agent: {response}\n")
|
||||
|
||||
# Show session info
|
||||
print(f"Session ID: {agent.session_id}")
|
||||
print(f"Messages in history: {len(agent.chat_history)}")
|
||||
|
||||
# Close DB connection
|
||||
db_instance.close()
|
||||
|
||||
print("\n=== Session persisted! ===")
|
||||
print("Run again with same session_id to resume.")
|
||||
@@ -0,0 +1,41 @@
|
||||
"""
|
||||
SQLite Local Database
|
||||
|
||||
Use SQLite for simple local persistence without external services.
|
||||
|
||||
Run:
|
||||
python sqlite_local.py
|
||||
|
||||
Expected output:
|
||||
- Agent responds
|
||||
- Data persisted to local SQLite file
|
||||
"""
|
||||
|
||||
from praisonaiagents import Agent, db
|
||||
import tempfile
|
||||
import os
|
||||
|
||||
# Create SQLite database (local file)
|
||||
db_path = os.path.join(tempfile.gettempdir(), "my_agent.db")
|
||||
my_db = db.SQLiteDB(path=db_path)
|
||||
|
||||
print(f"Database: {db_path}")
|
||||
|
||||
# Create agent
|
||||
agent = Agent(
|
||||
name="LocalBot",
|
||||
instructions="You are a helpful assistant.",
|
||||
db=my_db,
|
||||
session_id="local-session"
|
||||
)
|
||||
|
||||
# Chat
|
||||
response = agent.chat("Hello! What can you help me with?")
|
||||
print(f"Response: {response}")
|
||||
|
||||
# Verify
|
||||
data = my_db.export_session("local-session")
|
||||
print(f"\nMessages stored: {len(data.get('messages', []))}")
|
||||
|
||||
my_db.close()
|
||||
print("✅ Done")
|
||||
@@ -0,0 +1,67 @@
|
||||
"""
|
||||
Redis State Store Example for PraisonAI.
|
||||
|
||||
Demonstrates set/get/delete + TTL operations.
|
||||
|
||||
Requirements:
|
||||
pip install "praisonai[tools]"
|
||||
|
||||
Docker Setup:
|
||||
docker run -d --name praison-redis -p 6379:6379 redis:7
|
||||
|
||||
Run:
|
||||
python state_redis.py
|
||||
|
||||
Expected Output:
|
||||
=== Redis State Store Demo ===
|
||||
Set key: session:user123:preferences
|
||||
Get value: {'theme': 'dark', 'language': 'en'}
|
||||
TTL remaining: 3600 seconds
|
||||
Hash operations: {'name': 'Alice', 'role': 'admin'}
|
||||
"""
|
||||
|
||||
from praisonai.persistence.factory import create_state_store
|
||||
|
||||
# Create Redis state store
|
||||
store = create_state_store("redis", url="redis://localhost:6379")
|
||||
|
||||
print("=== Redis State Store Demo ===")
|
||||
|
||||
# Basic set/get
|
||||
key = "session:user123:preferences"
|
||||
value = {"theme": "dark", "language": "en", "notifications": True}
|
||||
|
||||
store.set(key, value)
|
||||
print(f"Set key: {key}")
|
||||
|
||||
retrieved = store.get(key)
|
||||
print(f"Get value: {retrieved}")
|
||||
|
||||
# Set with TTL (expires in 1 hour)
|
||||
ttl_key = "session:user123:token"
|
||||
store.set(ttl_key, "abc123xyz", ttl=3600)
|
||||
remaining = store.ttl(ttl_key)
|
||||
print(f"TTL remaining: {remaining} seconds")
|
||||
|
||||
# Hash operations (for structured data)
|
||||
hash_key = "user:123:profile"
|
||||
store.hset(hash_key, "name", "Alice")
|
||||
store.hset(hash_key, "role", "admin")
|
||||
store.hset(hash_key, "last_login", "2024-12-24")
|
||||
|
||||
profile = store.hgetall(hash_key)
|
||||
print(f"Hash operations: {profile}")
|
||||
|
||||
# Check existence
|
||||
exists = store.exists(key)
|
||||
print(f"Key exists: {exists}")
|
||||
|
||||
# Delete keys
|
||||
store.delete(key)
|
||||
store.delete(ttl_key)
|
||||
store.delete(hash_key)
|
||||
print("Cleaned up test keys")
|
||||
|
||||
store.close()
|
||||
|
||||
print("\n=== Demo Complete ===")
|
||||
@@ -0,0 +1,142 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Basic Policy Engine Example for PraisonAI Agents.
|
||||
|
||||
This example demonstrates how to use the policy engine to:
|
||||
1. Create policies with rules
|
||||
2. Check if actions are allowed
|
||||
3. Block dangerous operations
|
||||
4. Use convenience policy creators
|
||||
|
||||
Usage:
|
||||
python basic_policy.py
|
||||
"""
|
||||
|
||||
from praisonaiagents.policy import (
|
||||
PolicyEngine, Policy, PolicyRule, PolicyAction, PolicyConfig,
|
||||
create_deny_tools_policy, create_read_only_policy
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 60)
|
||||
print("Policy Engine Demo")
|
||||
print("=" * 60)
|
||||
|
||||
# Create a policy engine
|
||||
engine = PolicyEngine()
|
||||
|
||||
# ==========================================================================
|
||||
# Policy 1: Block dangerous tools
|
||||
# ==========================================================================
|
||||
print("\n--- Creating Policies ---")
|
||||
|
||||
dangerous_tools_policy = Policy(
|
||||
name="block_dangerous",
|
||||
description="Block dangerous file operations",
|
||||
rules=[
|
||||
PolicyRule(
|
||||
action=PolicyAction.DENY,
|
||||
resource="tool:delete_*",
|
||||
reason="Delete operations are blocked",
|
||||
name="deny_delete"
|
||||
),
|
||||
PolicyRule(
|
||||
action=PolicyAction.DENY,
|
||||
resource="tool:rm_*",
|
||||
reason="Remove operations are blocked",
|
||||
name="deny_rm"
|
||||
),
|
||||
],
|
||||
priority=100
|
||||
)
|
||||
engine.add_policy(dangerous_tools_policy)
|
||||
print(f"✅ Added policy: {dangerous_tools_policy.name}")
|
||||
|
||||
# ==========================================================================
|
||||
# Policy 2: Allow read operations
|
||||
# ==========================================================================
|
||||
read_policy = Policy(
|
||||
name="allow_read",
|
||||
description="Allow read operations",
|
||||
rules=[
|
||||
PolicyRule(
|
||||
action=PolicyAction.ALLOW,
|
||||
resource="tool:read_*",
|
||||
name="allow_read"
|
||||
),
|
||||
PolicyRule(
|
||||
action=PolicyAction.ALLOW,
|
||||
resource="tool:list_*",
|
||||
name="allow_list"
|
||||
),
|
||||
]
|
||||
)
|
||||
engine.add_policy(read_policy)
|
||||
print(f"✅ Added policy: {read_policy.name}")
|
||||
|
||||
# ==========================================================================
|
||||
# Test policy checks
|
||||
# ==========================================================================
|
||||
print("\n--- Testing Policy Checks ---")
|
||||
|
||||
test_cases = [
|
||||
("tool:read_file", "Reading a file"),
|
||||
("tool:list_directory", "Listing directory"),
|
||||
("tool:delete_file", "Deleting a file"),
|
||||
("tool:rm_directory", "Removing directory"),
|
||||
("tool:write_file", "Writing a file"),
|
||||
]
|
||||
|
||||
for resource, description in test_cases:
|
||||
result = engine.check(resource, {})
|
||||
status = "✅ ALLOWED" if result.allowed else "🚫 DENIED"
|
||||
reason = f" ({result.reason})" if result.reason else ""
|
||||
print(f" {status}: {description}{reason}")
|
||||
|
||||
# ==========================================================================
|
||||
# Using convenience functions
|
||||
# ==========================================================================
|
||||
print("\n--- Using Convenience Functions ---")
|
||||
|
||||
# Create a read-only policy
|
||||
read_only = create_read_only_policy()
|
||||
print(f"✅ Created read-only policy with {len(read_only.rules)} rules")
|
||||
|
||||
# Create a deny tools policy
|
||||
deny_tools = create_deny_tools_policy(
|
||||
["execute_*", "shell_*", "system_*"],
|
||||
reason="System commands are blocked"
|
||||
)
|
||||
print(f"✅ Created deny-tools policy with {len(deny_tools.rules)} rules")
|
||||
|
||||
# ==========================================================================
|
||||
# Strict mode example
|
||||
# ==========================================================================
|
||||
print("\n--- Strict Mode Example ---")
|
||||
|
||||
strict_engine = PolicyEngine(PolicyConfig(strict_mode=True))
|
||||
|
||||
# In strict mode, unknown resources are denied
|
||||
result = strict_engine.check("tool:unknown_operation", {})
|
||||
print(f" Unknown operation in strict mode: {'DENIED' if not result.allowed else 'ALLOWED'}")
|
||||
|
||||
# ==========================================================================
|
||||
# Policy serialization
|
||||
# ==========================================================================
|
||||
print("\n--- Policy Serialization ---")
|
||||
|
||||
policy_dict = dangerous_tools_policy.to_dict()
|
||||
print(f" Serialized policy: {policy_dict['name']} with {len(policy_dict['rules'])} rules")
|
||||
|
||||
# Recreate from dict
|
||||
restored = Policy.from_dict(policy_dict)
|
||||
print(f" Restored policy: {restored.name}")
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("Demo Complete!")
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,69 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run all examples and collect PASS/SKIP/FAIL results."""
|
||||
import subprocess
|
||||
import sys
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
EXAMPLES_DIR = Path(__file__).parent
|
||||
RESULTS = []
|
||||
|
||||
def run_example(path: Path) -> tuple:
|
||||
"""Run an example and return (name, status, reason)."""
|
||||
name = f"{path.parent.name}/{path.name}"
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(path)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
env={**os.environ, "PYTHONPATH": str(EXAMPLES_DIR.parent / "src")}
|
||||
)
|
||||
output = result.stdout + result.stderr
|
||||
if "PASSED" in output:
|
||||
return (name, "PASS", "")
|
||||
elif "SKIPPED" in output:
|
||||
return (name, "SKIP", output.split("SKIPPED:")[-1].strip()[:50])
|
||||
elif result.returncode != 0:
|
||||
error = output.strip().split("\n")[-1][:80]
|
||||
return (name, "FAIL", error)
|
||||
else:
|
||||
return (name, "PASS", "")
|
||||
except subprocess.TimeoutExpired:
|
||||
return (name, "FAIL", "Timeout")
|
||||
except Exception as e:
|
||||
return (name, "FAIL", str(e)[:50])
|
||||
|
||||
def main():
|
||||
print("=" * 70)
|
||||
print("EXAMPLE RUNNER - PASS/SKIP/FAIL SUMMARY")
|
||||
print("=" * 70)
|
||||
|
||||
# Find all example files
|
||||
examples = []
|
||||
for subdir in ["db", "vector", "observability", "multi_agent", "workflows"]:
|
||||
subpath = EXAMPLES_DIR / subdir
|
||||
if subpath.exists():
|
||||
examples.extend(sorted(subpath.glob("*_wow.py")))
|
||||
|
||||
print(f"\nFound {len(examples)} examples to run\n")
|
||||
|
||||
for ex in examples:
|
||||
name, status, reason = run_example(ex)
|
||||
RESULTS.append((name, status, reason))
|
||||
icon = {"PASS": "✅", "SKIP": "⏭️", "FAIL": "❌"}.get(status, "?")
|
||||
reason_str = f" ({reason})" if reason else ""
|
||||
print(f"{icon} {status:4} | {name:45} {reason_str}")
|
||||
|
||||
# Summary
|
||||
print("\n" + "=" * 70)
|
||||
passed = sum(1 for _, s, _ in RESULTS if s == "PASS")
|
||||
skipped = sum(1 for _, s, _ in RESULTS if s == "SKIP")
|
||||
failed = sum(1 for _, s, _ in RESULTS if s == "FAIL")
|
||||
print(f"SUMMARY: {passed} PASS | {skipped} SKIP | {failed} FAIL | {len(RESULTS)} TOTAL")
|
||||
print("=" * 70)
|
||||
|
||||
return 0 if failed == 0 else 1
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,94 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Example: Creating Skills with PraisonAI CLI
|
||||
|
||||
This example demonstrates how to create Agent Skills using the CLI.
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
import tempfile
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def example_create_skill_with_template():
|
||||
"""Create a skill using template mode (no API key required)."""
|
||||
print("=" * 60)
|
||||
print("Example 1: Create skill with template")
|
||||
print("=" * 60)
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
result = subprocess.run([
|
||||
"praisonai", "skills", "create",
|
||||
"--name", "my-template-skill",
|
||||
"--description", "A skill for processing text files",
|
||||
"--author", "example-team",
|
||||
"--license", "MIT",
|
||||
"--template",
|
||||
"--output-dir", tmpdir
|
||||
], capture_output=True, text=True)
|
||||
|
||||
print(result.stdout)
|
||||
if result.returncode == 0:
|
||||
skill_md = Path(tmpdir) / "my-template-skill" / "SKILL.md"
|
||||
print(f"\nGenerated SKILL.md:\n{skill_md.read_text()[:500]}...")
|
||||
else:
|
||||
print(f"Error: {result.stderr}")
|
||||
|
||||
|
||||
def example_create_skill_with_ai():
|
||||
"""Create a skill using AI generation (requires API key)."""
|
||||
print("\n" + "=" * 60)
|
||||
print("Example 2: Create skill with AI generation")
|
||||
print("=" * 60)
|
||||
|
||||
if not os.environ.get("OPENAI_API_KEY"):
|
||||
print("Skipping: OPENAI_API_KEY not set")
|
||||
return
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
result = subprocess.run([
|
||||
"praisonai", "skills", "create",
|
||||
"--name", "csv-analyzer",
|
||||
"--description", "Analyze CSV files and generate statistical reports",
|
||||
"--output-dir", tmpdir
|
||||
], capture_output=True, text=True)
|
||||
|
||||
print(result.stdout)
|
||||
if result.returncode == 0:
|
||||
skill_md = Path(tmpdir) / "csv-analyzer" / "SKILL.md"
|
||||
print(f"\nGenerated SKILL.md:\n{skill_md.read_text()[:800]}...")
|
||||
|
||||
# Check if script was generated
|
||||
script_path = Path(tmpdir) / "csv-analyzer" / "scripts" / "skill.py"
|
||||
if script_path.exists():
|
||||
print(f"\nGenerated skill.py:\n{script_path.read_text()[:500]}...")
|
||||
|
||||
|
||||
def example_create_skill_with_script():
|
||||
"""Create a skill with auto-generated Python script."""
|
||||
print("\n" + "=" * 60)
|
||||
print("Example 3: Create skill with script")
|
||||
print("=" * 60)
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
result = subprocess.run([
|
||||
"praisonai", "skills", "create",
|
||||
"--name", "data-processor",
|
||||
"--description", "Process and transform data files",
|
||||
"--template",
|
||||
"--script",
|
||||
"--output-dir", tmpdir
|
||||
], capture_output=True, text=True)
|
||||
|
||||
print(result.stdout)
|
||||
if result.returncode == 0:
|
||||
script_path = Path(tmpdir) / "data-processor" / "scripts" / "skill.py"
|
||||
if script_path.exists():
|
||||
print(f"\nGenerated skill.py:\n{script_path.read_text()}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
example_create_skill_with_template()
|
||||
example_create_skill_with_ai()
|
||||
example_create_skill_with_script()
|
||||
@@ -0,0 +1,137 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Example: Multi-Agent CLI Usage
|
||||
|
||||
This example demonstrates how to use the praisonai agents command
|
||||
to define and run multiple agents from the command line.
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
import os
|
||||
|
||||
|
||||
def example_single_agent():
|
||||
"""Run a single agent with a simple task."""
|
||||
print("=" * 60)
|
||||
print("Example 1: Single Agent")
|
||||
print("=" * 60)
|
||||
|
||||
result = subprocess.run([
|
||||
"praisonai", "agents", "run",
|
||||
"--agent", "assistant:Helper",
|
||||
"--task", "What is the capital of France?"
|
||||
], capture_output=True, text=True)
|
||||
|
||||
print(result.stdout)
|
||||
if result.returncode != 0:
|
||||
print(f"Error: {result.stderr}")
|
||||
|
||||
|
||||
def example_agent_with_tools():
|
||||
"""Run an agent with tools."""
|
||||
print("\n" + "=" * 60)
|
||||
print("Example 2: Agent with Tools")
|
||||
print("=" * 60)
|
||||
|
||||
result = subprocess.run([
|
||||
"praisonai", "agents", "run",
|
||||
"--agent", "researcher:Research Analyst:internet_search",
|
||||
"--task", "Find the latest news about renewable energy"
|
||||
], capture_output=True, text=True)
|
||||
|
||||
print(result.stdout)
|
||||
|
||||
|
||||
def example_multiple_agents_sequential():
|
||||
"""Run multiple agents in sequential mode."""
|
||||
print("\n" + "=" * 60)
|
||||
print("Example 3: Multiple Agents (Sequential)")
|
||||
print("=" * 60)
|
||||
|
||||
result = subprocess.run([
|
||||
"praisonai", "agents", "run",
|
||||
"--agent", "researcher:Research Analyst:internet_search",
|
||||
"--agent", "writer:Content Writer:write_file",
|
||||
"--task", "Research AI trends and write a brief summary"
|
||||
], capture_output=True, text=True)
|
||||
|
||||
print(result.stdout)
|
||||
|
||||
|
||||
def example_multiple_agents_parallel():
|
||||
"""Run multiple agents in parallel mode."""
|
||||
print("\n" + "=" * 60)
|
||||
print("Example 4: Multiple Agents (Parallel)")
|
||||
print("=" * 60)
|
||||
|
||||
result = subprocess.run([
|
||||
"praisonai", "agents", "run",
|
||||
"--agent", "analyst1:Market Analyst",
|
||||
"--agent", "analyst2:Tech Analyst",
|
||||
"--process", "parallel",
|
||||
"--task", "Analyze the current state of AI industry"
|
||||
], capture_output=True, text=True)
|
||||
|
||||
print(result.stdout)
|
||||
|
||||
|
||||
def example_with_custom_llm():
|
||||
"""Run agents with a custom LLM model."""
|
||||
print("\n" + "=" * 60)
|
||||
print("Example 5: Custom LLM Model")
|
||||
print("=" * 60)
|
||||
|
||||
result = subprocess.run([
|
||||
"praisonai", "agents", "run",
|
||||
"--agent", "coder:Developer:execute_command",
|
||||
"--llm", "gpt-4o-mini",
|
||||
"--task", "Write a simple hello world Python script"
|
||||
], capture_output=True, text=True)
|
||||
|
||||
print(result.stdout)
|
||||
|
||||
|
||||
def example_with_instructions():
|
||||
"""Run agents with additional instructions."""
|
||||
print("\n" + "=" * 60)
|
||||
print("Example 6: With Additional Instructions")
|
||||
print("=" * 60)
|
||||
|
||||
result = subprocess.run([
|
||||
"praisonai", "agents", "run",
|
||||
"--agent", "analyst:Data Analyst",
|
||||
"--instructions", "Be concise and provide only key insights",
|
||||
"--task", "Explain the benefits of using AI in healthcare"
|
||||
], capture_output=True, text=True)
|
||||
|
||||
print(result.stdout)
|
||||
|
||||
|
||||
def example_list_tools():
|
||||
"""List available tools for agents."""
|
||||
print("\n" + "=" * 60)
|
||||
print("Example 7: List Available Tools")
|
||||
print("=" * 60)
|
||||
|
||||
result = subprocess.run([
|
||||
"praisonai", "agents", "list"
|
||||
], capture_output=True, text=True)
|
||||
|
||||
print(result.stdout)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Check for API key
|
||||
if not os.environ.get("OPENAI_API_KEY"):
|
||||
print("Warning: OPENAI_API_KEY not set. Some examples may fail.")
|
||||
print()
|
||||
|
||||
# Run examples
|
||||
example_list_tools()
|
||||
example_single_agent()
|
||||
# Uncomment to run more examples:
|
||||
# example_agent_with_tools()
|
||||
# example_multiple_agents_sequential()
|
||||
# example_multiple_agents_parallel()
|
||||
# example_with_custom_llm()
|
||||
# example_with_instructions()
|
||||
@@ -0,0 +1,137 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Basic Thinking Budgets Example for PraisonAI Agents.
|
||||
|
||||
This example demonstrates how to use thinking budgets to:
|
||||
1. Configure token budgets for extended thinking
|
||||
2. Use predefined budget levels
|
||||
3. Track thinking usage
|
||||
4. Adapt budgets based on complexity
|
||||
|
||||
Usage:
|
||||
python basic_thinking.py
|
||||
"""
|
||||
|
||||
from praisonaiagents.thinking import (
|
||||
ThinkingBudget, ThinkingConfig, ThinkingUsage, ThinkingTracker
|
||||
)
|
||||
from praisonaiagents.thinking.budget import BudgetLevel
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 60)
|
||||
print("Thinking Budgets Demo")
|
||||
print("=" * 60)
|
||||
|
||||
# ==========================================================================
|
||||
# Budget Levels
|
||||
# ==========================================================================
|
||||
print("\n--- Budget Levels ---")
|
||||
|
||||
levels = [
|
||||
("Minimal", ThinkingBudget.minimal()),
|
||||
("Low", ThinkingBudget.low()),
|
||||
("Medium", ThinkingBudget.medium()),
|
||||
("High", ThinkingBudget.high()),
|
||||
("Maximum", ThinkingBudget.maximum()),
|
||||
]
|
||||
|
||||
for name, budget in levels:
|
||||
print(f" {name}: {budget.max_tokens:,} tokens")
|
||||
|
||||
# ==========================================================================
|
||||
# Custom Budget
|
||||
# ==========================================================================
|
||||
print("\n--- Custom Budget ---")
|
||||
|
||||
custom_budget = ThinkingBudget(
|
||||
max_tokens=12000,
|
||||
min_tokens=2000,
|
||||
adaptive=True,
|
||||
max_time_seconds=120.0
|
||||
)
|
||||
print(f" Max tokens: {custom_budget.max_tokens:,}")
|
||||
print(f" Min tokens: {custom_budget.min_tokens:,}")
|
||||
print(f" Adaptive: {custom_budget.adaptive}")
|
||||
print(f" Time limit: {custom_budget.max_time_seconds}s")
|
||||
|
||||
# ==========================================================================
|
||||
# Complexity-Based Scaling
|
||||
# ==========================================================================
|
||||
print("\n--- Complexity-Based Token Allocation ---")
|
||||
|
||||
budget = ThinkingBudget(
|
||||
max_tokens=16000,
|
||||
min_tokens=2000,
|
||||
adaptive=True
|
||||
)
|
||||
|
||||
complexities = [0.0, 0.25, 0.5, 0.75, 1.0]
|
||||
for complexity in complexities:
|
||||
tokens = budget.get_tokens_for_complexity(complexity)
|
||||
bar = "█" * int(tokens / 1000)
|
||||
print(f" Complexity {complexity:.2f}: {tokens:,} tokens {bar}")
|
||||
|
||||
# ==========================================================================
|
||||
# Usage Tracking
|
||||
# ==========================================================================
|
||||
print("\n--- Usage Tracking ---")
|
||||
|
||||
usage = ThinkingUsage(
|
||||
budget_tokens=8000,
|
||||
tokens_used=5000,
|
||||
time_seconds=30.0,
|
||||
budget_time=60.0
|
||||
)
|
||||
|
||||
print(f" Tokens used: {usage.tokens_used:,} / {usage.budget_tokens:,}")
|
||||
print(f" Tokens remaining: {usage.tokens_remaining:,}")
|
||||
print(f" Utilization: {usage.token_utilization:.1%}")
|
||||
print(f" Over budget: {usage.is_over_budget}")
|
||||
print(f" Time remaining: {usage.time_remaining}s")
|
||||
|
||||
# ==========================================================================
|
||||
# Session Tracking
|
||||
# ==========================================================================
|
||||
print("\n--- Session Tracking ---")
|
||||
|
||||
tracker = ThinkingTracker()
|
||||
|
||||
# Simulate multiple thinking sessions
|
||||
sessions = [
|
||||
(8000, 4000, 20.0),
|
||||
(8000, 6000, 35.0),
|
||||
(16000, 12000, 60.0),
|
||||
(8000, 9000, 45.0), # Over budget
|
||||
]
|
||||
|
||||
for budget_tokens, tokens_used, time_seconds in sessions:
|
||||
session = tracker.start_session(budget_tokens=budget_tokens)
|
||||
tracker.end_session(session, tokens_used=tokens_used, time_seconds=time_seconds)
|
||||
|
||||
summary = tracker.get_summary()
|
||||
print(f" Total sessions: {summary['session_count']}")
|
||||
print(f" Total tokens used: {summary['total_tokens_used']:,}")
|
||||
print(f" Average tokens/session: {summary['average_tokens_per_session']:,.0f}")
|
||||
print(f" Average utilization: {summary['average_utilization']:.1%}")
|
||||
print(f" Over-budget sessions: {summary['over_budget_count']}")
|
||||
|
||||
# ==========================================================================
|
||||
# Serialization
|
||||
# ==========================================================================
|
||||
print("\n--- Serialization ---")
|
||||
|
||||
budget = ThinkingBudget.from_level(BudgetLevel.HIGH)
|
||||
data = budget.to_dict()
|
||||
print(f" Serialized: level={data['level']}, max_tokens={data['max_tokens']}")
|
||||
|
||||
restored = ThinkingBudget.from_dict(data)
|
||||
print(f" Restored: {restored.max_tokens:,} tokens")
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("Demo Complete!")
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
"""ArXiv Tool Example.
|
||||
|
||||
This example demonstrates how to use the ArXiv tool with PraisonAI agents.
|
||||
|
||||
Requirements:
|
||||
pip install "praisonai[tools]"
|
||||
|
||||
Usage:
|
||||
python tool.py
|
||||
|
||||
Note: No API key required!
|
||||
"""
|
||||
|
||||
from praisonai_tools import ArxivTool
|
||||
|
||||
|
||||
def main():
|
||||
# Initialize ArXiv tool (no API key needed)
|
||||
arxiv = ArxivTool()
|
||||
|
||||
# Example 1: Search ArXiv
|
||||
print("=" * 60)
|
||||
print("Example 1: Search ArXiv")
|
||||
print("=" * 60)
|
||||
|
||||
results = arxiv.search("transformer neural networks", max_results=3)
|
||||
|
||||
if results and "error" in results[0]:
|
||||
print(f"Error: {results[0]['error']}")
|
||||
else:
|
||||
print(f"Found {len(results)} papers:")
|
||||
for paper in results:
|
||||
print(f"\n Title: {paper.get('title', 'N/A')[:60]}...")
|
||||
authors = paper.get('authors', [])
|
||||
if authors:
|
||||
print(f" Authors: {', '.join(authors[:2])}...")
|
||||
print(f" Published: {paper.get('published', 'N/A')}")
|
||||
print(f" PDF: {paper.get('pdf_url', 'N/A')}")
|
||||
|
||||
print("\n✅ ArXiv tool working correctly!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
"""Calculator Tool Example.
|
||||
|
||||
This example demonstrates how to use the Calculator tool with PraisonAI agents.
|
||||
|
||||
Requirements:
|
||||
pip install "praisonai[tools]"
|
||||
|
||||
Usage:
|
||||
python tool.py
|
||||
|
||||
Note: No API key required!
|
||||
"""
|
||||
|
||||
from praisonai_tools import CalculatorTool
|
||||
|
||||
|
||||
def main():
|
||||
# Initialize Calculator tool
|
||||
calc = CalculatorTool()
|
||||
|
||||
# Example 1: Basic arithmetic
|
||||
print("=" * 60)
|
||||
print("Example 1: Basic Arithmetic")
|
||||
print("=" * 60)
|
||||
|
||||
expressions = [
|
||||
"2 + 2",
|
||||
"10 * 5",
|
||||
"100 / 4",
|
||||
"2 ** 10",
|
||||
"(10 + 5) * 2 - 3"
|
||||
]
|
||||
|
||||
for expr in expressions:
|
||||
result = calc.evaluate(expr)
|
||||
print(f" {expr} = {result.get('result', result)}")
|
||||
|
||||
# Example 2: Percentage calculation
|
||||
print("\n" + "=" * 60)
|
||||
print("Example 2: Percentage Calculation")
|
||||
print("=" * 60)
|
||||
|
||||
result = calc.evaluate("250 * 0.15")
|
||||
print(f" 15% of 250 = {result.get('result', result)}")
|
||||
|
||||
result = calc.evaluate("1000 * (1 + 0.05) ** 3")
|
||||
print(f" $1000 at 5% for 3 years = ${result.get('result', 0):.2f}")
|
||||
|
||||
print("\n✅ Calculator tool working correctly!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
"""DuckDuckGo Search Tool Example.
|
||||
|
||||
This example demonstrates how to use the DuckDuckGo search tool with PraisonAI agents.
|
||||
|
||||
Requirements:
|
||||
pip install "praisonai[tools]"
|
||||
|
||||
Usage:
|
||||
python tool.py
|
||||
|
||||
Note: No API key required!
|
||||
"""
|
||||
|
||||
from praisonai_tools import DuckDuckGoTool
|
||||
|
||||
|
||||
def main():
|
||||
# Initialize DuckDuckGo tool (no API key needed)
|
||||
ddg = DuckDuckGoTool()
|
||||
|
||||
# Example 1: Web search
|
||||
print("=" * 60)
|
||||
print("Example 1: Web Search")
|
||||
print("=" * 60)
|
||||
|
||||
results = ddg.search("Python programming best practices", max_results=3)
|
||||
|
||||
if results and "error" in results[0]:
|
||||
print(f"Error: {results[0]['error']}")
|
||||
else:
|
||||
print(f"Found {len(results)} results:")
|
||||
for r in results:
|
||||
print(f" - {r['title']}")
|
||||
print(f" URL: {r['url']}")
|
||||
print(f" Snippet: {r['snippet'][:100]}...")
|
||||
print()
|
||||
|
||||
# Example 2: News search
|
||||
print("=" * 60)
|
||||
print("Example 2: News Search")
|
||||
print("=" * 60)
|
||||
|
||||
news = ddg.news("artificial intelligence", max_results=3)
|
||||
|
||||
if news and "error" in news[0]:
|
||||
print(f"Error: {news[0]['error']}")
|
||||
else:
|
||||
print(f"Found {len(news)} news articles:")
|
||||
for n in news:
|
||||
print(f" - {n['title']}")
|
||||
print(f" Source: {n.get('source', 'N/A')}")
|
||||
print(f" Date: {n.get('date', 'N/A')}")
|
||||
print()
|
||||
|
||||
# Example 3: Image search
|
||||
print("=" * 60)
|
||||
print("Example 3: Image Search")
|
||||
print("=" * 60)
|
||||
|
||||
images = ddg.images("sunset landscape", max_results=3)
|
||||
|
||||
if images and "error" in images[0]:
|
||||
print(f"Error: {images[0]['error']}")
|
||||
else:
|
||||
print(f"Found {len(images)} images:")
|
||||
for img in images:
|
||||
print(f" - {img['title']}")
|
||||
print(f" Image URL: {img['image_url'][:60]}...")
|
||||
print()
|
||||
|
||||
print("✅ DuckDuckGo tool working correctly!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Vendored
+48
@@ -0,0 +1,48 @@
|
||||
"""Exa Search Tool Example.
|
||||
|
||||
This example demonstrates how to use the Exa search tool with PraisonAI agents.
|
||||
|
||||
Requirements:
|
||||
pip install "praisonai[tools]"
|
||||
export EXA_API_KEY=your_api_key
|
||||
|
||||
Usage:
|
||||
python tool.py
|
||||
"""
|
||||
|
||||
import os
|
||||
from praisonai_tools import ExaTool
|
||||
|
||||
|
||||
def main():
|
||||
# Check for API key
|
||||
if not os.getenv("EXA_API_KEY"):
|
||||
print("Error: EXA_API_KEY environment variable not set")
|
||||
print("Get your API key from https://exa.ai/")
|
||||
return
|
||||
|
||||
# Initialize Exa tool
|
||||
exa = ExaTool()
|
||||
|
||||
# Example 1: Neural search
|
||||
print("=" * 60)
|
||||
print("Example 1: Neural Search")
|
||||
print("=" * 60)
|
||||
|
||||
results = exa.search("best practices for Python development", num_results=3)
|
||||
|
||||
if results and "error" in results[0]:
|
||||
print(f"Error: {results[0]['error']}")
|
||||
else:
|
||||
print(f"Found {len(results)} results:")
|
||||
for r in results:
|
||||
print(f" - {r.get('title', 'N/A')[:50]}...")
|
||||
print(f" URL: {r.get('url', 'N/A')[:60]}...")
|
||||
print(f" Score: {r.get('score', 'N/A')}")
|
||||
print()
|
||||
|
||||
print("✅ Exa tool working correctly!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
"""Firecrawl Tool Example.
|
||||
|
||||
This example demonstrates how to use the Firecrawl tool with PraisonAI agents.
|
||||
|
||||
Requirements:
|
||||
pip install "praisonai[tools]"
|
||||
export FIRECRAWL_API_KEY=your_api_key
|
||||
|
||||
Usage:
|
||||
python tool.py
|
||||
"""
|
||||
|
||||
import os
|
||||
from praisonai_tools import FirecrawlTool
|
||||
|
||||
|
||||
def main():
|
||||
# Check for API key
|
||||
if not os.getenv("FIRECRAWL_API_KEY"):
|
||||
print("Error: FIRECRAWL_API_KEY environment variable not set")
|
||||
print("Get your API key from https://firecrawl.dev/")
|
||||
return
|
||||
|
||||
# Initialize Firecrawl tool
|
||||
firecrawl = FirecrawlTool()
|
||||
|
||||
# Example 1: Scrape a page
|
||||
print("=" * 60)
|
||||
print("Example 1: Scrape a Page")
|
||||
print("=" * 60)
|
||||
|
||||
result = firecrawl.scrape("https://example.com")
|
||||
|
||||
if "error" in result:
|
||||
print(f"Error: {result['error']}")
|
||||
else:
|
||||
print(f"URL: {result.get('url', 'N/A')}")
|
||||
markdown = result.get('markdown', '')
|
||||
print(f"Content preview: {markdown[:300]}...")
|
||||
|
||||
print("\n✅ Firecrawl tool working correctly!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
"""Hacker News Tool Example.
|
||||
|
||||
This example demonstrates how to use the Hacker News tool with PraisonAI agents.
|
||||
|
||||
Requirements:
|
||||
pip install "praisonai[tools]"
|
||||
|
||||
Usage:
|
||||
python tool.py
|
||||
|
||||
Note: No API key required!
|
||||
"""
|
||||
|
||||
from praisonai_tools import HackerNewsTool
|
||||
|
||||
|
||||
def main():
|
||||
# Initialize Hacker News tool
|
||||
hn = HackerNewsTool()
|
||||
|
||||
# Example 1: Get top stories
|
||||
print("=" * 60)
|
||||
print("Example 1: Top Stories")
|
||||
print("=" * 60)
|
||||
|
||||
stories = hn.get_top_stories(limit=5)
|
||||
|
||||
if stories and "error" in stories[0]:
|
||||
print(f"Error: {stories[0]['error']}")
|
||||
else:
|
||||
print(f"Found {len(stories)} top stories:")
|
||||
for story in stories:
|
||||
print(f" - {story.get('title', 'N/A')[:50]}...")
|
||||
print(f" Score: {story.get('score', 'N/A')} | Comments: {story.get('descendants', 'N/A')}")
|
||||
print(f" URL: {story.get('url', 'N/A')[:60]}...")
|
||||
print()
|
||||
|
||||
print("✅ Hacker News tool working correctly!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Vendored
+40
@@ -0,0 +1,40 @@
|
||||
"""Jina Reader Tool Example.
|
||||
|
||||
This example demonstrates how to use the Jina Reader tool with PraisonAI agents.
|
||||
|
||||
Requirements:
|
||||
pip install "praisonai[tools]"
|
||||
|
||||
Usage:
|
||||
python tool.py
|
||||
|
||||
Note: Works without API key (with rate limits)
|
||||
"""
|
||||
|
||||
from praisonai_tools import JinaTool
|
||||
|
||||
|
||||
def main():
|
||||
# Initialize Jina tool (works without API key)
|
||||
jina = JinaTool()
|
||||
|
||||
# Example 1: Read a URL
|
||||
print("=" * 60)
|
||||
print("Example 1: Read URL Content")
|
||||
print("=" * 60)
|
||||
|
||||
result = jina.read("https://example.com")
|
||||
|
||||
if "error" in result:
|
||||
print(f"Error: {result['error']}")
|
||||
else:
|
||||
print(f"URL: {result.get('url', 'N/A')}")
|
||||
print(f"Title: {result.get('title', 'N/A')}")
|
||||
content = result.get('content', '')
|
||||
print(f"Content preview: {content[:300]}...")
|
||||
|
||||
print("\n✅ Jina tool working correctly!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
"""PostgreSQL Tool Example.
|
||||
|
||||
This example demonstrates how to use the PostgreSQL tool with PraisonAI agents.
|
||||
|
||||
Requirements:
|
||||
pip install "praisonai[tools]"
|
||||
Docker: docker run -d --name postgres -e POSTGRES_PASSWORD=praison123 -e POSTGRES_DB=praisonai -p 5432:5432 postgres:16
|
||||
|
||||
Usage:
|
||||
python tool.py
|
||||
"""
|
||||
|
||||
from praisonai_tools import PostgresTool
|
||||
|
||||
|
||||
def main():
|
||||
# Initialize PostgreSQL tool
|
||||
pg = PostgresTool(
|
||||
host="localhost",
|
||||
port=5432,
|
||||
database="praisonai",
|
||||
user="postgres",
|
||||
password="praison123"
|
||||
)
|
||||
|
||||
# Example 1: List tables
|
||||
print("=" * 60)
|
||||
print("Example 1: List Tables")
|
||||
print("=" * 60)
|
||||
|
||||
tables = pg.list_tables()
|
||||
|
||||
if isinstance(tables, dict) and "error" in tables:
|
||||
print(f"Error: {tables['error']}")
|
||||
else:
|
||||
print(f"Found {len(tables)} tables:")
|
||||
for t in tables[:10]:
|
||||
print(f" - {t.get('table_name', t)}")
|
||||
|
||||
# Example 2: Query
|
||||
print("\n" + "=" * 60)
|
||||
print("Example 2: Execute Query")
|
||||
print("=" * 60)
|
||||
|
||||
result = pg.query("SELECT version()")
|
||||
|
||||
if isinstance(result, dict) and "error" in result:
|
||||
print(f"Error: {result['error']}")
|
||||
else:
|
||||
print(f"PostgreSQL version: {result}")
|
||||
|
||||
print("\n✅ PostgreSQL tool working correctly!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
"""Serper Tool Example.
|
||||
|
||||
This example demonstrates how to use the Serper (Google Search) tool with PraisonAI agents.
|
||||
|
||||
Requirements:
|
||||
pip install "praisonai[tools]"
|
||||
export SERPER_API_KEY=your_api_key
|
||||
|
||||
Usage:
|
||||
python tool.py
|
||||
"""
|
||||
|
||||
import os
|
||||
from praisonai_tools import SerperTool
|
||||
|
||||
|
||||
def main():
|
||||
# Check for API key
|
||||
if not os.getenv("SERPER_API_KEY"):
|
||||
print("Error: SERPER_API_KEY environment variable not set")
|
||||
print("Get your API key from https://serper.dev/")
|
||||
return
|
||||
|
||||
# Initialize Serper tool
|
||||
serper = SerperTool()
|
||||
|
||||
# Example 1: Web search
|
||||
print("=" * 60)
|
||||
print("Example 1: Google Web Search")
|
||||
print("=" * 60)
|
||||
|
||||
results = serper.search("Python programming tutorials", max_results=3)
|
||||
|
||||
if results and "error" in results[0]:
|
||||
print(f"Error: {results[0]['error']}")
|
||||
else:
|
||||
print(f"Found {len(results)} results:")
|
||||
for r in results:
|
||||
print(f" - {r.get('title', 'N/A')[:50]}...")
|
||||
print(f" URL: {r.get('link', 'N/A')[:60]}...")
|
||||
print()
|
||||
|
||||
print("✅ Serper tool working correctly!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
"""Tavily Search Tool Example.
|
||||
|
||||
This example demonstrates how to use the Tavily search tool with PraisonAI agents.
|
||||
|
||||
Requirements:
|
||||
pip install "praisonai[tools]"
|
||||
export TAVILY_API_KEY=your_api_key
|
||||
|
||||
Usage:
|
||||
python tool.py
|
||||
"""
|
||||
|
||||
import os
|
||||
from praisonai_tools import TavilyTool
|
||||
|
||||
def main():
|
||||
# Check for API key
|
||||
if not os.getenv("TAVILY_API_KEY"):
|
||||
print("Error: TAVILY_API_KEY environment variable not set")
|
||||
print("Get your API key from https://tavily.com/")
|
||||
return
|
||||
|
||||
# Initialize Tavily tool
|
||||
tavily = TavilyTool()
|
||||
|
||||
# Example 1: Basic search
|
||||
print("=" * 60)
|
||||
print("Example 1: Basic Search")
|
||||
print("=" * 60)
|
||||
|
||||
results = tavily.search("What is quantum computing?", max_results=3)
|
||||
|
||||
if "error" in results:
|
||||
print(f"Error: {results['error']}")
|
||||
else:
|
||||
print(f"Query: {results.get('query')}")
|
||||
if "answer" in results:
|
||||
print(f"\nAI Answer: {results['answer'][:200]}...")
|
||||
print(f"\nResults ({len(results.get('results', []))}):")
|
||||
for r in results.get("results", []):
|
||||
print(f" - {r['title']}")
|
||||
print(f" URL: {r['url']}")
|
||||
print(f" Score: {r.get('score', 'N/A')}")
|
||||
|
||||
# Example 2: Search context for RAG
|
||||
print("\n" + "=" * 60)
|
||||
print("Example 2: Search Context (for RAG)")
|
||||
print("=" * 60)
|
||||
|
||||
context = tavily.search_context("Python best practices 2024")
|
||||
print(f"Context (first 300 chars): {context[:300]}...")
|
||||
|
||||
print("\n✅ Tavily tool working correctly!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
"""Wikipedia Tool Example.
|
||||
|
||||
This example demonstrates how to use the Wikipedia tool with PraisonAI agents.
|
||||
|
||||
Requirements:
|
||||
pip install "praisonai[tools]"
|
||||
|
||||
Usage:
|
||||
python tool.py
|
||||
|
||||
Note: No API key required!
|
||||
"""
|
||||
|
||||
from praisonai_tools import WikipediaTool
|
||||
|
||||
|
||||
def main():
|
||||
# Initialize Wikipedia tool (no API key needed)
|
||||
wiki = WikipediaTool()
|
||||
|
||||
# Example 1: Search Wikipedia
|
||||
print("=" * 60)
|
||||
print("Example 1: Search Wikipedia")
|
||||
print("=" * 60)
|
||||
|
||||
results = wiki.search("Python programming", max_results=5)
|
||||
|
||||
if results and "error" in results[0]:
|
||||
print(f"Error: {results[0]['error']}")
|
||||
else:
|
||||
print(f"Found {len(results)} results:")
|
||||
for r in results:
|
||||
print(f" - {r['title']}")
|
||||
|
||||
# Example 2: Get page summary
|
||||
print("\n" + "=" * 60)
|
||||
print("Example 2: Get Page Summary")
|
||||
print("=" * 60)
|
||||
|
||||
summary = wiki.summary("Artificial intelligence", sentences=3)
|
||||
|
||||
if "error" in summary:
|
||||
print(f"Error: {summary['error']}")
|
||||
else:
|
||||
print(f"Title: {summary['title']}")
|
||||
print(f"Summary: {summary['summary'][:300]}...")
|
||||
|
||||
# Example 3: Get full page
|
||||
print("\n" + "=" * 60)
|
||||
print("Example 3: Get Full Page")
|
||||
print("=" * 60)
|
||||
|
||||
page = wiki.get_page("Machine learning")
|
||||
|
||||
if "error" in page:
|
||||
print(f"Error: {page['error']}")
|
||||
if "options" in page:
|
||||
print(f"Options: {page['options'][:5]}")
|
||||
else:
|
||||
print(f"Title: {page['title']}")
|
||||
print(f"URL: {page['url']}")
|
||||
print(f"Categories: {page['categories'][:3]}")
|
||||
print(f"Content preview: {page['content'][:200]}...")
|
||||
|
||||
print("\n✅ Wikipedia tool working correctly!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,21 @@
|
||||
"""ChromaDB Vector Store - Basic Test"""
|
||||
import sys
|
||||
sys.path.insert(0, 'src/praisonai')
|
||||
from praisonai.persistence import create_knowledge_store
|
||||
from praisonai.persistence.knowledge.base import KnowledgeDocument
|
||||
|
||||
store = create_knowledge_store("chroma", path="/tmp/chroma_demo")
|
||||
try:
|
||||
store.delete_collection("demo")
|
||||
except Exception:
|
||||
pass
|
||||
store.create_collection("demo", dimension=1536)
|
||||
docs = [
|
||||
KnowledgeDocument(id="1", content="Python is a programming language", embedding=[0.1]*1536),
|
||||
KnowledgeDocument(id="2", content="JavaScript runs in browsers", embedding=[0.2]*1536)
|
||||
]
|
||||
store.insert("demo", docs)
|
||||
results = store.search("demo", query_embedding=[0.1]*1536, limit=1)
|
||||
print(f"Found: {len(results)} results")
|
||||
assert len(results) >= 1
|
||||
print("PASSED: ChromaDB vector store")
|
||||
@@ -0,0 +1,31 @@
|
||||
"""LanceDB Vector Store - Local Test"""
|
||||
import sys
|
||||
import os
|
||||
import shutil
|
||||
|
||||
try:
|
||||
import lancedb
|
||||
except ImportError:
|
||||
print("SKIPPED: LanceDB - lancedb not installed")
|
||||
sys.exit(0)
|
||||
|
||||
# Clean up previous test
|
||||
db_path = "/tmp/lancedb_test"
|
||||
if os.path.exists(db_path):
|
||||
shutil.rmtree(db_path)
|
||||
|
||||
db = lancedb.connect(db_path)
|
||||
|
||||
# Create table with vectors
|
||||
data = [
|
||||
{"id": "1", "text": "Machine learning is AI", "vector": [0.1] * 128},
|
||||
{"id": "2", "text": "Deep learning uses neural nets", "vector": [0.2] * 128},
|
||||
]
|
||||
table = db.create_table("demo", data)
|
||||
|
||||
# Search
|
||||
results = table.search([0.1] * 128).limit(1).to_list()
|
||||
print(f"Found: {len(results)} results")
|
||||
print(f"Top result: {results[0]['text'][:30]}...")
|
||||
assert len(results) >= 1
|
||||
print("PASSED: LanceDB vector store")
|
||||
@@ -0,0 +1,20 @@
|
||||
"""LanceDB Vector Store - Basic Test"""
|
||||
import sys
|
||||
sys.path.insert(0, 'src/praisonai')
|
||||
try:
|
||||
from praisonai.persistence import create_knowledge_store
|
||||
from praisonai.persistence.knowledge.base import KnowledgeDocument
|
||||
|
||||
store = create_knowledge_store("lancedb", path="/tmp/lancedb_demo")
|
||||
store.create_collection("demo", dimension=1536)
|
||||
docs = [
|
||||
KnowledgeDocument(id="doc1", content="The Eiffel Tower is in Paris", embedding=[0.1]*1536),
|
||||
KnowledgeDocument(id="doc2", content="The Statue of Liberty is in New York", embedding=[0.2]*1536)
|
||||
]
|
||||
store.insert("demo", docs)
|
||||
results = store.search("demo", query_embedding=[0.1]*1536, limit=1)
|
||||
print(f"Found: {len(results)} results")
|
||||
assert len(results) >= 1
|
||||
print("PASSED: LanceDB vector store")
|
||||
except ImportError as e:
|
||||
print(f"SKIPPED: LanceDB - {e}")
|
||||
@@ -0,0 +1,59 @@
|
||||
"""PGVector Vector Store - Docker Test"""
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Docker: docker run -d --name pgvector -e POSTGRES_PASSWORD=postgres -p 5433:5432 pgvector/pgvector:pg16
|
||||
url = os.getenv("PGVECTOR_URL", "postgresql://postgres:postgres@localhost:5433/postgres")
|
||||
|
||||
try:
|
||||
import psycopg2
|
||||
except ImportError:
|
||||
print("SKIPPED: PGVector - psycopg2 not installed")
|
||||
sys.exit(0)
|
||||
|
||||
try:
|
||||
conn = psycopg2.connect(url)
|
||||
cur = conn.cursor()
|
||||
|
||||
# Ensure extension
|
||||
cur.execute("CREATE EXTENSION IF NOT EXISTS vector")
|
||||
conn.commit()
|
||||
|
||||
# Create test table
|
||||
cur.execute("DROP TABLE IF EXISTS test_vectors")
|
||||
cur.execute("""
|
||||
CREATE TABLE test_vectors (
|
||||
id SERIAL PRIMARY KEY,
|
||||
content TEXT,
|
||||
embedding vector(128)
|
||||
)
|
||||
""")
|
||||
conn.commit()
|
||||
|
||||
# Insert vectors
|
||||
cur.execute("""
|
||||
INSERT INTO test_vectors (content, embedding) VALUES
|
||||
('Machine learning is AI', %s),
|
||||
('Deep learning uses neural nets', %s)
|
||||
""", ([0.1] * 128, [0.2] * 128))
|
||||
conn.commit()
|
||||
|
||||
# Search (cosine similarity)
|
||||
cur.execute("""
|
||||
SELECT content, embedding <=> %s::vector AS distance
|
||||
FROM test_vectors
|
||||
ORDER BY distance
|
||||
LIMIT 1
|
||||
""", ([0.1] * 128,))
|
||||
|
||||
row = cur.fetchone()
|
||||
print(f"Top result: {row[0][:30]}... (distance: {row[1]:.4f})")
|
||||
|
||||
# Cleanup
|
||||
cur.execute("DROP TABLE test_vectors")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
print("PASSED: PGVector vector store")
|
||||
except Exception as e:
|
||||
print(f"SKIPPED: PGVector - {e}")
|
||||
@@ -0,0 +1,23 @@
|
||||
"""PGVector Knowledge Store - Basic Test (requires Docker)"""
|
||||
import sys
|
||||
import os
|
||||
sys.path.insert(0, 'src/praisonai')
|
||||
from praisonai.persistence import create_knowledge_store
|
||||
from praisonai.persistence.knowledge.base import KnowledgeDocument
|
||||
|
||||
# Docker: docker run -d --name pgvector -e POSTGRES_PASSWORD=postgres -p 5433:5432 pgvector/pgvector:pg16
|
||||
url = os.getenv("PGVECTOR_URL", "postgresql://postgres:postgres@localhost:5433/postgres")
|
||||
try:
|
||||
store = create_knowledge_store("pgvector", url=url)
|
||||
store.create_collection("demo", dimension=1536)
|
||||
docs = [
|
||||
KnowledgeDocument(id="1", content="Kubernetes orchestrates containers", embedding=[0.1]*1536),
|
||||
KnowledgeDocument(id="2", content="Docker packages applications", embedding=[0.2]*1536)
|
||||
]
|
||||
store.insert("demo", docs)
|
||||
results = store.search("demo", query_embedding=[0.1]*1536, limit=1)
|
||||
print(f"Found: {len(results)} results")
|
||||
assert len(results) >= 1
|
||||
print("PASSED: PGVector vector store")
|
||||
except Exception as e:
|
||||
print(f"SKIPPED: PGVector - {e}")
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Pinecone Vector Store - Real API Test"""
|
||||
import os
|
||||
import sys
|
||||
sys.path.insert(0, 'src/praisonai')
|
||||
|
||||
# Requires: export PINECONE_API_KEY=...
|
||||
api_key = os.getenv("PINECONE_API_KEY")
|
||||
if not api_key:
|
||||
print("SKIPPED: Pinecone - PINECONE_API_KEY not set")
|
||||
sys.exit(0)
|
||||
|
||||
from pinecone import Pinecone
|
||||
|
||||
pc = Pinecone(api_key=api_key)
|
||||
indexes = pc.list_indexes()
|
||||
print(f"Pinecone connected! Found {len(list(indexes))} indexes")
|
||||
|
||||
# Use existing 'test' index or create one
|
||||
index_name = "test"
|
||||
if index_name not in [idx.name for idx in pc.list_indexes()]:
|
||||
print(f"SKIPPED: Pinecone - index '{index_name}' not found")
|
||||
sys.exit(0)
|
||||
|
||||
index = pc.Index(index_name)
|
||||
stats = index.describe_index_stats()
|
||||
print(f"Index '{index_name}' has {stats.total_vector_count} vectors")
|
||||
print("PASSED: Pinecone vector store")
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Qdrant Vector Store - Basic Test (requires Docker)"""
|
||||
import sys, os
|
||||
sys.path.insert(0, 'src/praisonai')
|
||||
from praisonai.persistence import create_knowledge_store
|
||||
from praisonai.persistence.knowledge.base import KnowledgeDocument
|
||||
|
||||
# Docker: docker run -d --name qdrant -p 6333:6333 qdrant/qdrant
|
||||
url = os.getenv("QDRANT_URL", "http://localhost:6333")
|
||||
store = create_knowledge_store("qdrant", url=url)
|
||||
try:
|
||||
store.delete_collection("demo")
|
||||
except Exception:
|
||||
pass
|
||||
store.create_collection("demo", dimension=1536)
|
||||
docs = [
|
||||
KnowledgeDocument(id="1", content="Machine learning is AI", embedding=[0.1]*1536),
|
||||
KnowledgeDocument(id="2", content="Deep learning uses neural nets", embedding=[0.2]*1536)
|
||||
]
|
||||
store.insert("demo", docs)
|
||||
results = store.search("demo", query_embedding=[0.1]*1536, limit=1)
|
||||
print(f"Found: {len(results)} results")
|
||||
assert len(results) >= 1
|
||||
print("PASSED: Qdrant vector store")
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Weaviate Cloud Vector Store - Real API Test"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Requires: export WEAVIATE_URL=... and WEAVIATE_API_KEY=...
|
||||
weaviate_url = os.getenv("WEAVIATE_URL", "https://thhoyczshsawyt16ot3uw.c0.europe-west3.gcp.weaviate.cloud")
|
||||
weaviate_key = os.getenv("WEAVIATE_API_KEY")
|
||||
if not weaviate_key:
|
||||
print("SKIPPED: Weaviate - WEAVIATE_API_KEY not set")
|
||||
sys.exit(0)
|
||||
|
||||
import weaviate
|
||||
from weaviate.classes.init import Auth
|
||||
|
||||
client = weaviate.connect_to_weaviate_cloud(
|
||||
cluster_url=weaviate_url,
|
||||
auth_credentials=Auth.api_key(weaviate_key)
|
||||
)
|
||||
|
||||
print(f"Weaviate connected: {client.is_ready()}")
|
||||
collections = client.collections.list_all()
|
||||
print(f"Found {len(collections)} collections")
|
||||
client.close()
|
||||
print("PASSED: Weaviate cloud vector store")
|
||||
@@ -54,6 +54,8 @@ except ImportError:
|
||||
MCP = None
|
||||
from .session import Session
|
||||
from .memory.memory import Memory
|
||||
from .db import db
|
||||
from .obs import obs
|
||||
# Workflows - import from dedicated workflows module
|
||||
from .workflows import (
|
||||
Workflow, WorkflowStep, WorkflowContext, StepResult,
|
||||
@@ -257,6 +259,8 @@ __all__ = [
|
||||
'AutoAgents',
|
||||
'Session',
|
||||
'Memory',
|
||||
'db',
|
||||
'obs',
|
||||
'display_interaction',
|
||||
'display_self_reflection',
|
||||
'display_instruction',
|
||||
|
||||
@@ -252,7 +252,12 @@ class Agent:
|
||||
history_in_context: Optional[int] = None,
|
||||
auto_save: Optional[str] = None,
|
||||
skills: Optional[List[str]] = None,
|
||||
skills_dirs: Optional[List[str]] = None
|
||||
skills_dirs: Optional[List[str]] = None,
|
||||
db: Optional[Any] = None,
|
||||
session_id: Optional[str] = None,
|
||||
hooks: Optional[List[Any]] = None,
|
||||
llm_config: Optional[Dict[str, Any]] = None,
|
||||
rate_limiter: Optional[Any] = None
|
||||
):
|
||||
"""Initialize an Agent instance.
|
||||
|
||||
@@ -393,6 +398,17 @@ class Agent:
|
||||
# Flag to track if final result has been displayed to prevent duplicates
|
||||
self._final_display_shown = False
|
||||
|
||||
# Store hooks for middleware system (zero overhead when empty)
|
||||
self._hooks = hooks or []
|
||||
self._middleware_manager = None # Lazy init
|
||||
|
||||
# Store llm_config for configurable model switching
|
||||
self._llm_config = llm_config or {}
|
||||
self._llm_configurable = self._llm_config.get('configurable', False)
|
||||
|
||||
# Store rate limiter (optional, zero overhead when None)
|
||||
self._rate_limiter = rate_limiter
|
||||
|
||||
# Store OpenAI client parameters for lazy initialization
|
||||
self._openai_api_key = api_key
|
||||
self._openai_base_url = base_url
|
||||
@@ -615,6 +631,11 @@ Your Goal: {self.goal}
|
||||
self._skill_manager = None # Lazy loaded
|
||||
self._skills_initialized = False
|
||||
|
||||
# Database persistence (lazy - no imports until used)
|
||||
self._db = db
|
||||
self._session_id = session_id
|
||||
self._db_initialized = False
|
||||
|
||||
@property
|
||||
def console(self):
|
||||
"""Lazily initialize Rich Console only when needed."""
|
||||
@@ -651,8 +672,45 @@ Your Goal: {self.goal}
|
||||
self._skill_manager.discover(self._skills_dirs, include_defaults=False)
|
||||
|
||||
self._skills_initialized = True
|
||||
|
||||
# Auto-add skill execution tools if not already present
|
||||
self._add_skill_tools()
|
||||
return self._skill_manager
|
||||
|
||||
def _add_skill_tools(self):
|
||||
"""Add tools required for skill execution (read_file, run_skill_script).
|
||||
|
||||
Uses lazy imports from praisonaiagents.tools to avoid performance impact
|
||||
when skills are not used.
|
||||
"""
|
||||
# Check if tools already include required capabilities
|
||||
tool_names = set()
|
||||
for tool in self.tools:
|
||||
if callable(tool) and hasattr(tool, '__name__'):
|
||||
tool_names.add(tool.__name__)
|
||||
elif hasattr(tool, 'name'):
|
||||
tool_names.add(tool.name)
|
||||
|
||||
# Add read_file if not present
|
||||
if 'read_file' not in tool_names:
|
||||
try:
|
||||
from ..tools import read_file
|
||||
self.tools.append(read_file)
|
||||
logging.debug("Added read_file tool for skill support")
|
||||
except ImportError:
|
||||
logging.warning("Could not import read_file tool for skills")
|
||||
|
||||
# Add run_skill_script from skill_tools module
|
||||
if 'run_skill_script' not in tool_names:
|
||||
try:
|
||||
from ..tools.skill_tools import create_skill_tools
|
||||
# Create skill tools with current working directory
|
||||
skill_tools = create_skill_tools()
|
||||
self.tools.append(skill_tools.run_skill_script)
|
||||
logging.debug("Added run_skill_script tool for skill support")
|
||||
except ImportError as e:
|
||||
logging.warning(f"Could not import skill_tools: {e}")
|
||||
|
||||
def get_skills_prompt(self) -> str:
|
||||
"""Get the XML prompt for available skills.
|
||||
|
||||
@@ -1321,6 +1379,13 @@ Your Goal: {self.goal}"""
|
||||
if self.verbose:
|
||||
self._display_memory_info()
|
||||
|
||||
# Add skills prompt if skills are configured
|
||||
if self._skills or self._skills_dirs:
|
||||
skills_prompt = self.get_skills_prompt()
|
||||
if skills_prompt:
|
||||
system_prompt += f"\n\n## Available Skills\n{skills_prompt}"
|
||||
system_prompt += "\n\nWhen a skill is relevant to the task, read its SKILL.md file to get detailed instructions. If the skill has scripts in its scripts/ directory, you can execute them using the execute_code or run_script tool."
|
||||
|
||||
# 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
|
||||
@@ -1571,8 +1636,32 @@ Your Goal: {self.goal}"""
|
||||
def execute_tool(self, function_name, arguments):
|
||||
"""
|
||||
Execute a tool dynamically based on the function name and arguments.
|
||||
Injects agent state for tools with Injected[T] parameters.
|
||||
"""
|
||||
logging.debug(f"{self.name} executing tool {function_name} with arguments: {arguments}")
|
||||
|
||||
# Set up injection context for tools with Injected parameters
|
||||
from ..tools.injected import AgentState, with_injection_context
|
||||
state = AgentState(
|
||||
agent_id=self.name,
|
||||
run_id=getattr(self, '_current_run_id', 'unknown'),
|
||||
session_id=getattr(self, '_session_id', None) or 'default',
|
||||
last_user_message=self.chat_history[-1].get('content') if self.chat_history else None,
|
||||
metadata={'agent_name': self.name}
|
||||
)
|
||||
|
||||
# Execute within injection context
|
||||
return self._execute_tool_with_context(function_name, arguments, state)
|
||||
|
||||
def _execute_tool_with_context(self, function_name, arguments, state):
|
||||
"""Execute tool within injection context."""
|
||||
from ..tools.injected import with_injection_context
|
||||
|
||||
with with_injection_context(state):
|
||||
return self._execute_tool_impl(function_name, arguments)
|
||||
|
||||
def _execute_tool_impl(self, function_name, arguments):
|
||||
"""Internal tool execution implementation."""
|
||||
|
||||
# Check if approval is required for this tool
|
||||
from ..approval import is_approval_required, console_approval_callback, get_risk_level, mark_approved, ApprovalDecision, get_approval_callback
|
||||
@@ -1913,7 +2002,112 @@ Your Goal: {self.goal}"""
|
||||
# expand=False
|
||||
# )
|
||||
|
||||
def chat(self, prompt, temperature=1.0, tools=None, output_json=None, output_pydantic=None, reasoning_steps=False, stream=None, task_name=None, task_description=None, task_id=None):
|
||||
def _init_db_session(self):
|
||||
"""Initialize DB session if db adapter is provided (lazy, first chat only)."""
|
||||
if self._db is None or self._db_initialized:
|
||||
return
|
||||
|
||||
# Generate session_id if not provided: default to per-hour ID (YYYYMMDDHH-agentname)
|
||||
if self._session_id is None:
|
||||
import hashlib
|
||||
from datetime import datetime, timezone
|
||||
# Per-hour session ID: YYYYMMDDHH (UTC) + agent name hash for uniqueness
|
||||
hour_str = datetime.now(timezone.utc).strftime("%Y%m%d%H")
|
||||
agent_hash = hashlib.md5(self.name.encode()).hexdigest()[:6]
|
||||
self._session_id = f"{hour_str}-{agent_hash}"
|
||||
|
||||
# Call db adapter's on_agent_start to get previous messages
|
||||
try:
|
||||
history = self._db.on_agent_start(
|
||||
agent_name=self.name,
|
||||
session_id=self._session_id,
|
||||
user_id=self.user_id,
|
||||
metadata={"role": self.role, "goal": self.goal}
|
||||
)
|
||||
|
||||
# Restore chat history from previous session
|
||||
if history:
|
||||
for msg in history:
|
||||
self.chat_history.append({
|
||||
"role": msg.role,
|
||||
"content": msg.content
|
||||
})
|
||||
logging.info(f"Resumed session {self._session_id} with {len(history)} messages")
|
||||
except Exception as e:
|
||||
logging.warning(f"Failed to initialize DB session: {e}")
|
||||
|
||||
self._db_initialized = True
|
||||
self._current_run_id = None # Track current run
|
||||
|
||||
def _start_run(self, input_content: str):
|
||||
"""Start a new run (turn) for persistence tracking."""
|
||||
if self._db is None:
|
||||
return
|
||||
|
||||
import uuid
|
||||
self._current_run_id = f"run-{uuid.uuid4().hex[:12]}"
|
||||
|
||||
try:
|
||||
if hasattr(self._db, 'on_run_start'):
|
||||
self._db.on_run_start(
|
||||
session_id=self._session_id,
|
||||
run_id=self._current_run_id,
|
||||
input_content=input_content,
|
||||
metadata={"agent_name": self.name}
|
||||
)
|
||||
except Exception as e:
|
||||
logging.warning(f"Failed to start run: {e}")
|
||||
|
||||
def _end_run(self, output_content: str, status: str = "completed", metrics: dict = None):
|
||||
"""End the current run (turn)."""
|
||||
if self._db is None or self._current_run_id is None:
|
||||
return
|
||||
|
||||
try:
|
||||
if hasattr(self._db, 'on_run_end'):
|
||||
self._db.on_run_end(
|
||||
session_id=self._session_id,
|
||||
run_id=self._current_run_id,
|
||||
output_content=output_content,
|
||||
status=status,
|
||||
metrics=metrics or {},
|
||||
metadata={"agent_name": self.name}
|
||||
)
|
||||
except Exception as e:
|
||||
logging.warning(f"Failed to end run: {e}")
|
||||
|
||||
self._current_run_id = None
|
||||
|
||||
def _persist_message(self, role: str, content: str):
|
||||
"""Persist a message to the DB if adapter is provided."""
|
||||
if self._db is None:
|
||||
return
|
||||
|
||||
try:
|
||||
if role == "user":
|
||||
self._db.on_user_message(self._session_id, content)
|
||||
elif role == "assistant":
|
||||
self._db.on_agent_message(self._session_id, content)
|
||||
except Exception as e:
|
||||
logging.warning(f"Failed to persist message: {e}")
|
||||
|
||||
@property
|
||||
def session_id(self) -> Optional[str]:
|
||||
"""Get the current session ID."""
|
||||
return self._session_id
|
||||
|
||||
def chat(self, prompt, temperature=1.0, tools=None, output_json=None, output_pydantic=None, reasoning_steps=False, stream=None, task_name=None, task_description=None, task_id=None, config=None):
|
||||
# Apply rate limiter if configured (before any LLM call)
|
||||
if self._rate_limiter is not None:
|
||||
self._rate_limiter.acquire()
|
||||
|
||||
# Initialize DB session on first chat (lazy)
|
||||
self._init_db_session()
|
||||
|
||||
# Start a new run for this chat turn
|
||||
prompt_str = prompt if isinstance(prompt, str) else str(prompt)
|
||||
self._start_run(prompt_str)
|
||||
|
||||
# Reset the final display flag for each new conversation
|
||||
self._final_display_shown = False
|
||||
|
||||
@@ -2010,6 +2204,8 @@ Your Goal: {self.goal}"""
|
||||
self.chat_history[-1].get("content") == normalized_content):
|
||||
# Add user message to chat history BEFORE LLM call so handoffs can access it
|
||||
self.chat_history.append({"role": "user", "content": normalized_content})
|
||||
# Persist user message to DB
|
||||
self._persist_message("user", normalized_content)
|
||||
|
||||
try:
|
||||
# Pass everything to LLM class
|
||||
@@ -2039,6 +2235,8 @@ Your Goal: {self.goal}"""
|
||||
)
|
||||
|
||||
self.chat_history.append({"role": "assistant", "content": response_text})
|
||||
# Persist assistant message to DB
|
||||
self._persist_message("assistant", response_text)
|
||||
|
||||
# Log completion time if in debug mode
|
||||
if logging.getLogger().getEffectiveLevel() == logging.DEBUG:
|
||||
@@ -2083,6 +2281,8 @@ Your Goal: {self.goal}"""
|
||||
self.chat_history[-1].get("content") == normalized_content):
|
||||
# Add user message to chat history BEFORE LLM call so handoffs can access it
|
||||
self.chat_history.append({"role": "user", "content": normalized_content})
|
||||
# Persist user message to DB (OpenAI path)
|
||||
self._persist_message("user", normalized_content)
|
||||
|
||||
reflection_count = 0
|
||||
start_time = time.time()
|
||||
@@ -2122,6 +2322,8 @@ Your Goal: {self.goal}"""
|
||||
# Add to chat history and return raw response
|
||||
# User message already added before LLM call via _build_messages
|
||||
self.chat_history.append({"role": "assistant", "content": response_text})
|
||||
# Persist assistant message to DB
|
||||
self._persist_message("assistant", response_text)
|
||||
# Apply guardrail validation even for JSON output
|
||||
try:
|
||||
validated_response = self._apply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id)
|
||||
@@ -2137,6 +2339,8 @@ Your Goal: {self.goal}"""
|
||||
if not self.self_reflect:
|
||||
# User message already added before LLM call via _build_messages
|
||||
self.chat_history.append({"role": "assistant", "content": response_text})
|
||||
# Persist assistant message to DB (non-reflect path)
|
||||
self._persist_message("assistant", response_text)
|
||||
if self.verbose:
|
||||
logging.debug(f"Agent {self.name} final response: {response_text}")
|
||||
# Return only reasoning content if reasoning_steps is True
|
||||
@@ -2225,11 +2429,13 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
validated_response = self._apply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id)
|
||||
# Execute callback after validation
|
||||
self._execute_callback_and_display(original_prompt, validated_response, time.time() - start_time, task_name, task_description, task_id)
|
||||
self._end_run(validated_response, "completed", {"duration_ms": (time.time() - start_time) * 1000})
|
||||
return validated_response
|
||||
except Exception as e:
|
||||
logging.error(f"Agent {self.name}: Guardrail validation failed after reflection: {e}")
|
||||
# Rollback chat history on guardrail failure
|
||||
self.chat_history = self.chat_history[:chat_history_length]
|
||||
self._end_run(None, "error", {"error": str(e)})
|
||||
return None
|
||||
|
||||
# Check if we've hit max reflections
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
"""
|
||||
Background Agents Module for PraisonAI Agents.
|
||||
|
||||
Provides the ability to run agents in the background, allowing:
|
||||
- Long-running tasks without blocking
|
||||
- Task queuing and management
|
||||
- Progress monitoring and notifications
|
||||
- Graceful cancellation
|
||||
|
||||
Zero Performance Impact:
|
||||
- All imports are lazy loaded via __getattr__
|
||||
- Background processing only when explicitly started
|
||||
- No overhead when not in use
|
||||
|
||||
Usage:
|
||||
from praisonaiagents.background import BackgroundRunner, BackgroundTask
|
||||
|
||||
# Create a background runner
|
||||
runner = BackgroundRunner()
|
||||
|
||||
# Submit a task
|
||||
task = runner.submit(
|
||||
agent=my_agent,
|
||||
prompt="Research AI trends",
|
||||
callback=on_complete
|
||||
)
|
||||
|
||||
# Check status
|
||||
print(task.status) # "running", "completed", "failed"
|
||||
|
||||
# Wait for completion
|
||||
result = await task.wait()
|
||||
"""
|
||||
|
||||
__all__ = [
|
||||
# Core classes
|
||||
"BackgroundRunner",
|
||||
"BackgroundTask",
|
||||
# Status enum
|
||||
"TaskStatus",
|
||||
# Configuration
|
||||
"BackgroundConfig",
|
||||
]
|
||||
|
||||
|
||||
def __getattr__(name: str):
|
||||
"""Lazy load module components to avoid import overhead."""
|
||||
if name == "BackgroundRunner":
|
||||
from .runner import BackgroundRunner
|
||||
return BackgroundRunner
|
||||
|
||||
if name == "BackgroundTask":
|
||||
from .task import BackgroundTask
|
||||
return BackgroundTask
|
||||
|
||||
if name == "TaskStatus":
|
||||
from .task import TaskStatus
|
||||
return TaskStatus
|
||||
|
||||
if name == "BackgroundConfig":
|
||||
from .config import BackgroundConfig
|
||||
return BackgroundConfig
|
||||
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
@@ -0,0 +1,17 @@
|
||||
"""
|
||||
Background Configuration for PraisonAI Agents.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class BackgroundConfig:
|
||||
"""Configuration for background task execution."""
|
||||
max_concurrent_tasks: int = 5
|
||||
default_timeout: Optional[float] = None # None = no timeout
|
||||
auto_cleanup: bool = True # Auto-remove completed tasks
|
||||
cleanup_delay: float = 300.0 # Seconds to keep completed tasks
|
||||
enable_notifications: bool = True
|
||||
log_progress: bool = True
|
||||
@@ -0,0 +1,314 @@
|
||||
"""
|
||||
Background Runner for PraisonAI Agents.
|
||||
|
||||
Manages background task execution.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Optional, List, Dict, Any, Callable, Union
|
||||
from datetime import datetime
|
||||
|
||||
from .task import BackgroundTask, TaskStatus
|
||||
from .config import BackgroundConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BackgroundRunner:
|
||||
"""
|
||||
Manages background task execution.
|
||||
|
||||
Provides:
|
||||
- Task submission and queuing
|
||||
- Concurrent execution management
|
||||
- Progress monitoring
|
||||
- Task cancellation
|
||||
|
||||
Example:
|
||||
runner = BackgroundRunner()
|
||||
|
||||
# Submit a task
|
||||
task = await runner.submit(
|
||||
func=my_agent.start,
|
||||
args=("Research AI trends",),
|
||||
name="research_task"
|
||||
)
|
||||
|
||||
# Check status
|
||||
print(task.status)
|
||||
|
||||
# Wait for result
|
||||
result = await task.wait()
|
||||
"""
|
||||
|
||||
def __init__(self, config: Optional[BackgroundConfig] = None):
|
||||
"""
|
||||
Initialize the background runner.
|
||||
|
||||
Args:
|
||||
config: Configuration for background execution
|
||||
"""
|
||||
self.config = config or BackgroundConfig()
|
||||
self._tasks: Dict[str, BackgroundTask] = {}
|
||||
self._semaphore = asyncio.Semaphore(self.config.max_concurrent_tasks)
|
||||
self._running = False
|
||||
self._cleanup_task: Optional[asyncio.Task] = None
|
||||
|
||||
@property
|
||||
def tasks(self) -> List[BackgroundTask]:
|
||||
"""Get all tasks."""
|
||||
return list(self._tasks.values())
|
||||
|
||||
@property
|
||||
def running_tasks(self) -> List[BackgroundTask]:
|
||||
"""Get currently running tasks."""
|
||||
return [t for t in self._tasks.values() if t.is_running]
|
||||
|
||||
@property
|
||||
def pending_tasks(self) -> List[BackgroundTask]:
|
||||
"""Get pending tasks."""
|
||||
return [t for t in self._tasks.values() if t.status == TaskStatus.PENDING]
|
||||
|
||||
async def submit(
|
||||
self,
|
||||
func: Callable,
|
||||
args: tuple = (),
|
||||
kwargs: Optional[Dict[str, Any]] = None,
|
||||
name: Optional[str] = None,
|
||||
timeout: Optional[float] = None,
|
||||
on_complete: Optional[Callable[[BackgroundTask], None]] = None,
|
||||
on_progress: Optional[Callable[[BackgroundTask], None]] = None
|
||||
) -> BackgroundTask:
|
||||
"""
|
||||
Submit a task for background execution.
|
||||
|
||||
Args:
|
||||
func: Function to execute (can be sync or async)
|
||||
args: Positional arguments for the function
|
||||
kwargs: Keyword arguments for the function
|
||||
name: Optional task name
|
||||
timeout: Optional timeout in seconds
|
||||
on_complete: Callback when task completes
|
||||
on_progress: Callback for progress updates
|
||||
|
||||
Returns:
|
||||
BackgroundTask object for tracking
|
||||
"""
|
||||
kwargs = kwargs or {}
|
||||
|
||||
task = BackgroundTask(
|
||||
name=name or func.__name__,
|
||||
metadata={
|
||||
"func_name": func.__name__,
|
||||
"timeout": timeout or self.config.default_timeout
|
||||
}
|
||||
)
|
||||
|
||||
self._tasks[task.id] = task
|
||||
|
||||
# Create the execution coroutine
|
||||
async def execute():
|
||||
async with self._semaphore:
|
||||
task.start()
|
||||
logger.info(f"Background task started: {task.name} ({task.id})")
|
||||
|
||||
try:
|
||||
# Check if function is async
|
||||
if asyncio.iscoroutinefunction(func):
|
||||
result = await asyncio.wait_for(
|
||||
func(*args, **kwargs),
|
||||
timeout=timeout or self.config.default_timeout
|
||||
)
|
||||
else:
|
||||
# Run sync function in thread pool
|
||||
loop = asyncio.get_event_loop()
|
||||
result = await asyncio.wait_for(
|
||||
loop.run_in_executor(None, lambda: func(*args, **kwargs)),
|
||||
timeout=timeout or self.config.default_timeout
|
||||
)
|
||||
|
||||
task.complete(result)
|
||||
logger.info(f"Background task completed: {task.name} ({task.id})")
|
||||
|
||||
if on_complete:
|
||||
try:
|
||||
on_complete(task)
|
||||
except Exception as e:
|
||||
logger.warning(f"on_complete callback error: {e}")
|
||||
|
||||
return result
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
task.fail(f"Task timed out after {timeout}s")
|
||||
logger.warning(f"Background task timed out: {task.name}")
|
||||
|
||||
except asyncio.CancelledError:
|
||||
task.cancel()
|
||||
logger.info(f"Background task cancelled: {task.name}")
|
||||
raise
|
||||
|
||||
except Exception as e:
|
||||
task.fail(str(e))
|
||||
logger.error(f"Background task failed: {task.name} - {e}")
|
||||
|
||||
if on_complete:
|
||||
try:
|
||||
on_complete(task)
|
||||
except Exception as cb_error:
|
||||
logger.warning(f"on_complete callback error: {cb_error}")
|
||||
|
||||
# Create and store the future
|
||||
task._future = asyncio.create_task(execute())
|
||||
|
||||
return task
|
||||
|
||||
async def submit_agent(
|
||||
self,
|
||||
agent: Any,
|
||||
prompt: str,
|
||||
name: Optional[str] = None,
|
||||
timeout: Optional[float] = None,
|
||||
on_complete: Optional[Callable[[BackgroundTask], None]] = None
|
||||
) -> BackgroundTask:
|
||||
"""
|
||||
Submit an agent task for background execution.
|
||||
|
||||
Args:
|
||||
agent: Agent instance to run
|
||||
prompt: Prompt to send to the agent
|
||||
name: Optional task name
|
||||
timeout: Optional timeout in seconds
|
||||
on_complete: Callback when task completes
|
||||
|
||||
Returns:
|
||||
BackgroundTask object for tracking
|
||||
"""
|
||||
task_name = name or f"agent_{getattr(agent, 'name', 'unknown')}"
|
||||
|
||||
# Determine the method to call
|
||||
if hasattr(agent, 'start'):
|
||||
func = agent.start
|
||||
elif hasattr(agent, 'chat'):
|
||||
func = agent.chat
|
||||
elif hasattr(agent, 'run'):
|
||||
func = agent.run
|
||||
else:
|
||||
raise ValueError("Agent must have start(), chat(), or run() method")
|
||||
|
||||
return await self.submit(
|
||||
func=func,
|
||||
args=(prompt,),
|
||||
name=task_name,
|
||||
timeout=timeout,
|
||||
on_complete=on_complete
|
||||
)
|
||||
|
||||
def get_task(self, task_id: str) -> Optional[BackgroundTask]:
|
||||
"""Get a task by ID."""
|
||||
return self._tasks.get(task_id)
|
||||
|
||||
async def cancel_task(self, task_id: str) -> bool:
|
||||
"""
|
||||
Cancel a task.
|
||||
|
||||
Args:
|
||||
task_id: ID of task to cancel
|
||||
|
||||
Returns:
|
||||
True if cancelled, False if not found or already completed
|
||||
"""
|
||||
task = self._tasks.get(task_id)
|
||||
if task is None:
|
||||
return False
|
||||
|
||||
if task.is_completed:
|
||||
return False
|
||||
|
||||
task.cancel()
|
||||
|
||||
if task._future and not task._future.done():
|
||||
task._future.cancel()
|
||||
|
||||
return True
|
||||
|
||||
async def cancel_all(self):
|
||||
"""Cancel all running tasks."""
|
||||
for task in self.running_tasks:
|
||||
await self.cancel_task(task.id)
|
||||
|
||||
async def wait_all(self, timeout: Optional[float] = None) -> List[BackgroundTask]:
|
||||
"""
|
||||
Wait for all tasks to complete.
|
||||
|
||||
Args:
|
||||
timeout: Maximum time to wait
|
||||
|
||||
Returns:
|
||||
List of completed tasks
|
||||
"""
|
||||
futures = [t._future for t in self._tasks.values() if t._future]
|
||||
|
||||
if not futures:
|
||||
return list(self._tasks.values())
|
||||
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
asyncio.gather(*futures, return_exceptions=True),
|
||||
timeout=timeout
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
|
||||
return list(self._tasks.values())
|
||||
|
||||
def clear_completed(self):
|
||||
"""Remove completed tasks from tracking."""
|
||||
completed_ids = [
|
||||
task_id for task_id, task in self._tasks.items()
|
||||
if task.is_completed
|
||||
]
|
||||
for task_id in completed_ids:
|
||||
del self._tasks[task_id]
|
||||
|
||||
def list_tasks(self, status: Optional[TaskStatus] = None) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
List tasks with optional status filter.
|
||||
|
||||
Args:
|
||||
status: Optional status to filter by
|
||||
|
||||
Returns:
|
||||
List of task dictionaries
|
||||
"""
|
||||
tasks = self._tasks.values()
|
||||
|
||||
if status:
|
||||
tasks = [t for t in tasks if t.status == status]
|
||||
|
||||
return [t.to_dict() for t in tasks]
|
||||
|
||||
async def start_cleanup_loop(self):
|
||||
"""Start the automatic cleanup loop."""
|
||||
if self._cleanup_task is not None:
|
||||
return
|
||||
|
||||
async def cleanup_loop():
|
||||
while True:
|
||||
await asyncio.sleep(self.config.cleanup_delay)
|
||||
if self.config.auto_cleanup:
|
||||
self.clear_completed()
|
||||
|
||||
self._cleanup_task = asyncio.create_task(cleanup_loop())
|
||||
|
||||
async def stop(self):
|
||||
"""Stop the runner and cancel all tasks."""
|
||||
await self.cancel_all()
|
||||
|
||||
if self._cleanup_task:
|
||||
self._cleanup_task.cancel()
|
||||
try:
|
||||
await self._cleanup_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._cleanup_task = None
|
||||
@@ -0,0 +1,154 @@
|
||||
"""
|
||||
Background Task for PraisonAI Agents.
|
||||
|
||||
Represents a task running in the background.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
import asyncio
|
||||
import logging
|
||||
from enum import Enum
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional, Any, Callable, Dict
|
||||
from datetime import datetime
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TaskStatus(str, Enum):
|
||||
"""Status of a background task."""
|
||||
PENDING = "pending"
|
||||
RUNNING = "running"
|
||||
COMPLETED = "completed"
|
||||
FAILED = "failed"
|
||||
CANCELLED = "cancelled"
|
||||
|
||||
|
||||
@dataclass
|
||||
class BackgroundTask:
|
||||
"""
|
||||
Represents a background task.
|
||||
|
||||
Tracks the status, progress, and result of a task running
|
||||
in the background.
|
||||
"""
|
||||
id: str = field(default_factory=lambda: str(uuid.uuid4())[:8])
|
||||
name: str = ""
|
||||
status: TaskStatus = TaskStatus.PENDING
|
||||
progress: float = 0.0 # 0.0 to 1.0
|
||||
result: Any = None
|
||||
error: Optional[str] = None
|
||||
created_at: datetime = field(default_factory=datetime.now)
|
||||
started_at: Optional[datetime] = None
|
||||
completed_at: Optional[datetime] = None
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
# Internal
|
||||
_future: Optional[asyncio.Future] = field(default=None, repr=False)
|
||||
_cancel_event: Optional[asyncio.Event] = field(default=None, repr=False)
|
||||
|
||||
def __post_init__(self):
|
||||
self._cancel_event = asyncio.Event()
|
||||
|
||||
@property
|
||||
def is_running(self) -> bool:
|
||||
"""Check if task is currently running."""
|
||||
return self.status == TaskStatus.RUNNING
|
||||
|
||||
@property
|
||||
def is_completed(self) -> bool:
|
||||
"""Check if task has completed (success or failure)."""
|
||||
return self.status in (TaskStatus.COMPLETED, TaskStatus.FAILED, TaskStatus.CANCELLED)
|
||||
|
||||
@property
|
||||
def is_successful(self) -> bool:
|
||||
"""Check if task completed successfully."""
|
||||
return self.status == TaskStatus.COMPLETED
|
||||
|
||||
@property
|
||||
def duration_seconds(self) -> Optional[float]:
|
||||
"""Get task duration in seconds."""
|
||||
if self.started_at is None:
|
||||
return None
|
||||
end_time = self.completed_at or datetime.now()
|
||||
return (end_time - self.started_at).total_seconds()
|
||||
|
||||
def start(self):
|
||||
"""Mark task as started."""
|
||||
self.status = TaskStatus.RUNNING
|
||||
self.started_at = datetime.now()
|
||||
|
||||
def complete(self, result: Any = None):
|
||||
"""Mark task as completed."""
|
||||
self.status = TaskStatus.COMPLETED
|
||||
self.result = result
|
||||
self.progress = 1.0
|
||||
self.completed_at = datetime.now()
|
||||
|
||||
def fail(self, error: str):
|
||||
"""Mark task as failed."""
|
||||
self.status = TaskStatus.FAILED
|
||||
self.error = error
|
||||
self.completed_at = datetime.now()
|
||||
|
||||
def cancel(self):
|
||||
"""Request task cancellation."""
|
||||
if self._cancel_event:
|
||||
self._cancel_event.set()
|
||||
self.status = TaskStatus.CANCELLED
|
||||
self.completed_at = datetime.now()
|
||||
|
||||
def update_progress(self, progress: float, message: Optional[str] = None):
|
||||
"""Update task progress."""
|
||||
self.progress = min(max(progress, 0.0), 1.0)
|
||||
if message:
|
||||
self.metadata["last_message"] = message
|
||||
|
||||
async def wait(self, timeout: Optional[float] = None) -> Any:
|
||||
"""
|
||||
Wait for task completion.
|
||||
|
||||
Args:
|
||||
timeout: Maximum time to wait in seconds
|
||||
|
||||
Returns:
|
||||
Task result
|
||||
|
||||
Raises:
|
||||
asyncio.TimeoutError: If timeout exceeded
|
||||
RuntimeError: If task failed
|
||||
"""
|
||||
if self._future is None:
|
||||
if self.is_completed:
|
||||
if self.status == TaskStatus.FAILED:
|
||||
raise RuntimeError(self.error)
|
||||
return self.result
|
||||
raise RuntimeError("Task has no associated future")
|
||||
|
||||
try:
|
||||
result = await asyncio.wait_for(self._future, timeout=timeout)
|
||||
return result
|
||||
except asyncio.TimeoutError:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise RuntimeError(str(e))
|
||||
|
||||
def should_cancel(self) -> bool:
|
||||
"""Check if cancellation was requested."""
|
||||
return self._cancel_event is not None and self._cancel_event.is_set()
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary."""
|
||||
return {
|
||||
"id": self.id,
|
||||
"name": self.name,
|
||||
"status": self.status.value,
|
||||
"progress": self.progress,
|
||||
"result": str(self.result) if self.result else None,
|
||||
"error": self.error,
|
||||
"created_at": self.created_at.isoformat(),
|
||||
"started_at": self.started_at.isoformat() if self.started_at else None,
|
||||
"completed_at": self.completed_at.isoformat() if self.completed_at else None,
|
||||
"duration_seconds": self.duration_seconds,
|
||||
"metadata": self.metadata
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
"""
|
||||
Checkpoints Module for PraisonAI Agents.
|
||||
|
||||
Provides file-level checkpointing using a shadow git repository to track
|
||||
and restore file changes made by agents. This enables:
|
||||
|
||||
- Automatic checkpointing before file modifications
|
||||
- Rewind to any previous checkpoint
|
||||
- Diff between checkpoints
|
||||
- Restore files and conversation state together
|
||||
|
||||
Zero Performance Impact:
|
||||
- All imports are lazy loaded via __getattr__
|
||||
- Checkpoints only created when enabled
|
||||
- No overhead when checkpoints are disabled
|
||||
|
||||
Usage:
|
||||
from praisonaiagents.checkpoints import CheckpointService
|
||||
|
||||
# Create checkpoint service
|
||||
service = CheckpointService(
|
||||
workspace_dir="/path/to/project",
|
||||
storage_dir="~/.praison/checkpoints"
|
||||
)
|
||||
|
||||
# Initialize shadow git
|
||||
await service.initialize()
|
||||
|
||||
# Save a checkpoint
|
||||
checkpoint_id = await service.save("Before refactoring")
|
||||
|
||||
# Restore to a checkpoint
|
||||
await service.restore(checkpoint_id)
|
||||
|
||||
# Get diff between checkpoints
|
||||
diff = await service.diff(from_id, to_id)
|
||||
"""
|
||||
|
||||
__all__ = [
|
||||
# Core service
|
||||
"CheckpointService",
|
||||
# Data types
|
||||
"Checkpoint",
|
||||
"CheckpointDiff",
|
||||
"CheckpointConfig",
|
||||
# Events
|
||||
"CheckpointEvent",
|
||||
]
|
||||
|
||||
|
||||
def __getattr__(name: str):
|
||||
"""Lazy load module components to avoid import overhead."""
|
||||
if name == "CheckpointService":
|
||||
from .service import CheckpointService
|
||||
return CheckpointService
|
||||
|
||||
if name in ("Checkpoint", "CheckpointDiff", "CheckpointConfig"):
|
||||
from .types import Checkpoint, CheckpointDiff, CheckpointConfig
|
||||
return locals()[name]
|
||||
|
||||
if name == "CheckpointEvent":
|
||||
from .types import CheckpointEvent
|
||||
return CheckpointEvent
|
||||
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
@@ -0,0 +1,497 @@
|
||||
"""
|
||||
Checkpoint Service for PraisonAI Agents.
|
||||
|
||||
Implements shadow git repository for file-level checkpointing.
|
||||
"""
|
||||
|
||||
import os
|
||||
import asyncio
|
||||
import logging
|
||||
import shutil
|
||||
from typing import Optional, List, Dict, Any, Callable
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from .types import (
|
||||
CheckpointConfig, Checkpoint, CheckpointDiff, FileDiff,
|
||||
CheckpointResult, CheckpointEvent
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Protected paths that should never be checkpointed
|
||||
PROTECTED_PATHS = [
|
||||
os.path.expanduser("~"),
|
||||
os.path.expanduser("~/Desktop"),
|
||||
os.path.expanduser("~/Documents"),
|
||||
os.path.expanduser("~/Downloads"),
|
||||
"/",
|
||||
"/tmp",
|
||||
"/var",
|
||||
"/etc",
|
||||
]
|
||||
|
||||
|
||||
class CheckpointService:
|
||||
"""
|
||||
Shadow Git Checkpoint Service.
|
||||
|
||||
Uses a separate git repository to track file changes in the workspace,
|
||||
enabling checkpoint creation, restoration, and diffing without
|
||||
interfering with the workspace's own git repository.
|
||||
|
||||
Example:
|
||||
service = CheckpointService(
|
||||
workspace_dir="/path/to/project",
|
||||
storage_dir="~/.praison/checkpoints"
|
||||
)
|
||||
|
||||
await service.initialize()
|
||||
|
||||
# Save checkpoint before making changes
|
||||
result = await service.save("Before refactoring")
|
||||
|
||||
# Make changes...
|
||||
|
||||
# Restore if needed
|
||||
await service.restore(result.checkpoint.id)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
workspace_dir: str,
|
||||
storage_dir: Optional[str] = None,
|
||||
enabled: bool = True,
|
||||
auto_checkpoint: bool = True,
|
||||
max_checkpoints: int = 100
|
||||
):
|
||||
"""
|
||||
Initialize the checkpoint service.
|
||||
|
||||
Args:
|
||||
workspace_dir: Directory to track
|
||||
storage_dir: Where to store checkpoint data
|
||||
enabled: Whether checkpoints are enabled
|
||||
auto_checkpoint: Auto-checkpoint before file modifications
|
||||
max_checkpoints: Maximum checkpoints to keep
|
||||
"""
|
||||
self.config = CheckpointConfig(
|
||||
workspace_dir=workspace_dir,
|
||||
storage_dir=storage_dir,
|
||||
enabled=enabled,
|
||||
auto_checkpoint=auto_checkpoint,
|
||||
max_checkpoints=max_checkpoints
|
||||
)
|
||||
|
||||
self._initialized = False
|
||||
self._checkpoints: List[Checkpoint] = []
|
||||
self._event_handlers: Dict[CheckpointEvent, List[Callable]] = {
|
||||
event: [] for event in CheckpointEvent
|
||||
}
|
||||
|
||||
@property
|
||||
def workspace_dir(self) -> str:
|
||||
"""Get the workspace directory."""
|
||||
return self.config.workspace_dir
|
||||
|
||||
@property
|
||||
def checkpoint_dir(self) -> str:
|
||||
"""Get the checkpoint storage directory."""
|
||||
return self.config.get_checkpoint_dir()
|
||||
|
||||
@property
|
||||
def git_dir(self) -> str:
|
||||
"""Get the shadow git directory."""
|
||||
return os.path.join(self.checkpoint_dir, ".git")
|
||||
|
||||
@property
|
||||
def is_initialized(self) -> bool:
|
||||
"""Check if the service is initialized."""
|
||||
return self._initialized
|
||||
|
||||
def on(self, event: CheckpointEvent, handler: Callable):
|
||||
"""Register an event handler."""
|
||||
self._event_handlers[event].append(handler)
|
||||
|
||||
def _emit(self, event: CheckpointEvent, data: Any = None):
|
||||
"""Emit an event to all handlers."""
|
||||
for handler in self._event_handlers[event]:
|
||||
try:
|
||||
handler(data)
|
||||
except Exception as e:
|
||||
logger.warning(f"Event handler error: {e}")
|
||||
|
||||
async def initialize(self) -> bool:
|
||||
"""
|
||||
Initialize the shadow git repository.
|
||||
|
||||
Returns:
|
||||
True if initialization succeeded
|
||||
"""
|
||||
if not self.config.enabled:
|
||||
return False
|
||||
|
||||
# Check for protected paths
|
||||
workspace_real = os.path.realpath(self.config.workspace_dir)
|
||||
for protected in PROTECTED_PATHS:
|
||||
if workspace_real == os.path.realpath(protected):
|
||||
logger.warning(f"Cannot checkpoint protected path: {workspace_real}")
|
||||
return False
|
||||
|
||||
# Check if workspace exists
|
||||
if not os.path.isdir(self.config.workspace_dir):
|
||||
logger.error(f"Workspace does not exist: {self.config.workspace_dir}")
|
||||
return False
|
||||
|
||||
# Create checkpoint directory
|
||||
os.makedirs(self.checkpoint_dir, exist_ok=True)
|
||||
|
||||
# Initialize shadow git if needed
|
||||
if not os.path.exists(self.git_dir):
|
||||
success = await self._init_shadow_git()
|
||||
if not success:
|
||||
return False
|
||||
|
||||
# Load existing checkpoints
|
||||
await self._load_checkpoints()
|
||||
|
||||
self._initialized = True
|
||||
self._emit(CheckpointEvent.INITIALIZED, {"workspace": self.config.workspace_dir})
|
||||
|
||||
return True
|
||||
|
||||
async def _init_shadow_git(self) -> bool:
|
||||
"""Initialize the shadow git repository."""
|
||||
try:
|
||||
# Initialize git repo
|
||||
await self._run_git("init")
|
||||
|
||||
# Configure git
|
||||
await self._run_git("config", "user.name", "PraisonAI Checkpoints")
|
||||
await self._run_git("config", "user.email", "[email protected]")
|
||||
|
||||
# Set worktree to workspace
|
||||
await self._run_git("config", "core.worktree", self.config.workspace_dir)
|
||||
|
||||
# Create exclude file
|
||||
exclude_dir = os.path.join(self.git_dir, "info")
|
||||
os.makedirs(exclude_dir, exist_ok=True)
|
||||
|
||||
exclude_file = os.path.join(exclude_dir, "exclude")
|
||||
with open(exclude_file, "w") as f:
|
||||
for pattern in self.config.exclude_patterns:
|
||||
f.write(f"{pattern}\n")
|
||||
|
||||
# Also exclude the checkpoint directory itself
|
||||
f.write(f"{self.checkpoint_dir}\n")
|
||||
|
||||
# Create initial empty commit
|
||||
await self._run_git("commit", "--allow-empty", "-m", "Initial checkpoint")
|
||||
|
||||
logger.info(f"Initialized shadow git at {self.checkpoint_dir}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize shadow git: {e}")
|
||||
return False
|
||||
|
||||
async def _run_git(self, *args: str) -> str:
|
||||
"""Run a git command in the shadow repository."""
|
||||
env = self._get_sanitized_env()
|
||||
|
||||
cmd = ["git", f"--git-dir={self.git_dir}", f"--work-tree={self.config.workspace_dir}"]
|
||||
cmd.extend(args)
|
||||
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
env=env,
|
||||
cwd=self.config.workspace_dir
|
||||
)
|
||||
|
||||
stdout, stderr = await process.communicate()
|
||||
|
||||
if process.returncode != 0:
|
||||
error_msg = stderr.decode().strip()
|
||||
raise RuntimeError(f"Git command failed: {' '.join(args)}\n{error_msg}")
|
||||
|
||||
return stdout.decode().strip()
|
||||
|
||||
def _get_sanitized_env(self) -> Dict[str, str]:
|
||||
"""Get environment with git variables sanitized."""
|
||||
env = os.environ.copy()
|
||||
|
||||
# Remove git environment variables that could interfere
|
||||
git_vars = [
|
||||
"GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE",
|
||||
"GIT_OBJECT_DIRECTORY", "GIT_ALTERNATE_OBJECT_DIRECTORIES"
|
||||
]
|
||||
for var in git_vars:
|
||||
env.pop(var, None)
|
||||
|
||||
return env
|
||||
|
||||
async def save(self, message: str, allow_empty: bool = False) -> CheckpointResult:
|
||||
"""
|
||||
Save a checkpoint.
|
||||
|
||||
Args:
|
||||
message: Checkpoint message
|
||||
allow_empty: Allow checkpoint even if no changes
|
||||
|
||||
Returns:
|
||||
CheckpointResult with the created checkpoint
|
||||
"""
|
||||
if not self._initialized:
|
||||
return CheckpointResult.fail("Service not initialized")
|
||||
|
||||
try:
|
||||
# Stage all changes
|
||||
await self._run_git("add", "-A")
|
||||
|
||||
# Check if there are changes
|
||||
try:
|
||||
await self._run_git("diff", "--cached", "--quiet")
|
||||
has_changes = False
|
||||
except RuntimeError:
|
||||
has_changes = True
|
||||
|
||||
if not has_changes and not allow_empty:
|
||||
return CheckpointResult.fail("No changes to checkpoint")
|
||||
|
||||
# Create commit
|
||||
commit_args = ["commit", "-m", message]
|
||||
if allow_empty:
|
||||
commit_args.append("--allow-empty")
|
||||
|
||||
await self._run_git(*commit_args)
|
||||
|
||||
# Get commit info
|
||||
commit_hash = await self._run_git("rev-parse", "HEAD")
|
||||
timestamp = await self._run_git("log", "-1", "--format=%aI")
|
||||
|
||||
checkpoint = Checkpoint(
|
||||
id=commit_hash,
|
||||
short_id=commit_hash[:8],
|
||||
message=message,
|
||||
timestamp=datetime.fromisoformat(timestamp)
|
||||
)
|
||||
|
||||
self._checkpoints.append(checkpoint)
|
||||
|
||||
# Prune old checkpoints if needed
|
||||
await self._prune_checkpoints()
|
||||
|
||||
self._emit(CheckpointEvent.CHECKPOINT_CREATED, checkpoint)
|
||||
logger.info(f"Created checkpoint: {checkpoint.short_id} - {message}")
|
||||
|
||||
return CheckpointResult.ok(checkpoint)
|
||||
|
||||
except Exception as e:
|
||||
error_msg = str(e)
|
||||
self._emit(CheckpointEvent.ERROR, {"error": error_msg})
|
||||
return CheckpointResult.fail(error_msg)
|
||||
|
||||
async def restore(self, checkpoint_id: str) -> CheckpointResult:
|
||||
"""
|
||||
Restore workspace to a checkpoint.
|
||||
|
||||
Args:
|
||||
checkpoint_id: Checkpoint ID (commit hash) to restore
|
||||
|
||||
Returns:
|
||||
CheckpointResult indicating success/failure
|
||||
"""
|
||||
if not self._initialized:
|
||||
return CheckpointResult.fail("Service not initialized")
|
||||
|
||||
try:
|
||||
# Clean untracked files
|
||||
await self._run_git("clean", "-f", "-d")
|
||||
|
||||
# Reset to checkpoint
|
||||
await self._run_git("reset", "--hard", checkpoint_id)
|
||||
|
||||
# Get checkpoint info
|
||||
message = await self._run_git("log", "-1", "--format=%s", checkpoint_id)
|
||||
timestamp = await self._run_git("log", "-1", "--format=%aI", checkpoint_id)
|
||||
|
||||
checkpoint = Checkpoint(
|
||||
id=checkpoint_id,
|
||||
short_id=checkpoint_id[:8],
|
||||
message=message,
|
||||
timestamp=datetime.fromisoformat(timestamp)
|
||||
)
|
||||
|
||||
self._emit(CheckpointEvent.CHECKPOINT_RESTORED, checkpoint)
|
||||
logger.info(f"Restored to checkpoint: {checkpoint.short_id}")
|
||||
|
||||
return CheckpointResult.ok(checkpoint)
|
||||
|
||||
except Exception as e:
|
||||
error_msg = str(e)
|
||||
self._emit(CheckpointEvent.ERROR, {"error": error_msg})
|
||||
return CheckpointResult.fail(error_msg)
|
||||
|
||||
async def diff(
|
||||
self,
|
||||
from_id: Optional[str] = None,
|
||||
to_id: Optional[str] = None
|
||||
) -> CheckpointDiff:
|
||||
"""
|
||||
Get diff between checkpoints.
|
||||
|
||||
Args:
|
||||
from_id: Starting checkpoint (default: previous checkpoint)
|
||||
to_id: Ending checkpoint (default: current working directory)
|
||||
|
||||
Returns:
|
||||
CheckpointDiff with file changes
|
||||
"""
|
||||
if not self._initialized:
|
||||
return CheckpointDiff(from_checkpoint="", to_checkpoint="", files=[])
|
||||
|
||||
try:
|
||||
# Default from_id to HEAD~1
|
||||
if from_id is None:
|
||||
try:
|
||||
from_id = await self._run_git("rev-parse", "HEAD~1")
|
||||
except RuntimeError:
|
||||
from_id = await self._run_git("rev-parse", "HEAD")
|
||||
|
||||
# Build diff command
|
||||
if to_id:
|
||||
diff_output = await self._run_git(
|
||||
"diff", "--stat", "--numstat", from_id, to_id
|
||||
)
|
||||
else:
|
||||
# Diff against working directory
|
||||
await self._run_git("add", "-A") # Stage to include untracked
|
||||
diff_output = await self._run_git(
|
||||
"diff", "--stat", "--numstat", "--cached", from_id
|
||||
)
|
||||
|
||||
# Parse diff output
|
||||
files = self._parse_diff_output(diff_output)
|
||||
|
||||
return CheckpointDiff(
|
||||
from_checkpoint=from_id[:8] if from_id else "",
|
||||
to_checkpoint=to_id[:8] if to_id else None,
|
||||
files=files
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get diff: {e}")
|
||||
return CheckpointDiff(from_checkpoint="", to_checkpoint="", files=[])
|
||||
|
||||
def _parse_diff_output(self, output: str) -> List[FileDiff]:
|
||||
"""Parse git diff --numstat output."""
|
||||
files = []
|
||||
|
||||
for line in output.strip().split("\n"):
|
||||
if not line or "\t" not in line:
|
||||
continue
|
||||
|
||||
parts = line.split("\t")
|
||||
if len(parts) >= 3:
|
||||
try:
|
||||
additions = int(parts[0]) if parts[0] != "-" else 0
|
||||
deletions = int(parts[1]) if parts[1] != "-" else 0
|
||||
path = parts[2]
|
||||
|
||||
# Determine status
|
||||
if additions > 0 and deletions == 0:
|
||||
status = "added"
|
||||
elif additions == 0 and deletions > 0:
|
||||
status = "deleted"
|
||||
else:
|
||||
status = "modified"
|
||||
|
||||
files.append(FileDiff(
|
||||
path=path,
|
||||
absolute_path=os.path.join(self.config.workspace_dir, path),
|
||||
status=status,
|
||||
additions=additions,
|
||||
deletions=deletions
|
||||
))
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
return files
|
||||
|
||||
async def list_checkpoints(self, limit: int = 50) -> List[Checkpoint]:
|
||||
"""
|
||||
List all checkpoints.
|
||||
|
||||
Args:
|
||||
limit: Maximum number of checkpoints to return
|
||||
|
||||
Returns:
|
||||
List of checkpoints, newest first
|
||||
"""
|
||||
if not self._initialized:
|
||||
return []
|
||||
|
||||
try:
|
||||
# Get commit log
|
||||
log_output = await self._run_git(
|
||||
"log", f"-{limit}", "--format=%H|%s|%aI"
|
||||
)
|
||||
|
||||
checkpoints = []
|
||||
for line in log_output.strip().split("\n"):
|
||||
if not line or "|" not in line:
|
||||
continue
|
||||
|
||||
parts = line.split("|", 2)
|
||||
if len(parts) >= 3:
|
||||
checkpoints.append(Checkpoint(
|
||||
id=parts[0],
|
||||
short_id=parts[0][:8],
|
||||
message=parts[1],
|
||||
timestamp=datetime.fromisoformat(parts[2])
|
||||
))
|
||||
|
||||
return checkpoints
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to list checkpoints: {e}")
|
||||
return []
|
||||
|
||||
async def _load_checkpoints(self):
|
||||
"""Load existing checkpoints from git log."""
|
||||
self._checkpoints = await self.list_checkpoints()
|
||||
|
||||
async def _prune_checkpoints(self):
|
||||
"""Prune old checkpoints if over limit."""
|
||||
if len(self._checkpoints) <= self.config.max_checkpoints:
|
||||
return
|
||||
|
||||
# Keep only the most recent checkpoints
|
||||
# Note: This doesn't actually delete git history, just our tracking
|
||||
self._checkpoints = self._checkpoints[:self.config.max_checkpoints]
|
||||
|
||||
async def get_checkpoint(self, checkpoint_id: str) -> Optional[Checkpoint]:
|
||||
"""Get a specific checkpoint by ID."""
|
||||
for cp in self._checkpoints:
|
||||
if cp.id.startswith(checkpoint_id) or cp.short_id == checkpoint_id:
|
||||
return cp
|
||||
return None
|
||||
|
||||
async def cleanup(self):
|
||||
"""Clean up the checkpoint service."""
|
||||
# Nothing to clean up currently
|
||||
self._initialized = False
|
||||
|
||||
async def delete_all(self) -> bool:
|
||||
"""Delete all checkpoint data for this workspace."""
|
||||
try:
|
||||
if os.path.exists(self.checkpoint_dir):
|
||||
shutil.rmtree(self.checkpoint_dir)
|
||||
self._checkpoints = []
|
||||
self._initialized = False
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete checkpoints: {e}")
|
||||
return False
|
||||
@@ -0,0 +1,158 @@
|
||||
"""
|
||||
Checkpoint Types for PraisonAI Agents.
|
||||
|
||||
Defines the core types, enums, and dataclasses for the checkpoint system.
|
||||
"""
|
||||
|
||||
import os
|
||||
from enum import Enum
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional, Dict, Any, List
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class CheckpointEvent(str, Enum):
|
||||
"""Events emitted by the checkpoint service."""
|
||||
INITIALIZED = "initialized"
|
||||
CHECKPOINT_CREATED = "checkpoint_created"
|
||||
CHECKPOINT_RESTORED = "checkpoint_restored"
|
||||
ERROR = "error"
|
||||
|
||||
|
||||
@dataclass
|
||||
class CheckpointConfig:
|
||||
"""Configuration for the checkpoint service."""
|
||||
workspace_dir: str
|
||||
storage_dir: Optional[str] = None
|
||||
enabled: bool = True
|
||||
auto_checkpoint: bool = True # Auto-checkpoint before file modifications
|
||||
max_checkpoints: int = 100 # Maximum checkpoints to keep
|
||||
exclude_patterns: List[str] = field(default_factory=lambda: [
|
||||
".git",
|
||||
".praison",
|
||||
"__pycache__",
|
||||
"*.pyc",
|
||||
".env",
|
||||
"node_modules",
|
||||
".venv",
|
||||
"venv",
|
||||
"*.log"
|
||||
])
|
||||
|
||||
def __post_init__(self):
|
||||
# Expand paths
|
||||
self.workspace_dir = os.path.expanduser(self.workspace_dir)
|
||||
if self.storage_dir:
|
||||
self.storage_dir = os.path.expanduser(self.storage_dir)
|
||||
else:
|
||||
# Default storage in user's home directory
|
||||
self.storage_dir = os.path.expanduser("~/.praison/checkpoints")
|
||||
|
||||
def get_checkpoint_dir(self) -> str:
|
||||
"""Get the checkpoint directory for this workspace."""
|
||||
# Hash the workspace path to create a unique directory
|
||||
import hashlib
|
||||
workspace_hash = hashlib.sha256(
|
||||
self.workspace_dir.encode()
|
||||
).hexdigest()[:12]
|
||||
return os.path.join(self.storage_dir, workspace_hash)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Checkpoint:
|
||||
"""Represents a single checkpoint."""
|
||||
id: str # Git commit hash
|
||||
short_id: str # Short hash (8 chars)
|
||||
message: str
|
||||
timestamp: datetime
|
||||
files_changed: int = 0
|
||||
insertions: int = 0
|
||||
deletions: int = 0
|
||||
|
||||
@classmethod
|
||||
def from_git_commit(cls, commit_hash: str, message: str, timestamp: str) -> "Checkpoint":
|
||||
"""Create a Checkpoint from git commit info."""
|
||||
return cls(
|
||||
id=commit_hash,
|
||||
short_id=commit_hash[:8],
|
||||
message=message,
|
||||
timestamp=datetime.fromisoformat(timestamp) if isinstance(timestamp, str) else timestamp
|
||||
)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary."""
|
||||
return {
|
||||
"id": self.id,
|
||||
"short_id": self.short_id,
|
||||
"message": self.message,
|
||||
"timestamp": self.timestamp.isoformat(),
|
||||
"files_changed": self.files_changed,
|
||||
"insertions": self.insertions,
|
||||
"deletions": self.deletions
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class FileDiff:
|
||||
"""Represents a diff for a single file."""
|
||||
path: str
|
||||
absolute_path: str
|
||||
status: str # added, modified, deleted
|
||||
additions: int = 0
|
||||
deletions: int = 0
|
||||
before_content: Optional[str] = None
|
||||
after_content: Optional[str] = None
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary."""
|
||||
return {
|
||||
"path": self.path,
|
||||
"absolute_path": self.absolute_path,
|
||||
"status": self.status,
|
||||
"additions": self.additions,
|
||||
"deletions": self.deletions
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class CheckpointDiff:
|
||||
"""Represents a diff between two checkpoints."""
|
||||
from_checkpoint: str
|
||||
to_checkpoint: Optional[str] # None means current working directory
|
||||
files: List[FileDiff] = field(default_factory=list)
|
||||
total_additions: int = 0
|
||||
total_deletions: int = 0
|
||||
|
||||
def __post_init__(self):
|
||||
# Calculate totals
|
||||
self.total_additions = sum(f.additions for f in self.files)
|
||||
self.total_deletions = sum(f.deletions for f in self.files)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary."""
|
||||
return {
|
||||
"from_checkpoint": self.from_checkpoint,
|
||||
"to_checkpoint": self.to_checkpoint,
|
||||
"files": [f.to_dict() for f in self.files],
|
||||
"total_additions": self.total_additions,
|
||||
"total_deletions": self.total_deletions
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class CheckpointResult:
|
||||
"""Result of a checkpoint operation."""
|
||||
success: bool
|
||||
checkpoint: Optional[Checkpoint] = None
|
||||
error: Optional[str] = None
|
||||
|
||||
@classmethod
|
||||
def ok(cls, checkpoint: Checkpoint) -> "CheckpointResult":
|
||||
"""Create a successful result."""
|
||||
return cls(success=True, checkpoint=checkpoint)
|
||||
|
||||
@classmethod
|
||||
def fail(cls, error: str) -> "CheckpointResult":
|
||||
"""Create a failed result."""
|
||||
return cls(success=False, error=error)
|
||||
@@ -0,0 +1,57 @@
|
||||
"""
|
||||
Auto Context Compaction Module for PraisonAI Agents.
|
||||
|
||||
Provides automatic context management for long conversations:
|
||||
- Message summarization
|
||||
- Context window management
|
||||
- Token counting and limits
|
||||
- Intelligent message pruning
|
||||
|
||||
Zero Performance Impact:
|
||||
- All imports are lazy loaded via __getattr__
|
||||
- Compaction only runs when needed
|
||||
- No overhead when context is within limits
|
||||
|
||||
Usage:
|
||||
from praisonaiagents.compaction import ContextCompactor, CompactionConfig
|
||||
|
||||
# Create a compactor
|
||||
compactor = ContextCompactor(
|
||||
max_tokens=8000,
|
||||
strategy="summarize"
|
||||
)
|
||||
|
||||
# Compact messages when needed
|
||||
compacted = compactor.compact(messages)
|
||||
"""
|
||||
|
||||
__all__ = [
|
||||
# Core classes
|
||||
"ContextCompactor",
|
||||
"CompactionConfig",
|
||||
# Strategies
|
||||
"CompactionStrategy",
|
||||
# Results
|
||||
"CompactionResult",
|
||||
]
|
||||
|
||||
|
||||
def __getattr__(name: str):
|
||||
"""Lazy load module components to avoid import overhead."""
|
||||
if name == "ContextCompactor":
|
||||
from .compactor import ContextCompactor
|
||||
return ContextCompactor
|
||||
|
||||
if name == "CompactionConfig":
|
||||
from .config import CompactionConfig
|
||||
return CompactionConfig
|
||||
|
||||
if name == "CompactionStrategy":
|
||||
from .strategy import CompactionStrategy
|
||||
return CompactionStrategy
|
||||
|
||||
if name == "CompactionResult":
|
||||
from .result import CompactionResult
|
||||
return CompactionResult
|
||||
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
@@ -0,0 +1,237 @@
|
||||
"""
|
||||
Context Compactor for PraisonAI Agents.
|
||||
|
||||
Manages context window by compacting messages when needed.
|
||||
"""
|
||||
|
||||
from typing import List, Dict, Any, Optional
|
||||
|
||||
from .config import CompactionConfig
|
||||
from .strategy import CompactionStrategy
|
||||
from .result import CompactionResult
|
||||
|
||||
|
||||
class ContextCompactor:
|
||||
"""
|
||||
Compacts conversation context to fit within token limits.
|
||||
|
||||
Example:
|
||||
compactor = ContextCompactor(max_tokens=8000)
|
||||
|
||||
# Check if compaction needed
|
||||
if compactor.needs_compaction(messages):
|
||||
result = compactor.compact(messages)
|
||||
messages = result.messages
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
max_tokens: int = 8000,
|
||||
target_tokens: Optional[int] = None,
|
||||
strategy: CompactionStrategy = CompactionStrategy.TRUNCATE,
|
||||
preserve_system: bool = True,
|
||||
preserve_recent: int = 5
|
||||
):
|
||||
"""
|
||||
Initialize the compactor.
|
||||
|
||||
Args:
|
||||
max_tokens: Maximum tokens before compaction
|
||||
target_tokens: Target tokens after compaction
|
||||
strategy: Compaction strategy to use
|
||||
preserve_system: Keep system messages
|
||||
preserve_recent: Number of recent messages to preserve
|
||||
"""
|
||||
self.max_tokens = max_tokens
|
||||
self.target_tokens = target_tokens or int(max_tokens * 0.75)
|
||||
self.strategy = strategy
|
||||
self.preserve_system = preserve_system
|
||||
self.preserve_recent = preserve_recent
|
||||
|
||||
def estimate_tokens(self, text: str) -> int:
|
||||
"""Estimate token count for text."""
|
||||
# Rough estimate: ~4 chars per token
|
||||
return len(text) // 4
|
||||
|
||||
def count_message_tokens(self, message: Dict[str, Any]) -> int:
|
||||
"""Count tokens in a message."""
|
||||
content = message.get("content", "")
|
||||
if isinstance(content, str):
|
||||
return self.estimate_tokens(content)
|
||||
elif isinstance(content, list):
|
||||
# Handle multi-part content
|
||||
total = 0
|
||||
for part in content:
|
||||
if isinstance(part, dict):
|
||||
total += self.estimate_tokens(str(part.get("text", "")))
|
||||
else:
|
||||
total += self.estimate_tokens(str(part))
|
||||
return total
|
||||
return 0
|
||||
|
||||
def count_total_tokens(self, messages: List[Dict[str, Any]]) -> int:
|
||||
"""Count total tokens in messages."""
|
||||
return sum(self.count_message_tokens(m) for m in messages)
|
||||
|
||||
def needs_compaction(self, messages: List[Dict[str, Any]]) -> bool:
|
||||
"""Check if messages need compaction."""
|
||||
return self.count_total_tokens(messages) > self.max_tokens
|
||||
|
||||
def compact(
|
||||
self,
|
||||
messages: List[Dict[str, Any]]
|
||||
) -> tuple[List[Dict[str, Any]], CompactionResult]:
|
||||
"""
|
||||
Compact messages to fit within token limit.
|
||||
|
||||
Args:
|
||||
messages: List of messages to compact
|
||||
|
||||
Returns:
|
||||
Tuple of (compacted messages, result)
|
||||
"""
|
||||
original_tokens = self.count_total_tokens(messages)
|
||||
|
||||
if original_tokens <= self.max_tokens:
|
||||
return messages, CompactionResult(
|
||||
original_tokens=original_tokens,
|
||||
compacted_tokens=original_tokens,
|
||||
messages_removed=0,
|
||||
messages_kept=len(messages),
|
||||
strategy_used=self.strategy
|
||||
)
|
||||
|
||||
if self.strategy == CompactionStrategy.TRUNCATE:
|
||||
compacted = self._truncate(messages)
|
||||
elif self.strategy == CompactionStrategy.SLIDING:
|
||||
compacted = self._sliding_window(messages)
|
||||
elif self.strategy == CompactionStrategy.SUMMARIZE:
|
||||
compacted = self._summarize(messages)
|
||||
elif self.strategy == CompactionStrategy.SMART:
|
||||
compacted = self._smart_compact(messages)
|
||||
else:
|
||||
compacted = self._truncate(messages)
|
||||
|
||||
compacted_tokens = self.count_total_tokens(compacted)
|
||||
|
||||
result = CompactionResult(
|
||||
original_tokens=original_tokens,
|
||||
compacted_tokens=compacted_tokens,
|
||||
messages_removed=len(messages) - len(compacted),
|
||||
messages_kept=len(compacted),
|
||||
strategy_used=self.strategy
|
||||
)
|
||||
|
||||
return compacted, result
|
||||
|
||||
def _truncate(self, messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
"""Truncate oldest messages."""
|
||||
result = []
|
||||
|
||||
# Separate system and non-system messages
|
||||
system_msgs = []
|
||||
other_msgs = []
|
||||
|
||||
for msg in messages:
|
||||
if self.preserve_system and msg.get("role") == "system":
|
||||
system_msgs.append(msg)
|
||||
else:
|
||||
other_msgs.append(msg)
|
||||
|
||||
# Always keep system messages
|
||||
result.extend(system_msgs)
|
||||
|
||||
# Keep recent messages
|
||||
recent = other_msgs[-self.preserve_recent:] if other_msgs else []
|
||||
|
||||
# Add recent messages
|
||||
result.extend(recent)
|
||||
|
||||
# If still over limit, truncate more
|
||||
while self.count_total_tokens(result) > self.target_tokens and len(result) > 1:
|
||||
# Remove oldest non-system message
|
||||
for i, msg in enumerate(result):
|
||||
if msg.get("role") != "system":
|
||||
result.pop(i)
|
||||
break
|
||||
|
||||
return result
|
||||
|
||||
def _sliding_window(self, messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
"""Keep a sliding window of recent messages."""
|
||||
result = []
|
||||
|
||||
# Keep system messages
|
||||
for msg in messages:
|
||||
if self.preserve_system and msg.get("role") == "system":
|
||||
result.append(msg)
|
||||
|
||||
# Add messages from end until we hit target
|
||||
non_system = [m for m in messages if m.get("role") != "system"]
|
||||
|
||||
for msg in reversed(non_system):
|
||||
if self.count_total_tokens(result + [msg]) <= self.target_tokens:
|
||||
result.insert(len([m for m in result if m.get("role") == "system"]), msg)
|
||||
else:
|
||||
break
|
||||
|
||||
# Ensure messages are in order
|
||||
system_msgs = [m for m in result if m.get("role") == "system"]
|
||||
other_msgs = [m for m in result if m.get("role") != "system"]
|
||||
|
||||
return system_msgs + other_msgs
|
||||
|
||||
def _summarize(self, messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
"""Summarize old messages (simplified version)."""
|
||||
result = []
|
||||
|
||||
# Keep system messages
|
||||
system_msgs = [m for m in messages if m.get("role") == "system"]
|
||||
other_msgs = [m for m in messages if m.get("role") != "system"]
|
||||
|
||||
result.extend(system_msgs)
|
||||
|
||||
# Keep recent messages
|
||||
recent = other_msgs[-self.preserve_recent:]
|
||||
|
||||
# Summarize older messages
|
||||
older = other_msgs[:-self.preserve_recent] if len(other_msgs) > self.preserve_recent else []
|
||||
|
||||
if older:
|
||||
# Create a simple summary
|
||||
summary_parts = []
|
||||
for msg in older:
|
||||
role = msg.get("role", "unknown")
|
||||
content = msg.get("content", "")
|
||||
if isinstance(content, str) and content:
|
||||
# Take first 100 chars
|
||||
summary_parts.append(f"{role}: {content[:100]}...")
|
||||
|
||||
if summary_parts:
|
||||
summary = "[Previous conversation summary]\n" + "\n".join(summary_parts[:5])
|
||||
result.append({
|
||||
"role": "system",
|
||||
"content": summary
|
||||
})
|
||||
|
||||
result.extend(recent)
|
||||
|
||||
return result
|
||||
|
||||
def _smart_compact(self, messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
"""Smart compaction based on message importance."""
|
||||
# For now, use sliding window as base
|
||||
return self._sliding_window(messages)
|
||||
|
||||
def get_stats(self, messages: List[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
"""Get statistics about messages."""
|
||||
total_tokens = self.count_total_tokens(messages)
|
||||
|
||||
return {
|
||||
"message_count": len(messages),
|
||||
"total_tokens": total_tokens,
|
||||
"max_tokens": self.max_tokens,
|
||||
"target_tokens": self.target_tokens,
|
||||
"needs_compaction": total_tokens > self.max_tokens,
|
||||
"utilization": total_tokens / self.max_tokens if self.max_tokens > 0 else 0
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
"""
|
||||
Compaction Configuration for PraisonAI Agents.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class CompactionConfig:
|
||||
"""Configuration for context compaction."""
|
||||
enabled: bool = True
|
||||
max_tokens: int = 8000
|
||||
target_tokens: int = 6000 # Target after compaction
|
||||
preserve_system: bool = True # Always keep system messages
|
||||
preserve_recent: int = 5 # Keep last N messages
|
||||
auto_compact: bool = True # Automatically compact when needed
|
||||
@@ -0,0 +1,49 @@
|
||||
"""
|
||||
Compaction Result for PraisonAI Agents.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List, Dict, Any
|
||||
|
||||
from .strategy import CompactionStrategy
|
||||
|
||||
|
||||
@dataclass
|
||||
class CompactionResult:
|
||||
"""Result of a compaction operation."""
|
||||
original_tokens: int
|
||||
compacted_tokens: int
|
||||
messages_removed: int
|
||||
messages_kept: int
|
||||
strategy_used: CompactionStrategy
|
||||
summary: str = ""
|
||||
|
||||
@property
|
||||
def tokens_saved(self) -> int:
|
||||
"""Get number of tokens saved."""
|
||||
return self.original_tokens - self.compacted_tokens
|
||||
|
||||
@property
|
||||
def compression_ratio(self) -> float:
|
||||
"""Get compression ratio."""
|
||||
if self.original_tokens == 0:
|
||||
return 0.0
|
||||
return self.compacted_tokens / self.original_tokens
|
||||
|
||||
@property
|
||||
def was_compacted(self) -> bool:
|
||||
"""Check if compaction occurred."""
|
||||
return self.messages_removed > 0 or self.tokens_saved > 0
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary."""
|
||||
return {
|
||||
"original_tokens": self.original_tokens,
|
||||
"compacted_tokens": self.compacted_tokens,
|
||||
"tokens_saved": self.tokens_saved,
|
||||
"messages_removed": self.messages_removed,
|
||||
"messages_kept": self.messages_kept,
|
||||
"strategy_used": self.strategy_used.value,
|
||||
"compression_ratio": self.compression_ratio,
|
||||
"summary": self.summary
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
"""
|
||||
Compaction Strategies for PraisonAI Agents.
|
||||
"""
|
||||
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class CompactionStrategy(str, Enum):
|
||||
"""Strategy for compacting context."""
|
||||
TRUNCATE = "truncate" # Remove oldest messages
|
||||
SUMMARIZE = "summarize" # Summarize old messages
|
||||
SLIDING = "sliding" # Sliding window of recent messages
|
||||
SMART = "smart" # Intelligent selection based on relevance
|
||||
@@ -0,0 +1,137 @@
|
||||
"""
|
||||
Hooks Module for PraisonAI Agents.
|
||||
|
||||
Provides a powerful hook system for intercepting and modifying agent behavior
|
||||
at various lifecycle points. Unlike callbacks (which are for UI events),
|
||||
hooks can intercept, modify, or block tool execution.
|
||||
|
||||
Features:
|
||||
- Event-based hook system (BeforeTool, AfterTool, BeforeAgent, etc.)
|
||||
- Shell command hooks for external integrations
|
||||
- Python function hooks for in-process customization
|
||||
- Matcher patterns for selective hook execution
|
||||
- Sequential and parallel hook execution
|
||||
- Decision outcomes (allow, deny, block, ask)
|
||||
|
||||
Zero Performance Impact:
|
||||
- All imports are lazy loaded via __getattr__
|
||||
- Hooks only execute when registered
|
||||
- No overhead when hooks are disabled
|
||||
|
||||
Usage:
|
||||
from praisonaiagents.hooks import HookRegistry, HookEvent
|
||||
|
||||
# Register a Python function hook
|
||||
registry = HookRegistry()
|
||||
|
||||
@registry.on(HookEvent.BEFORE_TOOL)
|
||||
def my_hook(event_data):
|
||||
if event_data.tool_name == "dangerous_tool":
|
||||
return HookResult(decision="deny", reason="Tool blocked by policy")
|
||||
return HookResult(decision="allow")
|
||||
|
||||
# Register a shell command hook
|
||||
registry.register_command_hook(
|
||||
event=HookEvent.BEFORE_TOOL,
|
||||
command="python /path/to/validator.py",
|
||||
matcher="write_*" # Only match tools starting with write_
|
||||
)
|
||||
|
||||
# Use with Agent
|
||||
agent = Agent(
|
||||
name="MyAgent",
|
||||
hooks=registry
|
||||
)
|
||||
"""
|
||||
|
||||
__all__ = [
|
||||
# Core types
|
||||
"HookEvent",
|
||||
"HookDecision",
|
||||
"HookResult",
|
||||
"HookInput",
|
||||
"HookOutput",
|
||||
# Hook definitions
|
||||
"HookDefinition",
|
||||
"CommandHook",
|
||||
"FunctionHook",
|
||||
# Registry and runner
|
||||
"HookRegistry",
|
||||
"HookRunner",
|
||||
# Event-specific inputs
|
||||
"BeforeToolInput",
|
||||
"AfterToolInput",
|
||||
"BeforeAgentInput",
|
||||
"AfterAgentInput",
|
||||
"SessionStartInput",
|
||||
"SessionEndInput",
|
||||
# Middleware types
|
||||
"InvocationContext",
|
||||
"ModelRequest",
|
||||
"ModelResponse",
|
||||
"ToolRequest",
|
||||
"ToolResponse",
|
||||
# Middleware decorators
|
||||
"before_model",
|
||||
"after_model",
|
||||
"wrap_model_call",
|
||||
"before_tool",
|
||||
"after_tool",
|
||||
"wrap_tool_call",
|
||||
# Middleware utilities
|
||||
"MiddlewareChain",
|
||||
"AsyncMiddlewareChain",
|
||||
"MiddlewareManager",
|
||||
]
|
||||
|
||||
|
||||
def __getattr__(name: str):
|
||||
"""Lazy load module components to avoid import overhead."""
|
||||
if name in ("HookEvent", "HookDecision", "HookResult", "HookInput", "HookOutput"):
|
||||
from .types import HookEvent, HookDecision, HookResult, HookInput, HookOutput
|
||||
return locals()[name]
|
||||
|
||||
if name in ("HookDefinition", "CommandHook", "FunctionHook"):
|
||||
from .types import HookDefinition, CommandHook, FunctionHook
|
||||
return locals()[name]
|
||||
|
||||
if name == "HookRegistry":
|
||||
from .registry import HookRegistry
|
||||
return HookRegistry
|
||||
|
||||
if name == "HookRunner":
|
||||
from .runner import HookRunner
|
||||
return HookRunner
|
||||
|
||||
if name in ("BeforeToolInput", "AfterToolInput", "BeforeAgentInput",
|
||||
"AfterAgentInput", "SessionStartInput", "SessionEndInput"):
|
||||
from .events import (
|
||||
BeforeToolInput, AfterToolInput, BeforeAgentInput,
|
||||
AfterAgentInput, SessionStartInput, SessionEndInput
|
||||
)
|
||||
return locals()[name]
|
||||
|
||||
# Middleware types
|
||||
if name in ("InvocationContext", "ModelRequest", "ModelResponse",
|
||||
"ToolRequest", "ToolResponse"):
|
||||
from .middleware import (
|
||||
InvocationContext, ModelRequest, ModelResponse,
|
||||
ToolRequest, ToolResponse
|
||||
)
|
||||
return locals()[name]
|
||||
|
||||
# Middleware decorators
|
||||
if name in ("before_model", "after_model", "wrap_model_call",
|
||||
"before_tool", "after_tool", "wrap_tool_call"):
|
||||
from .middleware import (
|
||||
before_model, after_model, wrap_model_call,
|
||||
before_tool, after_tool, wrap_tool_call
|
||||
)
|
||||
return locals()[name]
|
||||
|
||||
# Middleware utilities
|
||||
if name in ("MiddlewareChain", "AsyncMiddlewareChain", "MiddlewareManager"):
|
||||
from .middleware import MiddlewareChain, AsyncMiddlewareChain, MiddlewareManager
|
||||
return locals()[name]
|
||||
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
@@ -0,0 +1,195 @@
|
||||
"""
|
||||
Event-specific input types for the hook system.
|
||||
|
||||
Each event type has its own input class with relevant fields.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional, Dict, Any, List
|
||||
from .types import HookInput
|
||||
|
||||
|
||||
@dataclass
|
||||
class BeforeToolInput(HookInput):
|
||||
"""Input for BeforeTool hooks."""
|
||||
tool_name: str = ""
|
||||
tool_input: Dict[str, Any] = field(default_factory=dict)
|
||||
tool_description: Optional[str] = None
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
base = super().to_dict()
|
||||
base.update({
|
||||
"tool_name": self.tool_name,
|
||||
"tool_input": self.tool_input,
|
||||
"tool_description": self.tool_description
|
||||
})
|
||||
return base
|
||||
|
||||
|
||||
@dataclass
|
||||
class AfterToolInput(HookInput):
|
||||
"""Input for AfterTool hooks."""
|
||||
tool_name: str = ""
|
||||
tool_input: Dict[str, Any] = field(default_factory=dict)
|
||||
tool_output: Any = None
|
||||
tool_error: Optional[str] = None
|
||||
execution_time_ms: float = 0.0
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
base = super().to_dict()
|
||||
base.update({
|
||||
"tool_name": self.tool_name,
|
||||
"tool_input": self.tool_input,
|
||||
"tool_output": str(self.tool_output) if self.tool_output else None,
|
||||
"tool_error": self.tool_error,
|
||||
"execution_time_ms": self.execution_time_ms
|
||||
})
|
||||
return base
|
||||
|
||||
|
||||
@dataclass
|
||||
class BeforeAgentInput(HookInput):
|
||||
"""Input for BeforeAgent hooks."""
|
||||
prompt: str = ""
|
||||
conversation_history: List[Dict[str, Any]] = field(default_factory=list)
|
||||
tools_available: List[str] = field(default_factory=list)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
base = super().to_dict()
|
||||
base.update({
|
||||
"prompt": self.prompt,
|
||||
"conversation_history_length": len(self.conversation_history),
|
||||
"tools_available": self.tools_available
|
||||
})
|
||||
return base
|
||||
|
||||
|
||||
@dataclass
|
||||
class AfterAgentInput(HookInput):
|
||||
"""Input for AfterAgent hooks."""
|
||||
prompt: str = ""
|
||||
response: str = ""
|
||||
tools_used: List[str] = field(default_factory=list)
|
||||
total_tokens: int = 0
|
||||
execution_time_ms: float = 0.0
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
base = super().to_dict()
|
||||
base.update({
|
||||
"prompt": self.prompt,
|
||||
"response": self.response[:500] if self.response else "", # Truncate for JSON
|
||||
"tools_used": self.tools_used,
|
||||
"total_tokens": self.total_tokens,
|
||||
"execution_time_ms": self.execution_time_ms
|
||||
})
|
||||
return base
|
||||
|
||||
|
||||
@dataclass
|
||||
class SessionStartInput(HookInput):
|
||||
"""Input for SessionStart hooks."""
|
||||
source: str = "startup" # startup, resume, clear
|
||||
session_name: Optional[str] = None
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
base = super().to_dict()
|
||||
base.update({
|
||||
"source": self.source,
|
||||
"session_name": self.session_name
|
||||
})
|
||||
return base
|
||||
|
||||
|
||||
@dataclass
|
||||
class SessionEndInput(HookInput):
|
||||
"""Input for SessionEnd hooks."""
|
||||
reason: str = "exit" # exit, clear, logout, error
|
||||
total_turns: int = 0
|
||||
total_tokens: int = 0
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
base = super().to_dict()
|
||||
base.update({
|
||||
"reason": self.reason,
|
||||
"total_turns": self.total_turns,
|
||||
"total_tokens": self.total_tokens
|
||||
})
|
||||
return base
|
||||
|
||||
|
||||
@dataclass
|
||||
class BeforeLLMInput(HookInput):
|
||||
"""Input for BeforeLLM hooks."""
|
||||
messages: List[Dict[str, Any]] = field(default_factory=list)
|
||||
model: str = ""
|
||||
temperature: Optional[float] = None
|
||||
max_tokens: Optional[int] = None
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
base = super().to_dict()
|
||||
base.update({
|
||||
"messages_count": len(self.messages),
|
||||
"model": self.model,
|
||||
"temperature": self.temperature,
|
||||
"max_tokens": self.max_tokens
|
||||
})
|
||||
return base
|
||||
|
||||
|
||||
@dataclass
|
||||
class AfterLLMInput(HookInput):
|
||||
"""Input for AfterLLM hooks."""
|
||||
messages: List[Dict[str, Any]] = field(default_factory=list)
|
||||
response: str = ""
|
||||
model: str = ""
|
||||
tokens_used: int = 0
|
||||
latency_ms: float = 0.0
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
base = super().to_dict()
|
||||
base.update({
|
||||
"messages_count": len(self.messages),
|
||||
"response_length": len(self.response),
|
||||
"model": self.model,
|
||||
"tokens_used": self.tokens_used,
|
||||
"latency_ms": self.latency_ms
|
||||
})
|
||||
return base
|
||||
|
||||
|
||||
@dataclass
|
||||
class OnErrorInput(HookInput):
|
||||
"""Input for OnError hooks."""
|
||||
error_type: str = ""
|
||||
error_message: str = ""
|
||||
stack_trace: Optional[str] = None
|
||||
context: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
base = super().to_dict()
|
||||
base.update({
|
||||
"error_type": self.error_type,
|
||||
"error_message": self.error_message,
|
||||
"stack_trace": self.stack_trace,
|
||||
"context": self.context
|
||||
})
|
||||
return base
|
||||
|
||||
|
||||
@dataclass
|
||||
class OnRetryInput(HookInput):
|
||||
"""Input for OnRetry hooks."""
|
||||
retry_count: int = 0
|
||||
max_retries: int = 3
|
||||
error_message: str = ""
|
||||
operation: str = "" # tool_call, llm_request, etc.
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
base = super().to_dict()
|
||||
base.update({
|
||||
"retry_count": self.retry_count,
|
||||
"max_retries": self.max_retries,
|
||||
"error_message": self.error_message,
|
||||
"operation": self.operation
|
||||
})
|
||||
return base
|
||||
@@ -0,0 +1,439 @@
|
||||
"""
|
||||
Middleware System for PraisonAI Agents.
|
||||
|
||||
Provides wrap_model_call and wrap_tool_call patterns for intercepting
|
||||
and modifying agent behavior at model/tool call boundaries.
|
||||
|
||||
Zero Performance Impact:
|
||||
- All middleware is optional
|
||||
- Empty middleware chain is O(1) fast path
|
||||
- No overhead when middleware not registered
|
||||
|
||||
Usage:
|
||||
from praisonaiagents.hooks import before_model, wrap_model_call, wrap_tool_call
|
||||
|
||||
@before_model
|
||||
def add_context(request):
|
||||
request.messages.append({"role": "system", "content": "Extra"})
|
||||
return request
|
||||
|
||||
@wrap_model_call
|
||||
def retry_on_error(request, call_next):
|
||||
for _ in range(3):
|
||||
try:
|
||||
return call_next(request)
|
||||
except Exception:
|
||||
pass
|
||||
raise RuntimeError("All retries failed")
|
||||
|
||||
agent = Agent(name="Test", hooks=[add_context, retry_on_error])
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Callable, Dict, List, Optional, TypeVar, Generic
|
||||
from functools import wraps
|
||||
|
||||
|
||||
@dataclass
|
||||
class InvocationContext:
|
||||
"""Context passed through middleware chain.
|
||||
|
||||
Contains identifiers for multi-agent safety and optional metadata.
|
||||
"""
|
||||
agent_id: str
|
||||
run_id: str
|
||||
session_id: str
|
||||
tool_name: Optional[str] = None
|
||||
model_name: Optional[str] = None
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ModelRequest:
|
||||
"""Request data for model calls."""
|
||||
messages: List[Dict[str, Any]]
|
||||
model: str
|
||||
temperature: float = 1.0
|
||||
context: Optional[InvocationContext] = None
|
||||
tools: Optional[List[Any]] = None
|
||||
extra: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ModelResponse:
|
||||
"""Response data from model calls."""
|
||||
content: str
|
||||
model: str
|
||||
usage: Optional[Dict[str, int]] = None
|
||||
context: Optional[InvocationContext] = None
|
||||
tool_calls: Optional[List[Any]] = None
|
||||
extra: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolRequest:
|
||||
"""Request data for tool calls."""
|
||||
tool_name: str
|
||||
arguments: Dict[str, Any]
|
||||
context: Optional[InvocationContext] = None
|
||||
extra: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolResponse:
|
||||
"""Response data from tool calls."""
|
||||
tool_name: str
|
||||
result: Any
|
||||
error: Optional[str] = None
|
||||
context: Optional[InvocationContext] = None
|
||||
extra: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
# Type aliases for middleware functions
|
||||
T = TypeVar('T')
|
||||
BeforeModelFn = Callable[[ModelRequest], ModelRequest]
|
||||
AfterModelFn = Callable[[ModelResponse], ModelResponse]
|
||||
WrapModelCallFn = Callable[[ModelRequest, Callable[[ModelRequest], ModelResponse]], ModelResponse]
|
||||
BeforeToolFn = Callable[[ToolRequest], ToolRequest]
|
||||
AfterToolFn = Callable[[ToolResponse], ToolResponse]
|
||||
WrapToolCallFn = Callable[[ToolRequest, Callable[[ToolRequest], ToolResponse]], ToolResponse]
|
||||
|
||||
|
||||
class MiddlewareChain:
|
||||
"""Synchronous middleware chain executor.
|
||||
|
||||
Composes middleware functions in registration order.
|
||||
First registered middleware wraps all subsequent ones.
|
||||
"""
|
||||
|
||||
def __init__(self, middlewares: Optional[List[Callable]] = None):
|
||||
self._middlewares = middlewares or []
|
||||
|
||||
def execute(self, request: Any, final_handler: Callable[[Any], Any]) -> Any:
|
||||
"""Execute the middleware chain with a final handler.
|
||||
|
||||
Args:
|
||||
request: The request object to pass through
|
||||
final_handler: The actual operation to perform
|
||||
|
||||
Returns:
|
||||
The result from the chain
|
||||
"""
|
||||
if not self._middlewares:
|
||||
return final_handler(request)
|
||||
|
||||
# Build the chain from inside out
|
||||
# Last middleware wraps final_handler
|
||||
# First middleware is outermost
|
||||
def build_chain(index: int) -> Callable[[Any], Any]:
|
||||
if index >= len(self._middlewares):
|
||||
return final_handler
|
||||
|
||||
middleware = self._middlewares[index]
|
||||
next_handler = build_chain(index + 1)
|
||||
|
||||
def handler(req: Any) -> Any:
|
||||
return middleware(req, next_handler)
|
||||
|
||||
return handler
|
||||
|
||||
chain = build_chain(0)
|
||||
return chain(request)
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self._middlewares)
|
||||
|
||||
|
||||
class AsyncMiddlewareChain:
|
||||
"""Asynchronous middleware chain executor.
|
||||
|
||||
Same as MiddlewareChain but for async middleware and handlers.
|
||||
"""
|
||||
|
||||
def __init__(self, middlewares: Optional[List[Callable]] = None):
|
||||
self._middlewares = middlewares or []
|
||||
|
||||
async def execute(self, request: Any, final_handler: Callable) -> Any:
|
||||
"""Execute the async middleware chain.
|
||||
|
||||
Args:
|
||||
request: The request object to pass through
|
||||
final_handler: The actual async operation to perform
|
||||
|
||||
Returns:
|
||||
The result from the chain
|
||||
"""
|
||||
if not self._middlewares:
|
||||
return await final_handler(request)
|
||||
|
||||
# Build the chain from inside out
|
||||
def build_chain(index: int) -> Callable:
|
||||
if index >= len(self._middlewares):
|
||||
return final_handler
|
||||
|
||||
middleware = self._middlewares[index]
|
||||
next_handler = build_chain(index + 1)
|
||||
|
||||
async def handler(req: Any) -> Any:
|
||||
return await middleware(req, next_handler)
|
||||
|
||||
return handler
|
||||
|
||||
chain = build_chain(0)
|
||||
return await chain(request)
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self._middlewares)
|
||||
|
||||
|
||||
# Decorator functions for tagging middleware
|
||||
|
||||
def before_model(func: BeforeModelFn) -> BeforeModelFn:
|
||||
"""Decorator to mark a function as a before_model hook.
|
||||
|
||||
The function receives a ModelRequest and should return a (possibly modified) ModelRequest.
|
||||
|
||||
Example:
|
||||
@before_model
|
||||
def add_context(request):
|
||||
request.messages.append({"role": "system", "content": "Extra"})
|
||||
return request
|
||||
"""
|
||||
func._hook_type = 'before_model'
|
||||
return func
|
||||
|
||||
|
||||
def after_model(func: AfterModelFn) -> AfterModelFn:
|
||||
"""Decorator to mark a function as an after_model hook.
|
||||
|
||||
The function receives a ModelResponse and should return a (possibly modified) ModelResponse.
|
||||
|
||||
Example:
|
||||
@after_model
|
||||
def log_response(response):
|
||||
print(f"Model returned: {response.content[:50]}")
|
||||
return response
|
||||
"""
|
||||
func._hook_type = 'after_model'
|
||||
return func
|
||||
|
||||
|
||||
def wrap_model_call(func: WrapModelCallFn) -> WrapModelCallFn:
|
||||
"""Decorator to mark a function as a wrap_model_call middleware.
|
||||
|
||||
The function receives (request, call_next) and should call call_next(request)
|
||||
to continue the chain, or return early to short-circuit.
|
||||
|
||||
Example:
|
||||
@wrap_model_call
|
||||
def retry_on_error(request, call_next):
|
||||
for _ in range(3):
|
||||
try:
|
||||
return call_next(request)
|
||||
except Exception:
|
||||
pass
|
||||
raise RuntimeError("All retries failed")
|
||||
"""
|
||||
func._hook_type = 'wrap_model_call'
|
||||
return func
|
||||
|
||||
|
||||
def before_tool(func: BeforeToolFn) -> BeforeToolFn:
|
||||
"""Decorator to mark a function as a before_tool hook.
|
||||
|
||||
The function receives a ToolRequest and should return a (possibly modified) ToolRequest.
|
||||
|
||||
Example:
|
||||
@before_tool
|
||||
def validate_args(request):
|
||||
if 'dangerous' in request.arguments:
|
||||
raise ValueError("Dangerous argument detected")
|
||||
return request
|
||||
"""
|
||||
func._hook_type = 'before_tool'
|
||||
return func
|
||||
|
||||
|
||||
def after_tool(func: AfterToolFn) -> AfterToolFn:
|
||||
"""Decorator to mark a function as an after_tool hook.
|
||||
|
||||
The function receives a ToolResponse and should return a (possibly modified) ToolResponse.
|
||||
|
||||
Example:
|
||||
@after_tool
|
||||
def log_result(response):
|
||||
print(f"Tool {response.tool_name} returned: {response.result}")
|
||||
return response
|
||||
"""
|
||||
func._hook_type = 'after_tool'
|
||||
return func
|
||||
|
||||
|
||||
def wrap_tool_call(func: WrapToolCallFn) -> WrapToolCallFn:
|
||||
"""Decorator to mark a function as a wrap_tool_call middleware.
|
||||
|
||||
The function receives (request, call_next) and should call call_next(request)
|
||||
to continue the chain, or return early to short-circuit.
|
||||
|
||||
Example:
|
||||
@wrap_tool_call
|
||||
def retry_flaky_tool(request, call_next):
|
||||
last_error = None
|
||||
for _ in range(3):
|
||||
try:
|
||||
return call_next(request)
|
||||
except Exception as e:
|
||||
last_error = e
|
||||
raise last_error
|
||||
"""
|
||||
func._hook_type = 'wrap_tool_call'
|
||||
return func
|
||||
|
||||
|
||||
def get_hook_type(func: Callable) -> Optional[str]:
|
||||
"""Get the hook type of a decorated function."""
|
||||
return getattr(func, '_hook_type', None)
|
||||
|
||||
|
||||
def is_middleware(func: Callable) -> bool:
|
||||
"""Check if a function is a middleware (wrap_* type)."""
|
||||
hook_type = get_hook_type(func)
|
||||
return hook_type in ('wrap_model_call', 'wrap_tool_call')
|
||||
|
||||
|
||||
def categorize_hooks(hooks: List[Callable]) -> Dict[str, List[Callable]]:
|
||||
"""Categorize hooks by their type.
|
||||
|
||||
Returns:
|
||||
Dict with keys: before_model, after_model, wrap_model_call,
|
||||
before_tool, after_tool, wrap_tool_call
|
||||
"""
|
||||
categories = {
|
||||
'before_model': [],
|
||||
'after_model': [],
|
||||
'wrap_model_call': [],
|
||||
'before_tool': [],
|
||||
'after_tool': [],
|
||||
'wrap_tool_call': [],
|
||||
}
|
||||
|
||||
for hook in hooks or []:
|
||||
hook_type = get_hook_type(hook)
|
||||
if hook_type and hook_type in categories:
|
||||
categories[hook_type].append(hook)
|
||||
|
||||
return categories
|
||||
|
||||
|
||||
class MiddlewareManager:
|
||||
"""Manages middleware for an agent.
|
||||
|
||||
Provides methods to execute before/after hooks and middleware chains
|
||||
for both model and tool calls.
|
||||
"""
|
||||
|
||||
def __init__(self, hooks: Optional[List[Callable]] = None):
|
||||
self._hooks = hooks or []
|
||||
self._categorized = categorize_hooks(self._hooks)
|
||||
|
||||
# Pre-build chains for efficiency
|
||||
self._model_chain = MiddlewareChain(self._categorized['wrap_model_call'])
|
||||
self._tool_chain = MiddlewareChain(self._categorized['wrap_tool_call'])
|
||||
|
||||
@property
|
||||
def has_model_hooks(self) -> bool:
|
||||
"""Check if any model hooks are registered."""
|
||||
return bool(
|
||||
self._categorized['before_model'] or
|
||||
self._categorized['after_model'] or
|
||||
self._categorized['wrap_model_call']
|
||||
)
|
||||
|
||||
@property
|
||||
def has_tool_hooks(self) -> bool:
|
||||
"""Check if any tool hooks are registered."""
|
||||
return bool(
|
||||
self._categorized['before_tool'] or
|
||||
self._categorized['after_tool'] or
|
||||
self._categorized['wrap_tool_call']
|
||||
)
|
||||
|
||||
def run_before_model(self, request: ModelRequest) -> ModelRequest:
|
||||
"""Run all before_model hooks."""
|
||||
for hook in self._categorized['before_model']:
|
||||
request = hook(request)
|
||||
return request
|
||||
|
||||
def run_after_model(self, response: ModelResponse) -> ModelResponse:
|
||||
"""Run all after_model hooks (in reverse order)."""
|
||||
for hook in reversed(self._categorized['after_model']):
|
||||
response = hook(response)
|
||||
return response
|
||||
|
||||
def run_before_tool(self, request: ToolRequest) -> ToolRequest:
|
||||
"""Run all before_tool hooks."""
|
||||
for hook in self._categorized['before_tool']:
|
||||
request = hook(request)
|
||||
return request
|
||||
|
||||
def run_after_tool(self, response: ToolResponse) -> ToolResponse:
|
||||
"""Run all after_tool hooks (in reverse order)."""
|
||||
for hook in reversed(self._categorized['after_tool']):
|
||||
response = hook(response)
|
||||
return response
|
||||
|
||||
def execute_model_call(
|
||||
self,
|
||||
request: ModelRequest,
|
||||
model_fn: Callable[[ModelRequest], ModelResponse]
|
||||
) -> ModelResponse:
|
||||
"""Execute a model call with all hooks and middleware.
|
||||
|
||||
Order:
|
||||
1. before_model hooks (in order)
|
||||
2. wrap_model_call middleware chain
|
||||
3. actual model call
|
||||
4. after_model hooks (in reverse order)
|
||||
"""
|
||||
# Fast path: no hooks
|
||||
if not self.has_model_hooks:
|
||||
return model_fn(request)
|
||||
|
||||
# Run before hooks
|
||||
request = self.run_before_model(request)
|
||||
|
||||
# Build wrapped handler that includes after hooks
|
||||
def wrapped_model_fn(req: ModelRequest) -> ModelResponse:
|
||||
response = model_fn(req)
|
||||
return self.run_after_model(response)
|
||||
|
||||
# Execute through middleware chain
|
||||
return self._model_chain.execute(request, wrapped_model_fn)
|
||||
|
||||
def execute_tool_call(
|
||||
self,
|
||||
request: ToolRequest,
|
||||
tool_fn: Callable[[ToolRequest], ToolResponse]
|
||||
) -> ToolResponse:
|
||||
"""Execute a tool call with all hooks and middleware.
|
||||
|
||||
Order:
|
||||
1. before_tool hooks (in order)
|
||||
2. wrap_tool_call middleware chain
|
||||
3. actual tool call
|
||||
4. after_tool hooks (in reverse order)
|
||||
"""
|
||||
# Fast path: no hooks
|
||||
if not self.has_tool_hooks:
|
||||
return tool_fn(request)
|
||||
|
||||
# Run before hooks
|
||||
request = self.run_before_tool(request)
|
||||
|
||||
# Build wrapped handler that includes after hooks
|
||||
def wrapped_tool_fn(req: ToolRequest) -> ToolResponse:
|
||||
response = tool_fn(req)
|
||||
return self.run_after_tool(response)
|
||||
|
||||
# Execute through middleware chain
|
||||
return self._tool_chain.execute(request, wrapped_tool_fn)
|
||||
@@ -0,0 +1,336 @@
|
||||
"""
|
||||
Hook Registry for PraisonAI Agents.
|
||||
|
||||
Manages registration and lookup of hooks for different events.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Dict, List, Optional, Callable, Union
|
||||
from functools import wraps
|
||||
|
||||
from .types import (
|
||||
HookEvent, HookDefinition, CommandHook, FunctionHook,
|
||||
HookInput, HookResult
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class HookRegistry:
|
||||
"""
|
||||
Registry for managing hooks.
|
||||
|
||||
Provides methods to register, unregister, and lookup hooks
|
||||
for different events.
|
||||
|
||||
Example:
|
||||
registry = HookRegistry()
|
||||
|
||||
# Register a function hook using decorator
|
||||
@registry.on(HookEvent.BEFORE_TOOL)
|
||||
def validate_tool(event_data):
|
||||
if event_data.tool_name == "dangerous":
|
||||
return HookResult.deny("Tool is dangerous")
|
||||
return HookResult.allow()
|
||||
|
||||
# Register a command hook
|
||||
registry.register_command(
|
||||
event=HookEvent.BEFORE_TOOL,
|
||||
command="python /path/to/validator.py",
|
||||
matcher="write_*"
|
||||
)
|
||||
"""
|
||||
|
||||
def __init__(self, enabled: bool = True):
|
||||
"""
|
||||
Initialize the hook registry.
|
||||
|
||||
Args:
|
||||
enabled: Whether hooks are enabled globally
|
||||
"""
|
||||
self._hooks: Dict[HookEvent, List[HookDefinition]] = {
|
||||
event: [] for event in HookEvent
|
||||
}
|
||||
self._enabled = enabled
|
||||
self._global_timeout = 60.0
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
"""Check if hooks are enabled."""
|
||||
return self._enabled
|
||||
|
||||
@enabled.setter
|
||||
def enabled(self, value: bool):
|
||||
"""Enable or disable hooks."""
|
||||
self._enabled = value
|
||||
|
||||
def register(self, hook: HookDefinition) -> str:
|
||||
"""
|
||||
Register a hook definition.
|
||||
|
||||
Args:
|
||||
hook: The hook definition to register
|
||||
|
||||
Returns:
|
||||
The hook ID
|
||||
"""
|
||||
self._hooks[hook.event].append(hook)
|
||||
logger.debug(f"Registered hook '{hook.name}' for event '{hook.event.value}'")
|
||||
return hook.id
|
||||
|
||||
def register_function(
|
||||
self,
|
||||
event: HookEvent,
|
||||
func: Callable[[HookInput], HookResult],
|
||||
matcher: Optional[str] = None,
|
||||
name: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
sequential: bool = False,
|
||||
timeout: float = 60.0,
|
||||
is_async: bool = False
|
||||
) -> str:
|
||||
"""
|
||||
Register a Python function as a hook.
|
||||
|
||||
Args:
|
||||
event: The event to hook
|
||||
func: The function to call
|
||||
matcher: Optional regex pattern to match targets
|
||||
name: Optional name for the hook
|
||||
description: Optional description
|
||||
sequential: Whether to run sequentially
|
||||
timeout: Timeout in seconds
|
||||
is_async: Whether the function is async
|
||||
|
||||
Returns:
|
||||
The hook ID
|
||||
"""
|
||||
hook = FunctionHook(
|
||||
event=event,
|
||||
func=func,
|
||||
matcher=matcher,
|
||||
name=name or func.__name__,
|
||||
description=description,
|
||||
sequential=sequential,
|
||||
timeout=timeout,
|
||||
is_async=is_async
|
||||
)
|
||||
return self.register(hook)
|
||||
|
||||
def register_command(
|
||||
self,
|
||||
event: HookEvent,
|
||||
command: str,
|
||||
matcher: Optional[str] = None,
|
||||
name: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
sequential: bool = False,
|
||||
timeout: float = 60.0,
|
||||
env: Optional[Dict[str, str]] = None,
|
||||
shell: bool = True
|
||||
) -> str:
|
||||
"""
|
||||
Register a shell command as a hook.
|
||||
|
||||
Args:
|
||||
event: The event to hook
|
||||
command: The shell command to execute
|
||||
matcher: Optional regex pattern to match targets
|
||||
name: Optional name for the hook
|
||||
description: Optional description
|
||||
sequential: Whether to run sequentially
|
||||
timeout: Timeout in seconds
|
||||
env: Additional environment variables
|
||||
shell: Whether to run in shell mode
|
||||
|
||||
Returns:
|
||||
The hook ID
|
||||
"""
|
||||
hook = CommandHook(
|
||||
event=event,
|
||||
command=command,
|
||||
matcher=matcher,
|
||||
name=name,
|
||||
description=description,
|
||||
sequential=sequential,
|
||||
timeout=timeout,
|
||||
env=env or {},
|
||||
shell=shell
|
||||
)
|
||||
return self.register(hook)
|
||||
|
||||
def on(
|
||||
self,
|
||||
event: HookEvent,
|
||||
matcher: Optional[str] = None,
|
||||
sequential: bool = False,
|
||||
timeout: float = 60.0
|
||||
) -> Callable:
|
||||
"""
|
||||
Decorator to register a function as a hook.
|
||||
|
||||
Args:
|
||||
event: The event to hook
|
||||
matcher: Optional regex pattern to match targets
|
||||
sequential: Whether to run sequentially
|
||||
timeout: Timeout in seconds
|
||||
|
||||
Returns:
|
||||
Decorator function
|
||||
|
||||
Example:
|
||||
@registry.on(HookEvent.BEFORE_TOOL)
|
||||
def my_hook(event_data):
|
||||
return HookResult.allow()
|
||||
"""
|
||||
def decorator(func: Callable[[HookInput], HookResult]) -> Callable:
|
||||
import asyncio
|
||||
is_async = asyncio.iscoroutinefunction(func)
|
||||
|
||||
self.register_function(
|
||||
event=event,
|
||||
func=func,
|
||||
matcher=matcher,
|
||||
sequential=sequential,
|
||||
timeout=timeout,
|
||||
is_async=is_async
|
||||
)
|
||||
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
return func(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
def unregister(self, hook_id: str) -> bool:
|
||||
"""
|
||||
Unregister a hook by ID.
|
||||
|
||||
Args:
|
||||
hook_id: The hook ID to unregister
|
||||
|
||||
Returns:
|
||||
True if found and removed, False otherwise
|
||||
"""
|
||||
for event in HookEvent:
|
||||
for i, hook in enumerate(self._hooks[event]):
|
||||
if hook.id == hook_id:
|
||||
self._hooks[event].pop(i)
|
||||
logger.debug(f"Unregistered hook '{hook_id}'")
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_hooks(
|
||||
self,
|
||||
event: HookEvent,
|
||||
target: Optional[str] = None
|
||||
) -> List[HookDefinition]:
|
||||
"""
|
||||
Get all hooks for an event, optionally filtered by target.
|
||||
|
||||
Args:
|
||||
event: The event to get hooks for
|
||||
target: Optional target to filter by (e.g., tool name)
|
||||
|
||||
Returns:
|
||||
List of matching hook definitions
|
||||
"""
|
||||
if not self._enabled:
|
||||
return []
|
||||
|
||||
hooks = self._hooks.get(event, [])
|
||||
|
||||
if target is None:
|
||||
return [h for h in hooks if h.enabled]
|
||||
|
||||
return [h for h in hooks if h.enabled and h.matches(target)]
|
||||
|
||||
def has_hooks(self, event: HookEvent) -> bool:
|
||||
"""Check if there are any hooks registered for an event."""
|
||||
return bool(self.get_hooks(event))
|
||||
|
||||
def clear(self, event: Optional[HookEvent] = None):
|
||||
"""
|
||||
Clear all hooks or hooks for a specific event.
|
||||
|
||||
Args:
|
||||
event: Optional event to clear hooks for
|
||||
"""
|
||||
if event is None:
|
||||
for e in HookEvent:
|
||||
self._hooks[e] = []
|
||||
else:
|
||||
self._hooks[event] = []
|
||||
|
||||
def list_hooks(self) -> Dict[str, List[Dict]]:
|
||||
"""
|
||||
List all registered hooks.
|
||||
|
||||
Returns:
|
||||
Dictionary mapping event names to hook info
|
||||
"""
|
||||
result = {}
|
||||
for event in HookEvent:
|
||||
hooks = self._hooks[event]
|
||||
if hooks:
|
||||
result[event.value] = [
|
||||
{
|
||||
"id": h.id,
|
||||
"name": h.name,
|
||||
"type": "command" if isinstance(h, CommandHook) else "function",
|
||||
"matcher": h.matcher,
|
||||
"enabled": h.enabled,
|
||||
"sequential": h.sequential
|
||||
}
|
||||
for h in hooks
|
||||
]
|
||||
return result
|
||||
|
||||
def enable_hook(self, hook_id: str) -> bool:
|
||||
"""Enable a specific hook."""
|
||||
for event in HookEvent:
|
||||
for hook in self._hooks[event]:
|
||||
if hook.id == hook_id:
|
||||
hook.enabled = True
|
||||
return True
|
||||
return False
|
||||
|
||||
def disable_hook(self, hook_id: str) -> bool:
|
||||
"""Disable a specific hook."""
|
||||
for event in HookEvent:
|
||||
for hook in self._hooks[event]:
|
||||
if hook.id == hook_id:
|
||||
hook.enabled = False
|
||||
return True
|
||||
return False
|
||||
|
||||
def set_global_timeout(self, timeout: float):
|
||||
"""Set the global timeout for all hooks."""
|
||||
self._global_timeout = timeout
|
||||
|
||||
def __len__(self) -> int:
|
||||
"""Get total number of registered hooks."""
|
||||
return sum(len(hooks) for hooks in self._hooks.values())
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"HookRegistry(enabled={self._enabled}, hooks={len(self)})"
|
||||
|
||||
|
||||
# Global default registry
|
||||
_default_registry: Optional[HookRegistry] = None
|
||||
|
||||
|
||||
def get_default_registry() -> HookRegistry:
|
||||
"""Get the default global hook registry."""
|
||||
global _default_registry
|
||||
if _default_registry is None:
|
||||
_default_registry = HookRegistry()
|
||||
return _default_registry
|
||||
|
||||
|
||||
def set_default_registry(registry: HookRegistry):
|
||||
"""Set the default global hook registry."""
|
||||
global _default_registry
|
||||
_default_registry = registry
|
||||
@@ -0,0 +1,450 @@
|
||||
"""
|
||||
Hook Runner for PraisonAI Agents.
|
||||
|
||||
Executes hooks registered in the registry, supporting both
|
||||
Python functions and shell commands.
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import time
|
||||
import asyncio
|
||||
import logging
|
||||
import subprocess
|
||||
from typing import List, Optional, Dict, Any
|
||||
|
||||
from .types import (
|
||||
HookEvent, HookDefinition, CommandHook, FunctionHook,
|
||||
HookInput, HookResult, HookExecutionResult
|
||||
)
|
||||
from .registry import HookRegistry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Exit codes for command hooks
|
||||
EXIT_CODE_SUCCESS = 0
|
||||
EXIT_CODE_BLOCKING_ERROR = 2
|
||||
EXIT_CODE_NON_BLOCKING_ERROR = 1
|
||||
|
||||
|
||||
class HookRunner:
|
||||
"""
|
||||
Executes hooks from a registry.
|
||||
|
||||
Supports:
|
||||
- Python function hooks (sync and async)
|
||||
- Shell command hooks
|
||||
- Sequential and parallel execution
|
||||
- Timeout handling
|
||||
- Result aggregation
|
||||
|
||||
Example:
|
||||
registry = HookRegistry()
|
||||
runner = HookRunner(registry)
|
||||
|
||||
# Execute hooks for an event
|
||||
results = await runner.execute(
|
||||
event=HookEvent.BEFORE_TOOL,
|
||||
input_data=BeforeToolInput(
|
||||
tool_name="write_file",
|
||||
tool_input={"path": "/tmp/test.txt"}
|
||||
)
|
||||
)
|
||||
|
||||
# Check if any hook blocked
|
||||
if runner.is_blocked(results):
|
||||
print("Tool execution blocked by hook")
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
registry: Optional[HookRegistry] = None,
|
||||
default_timeout: float = 60.0,
|
||||
cwd: Optional[str] = None
|
||||
):
|
||||
"""
|
||||
Initialize the hook runner.
|
||||
|
||||
Args:
|
||||
registry: Hook registry to use
|
||||
default_timeout: Default timeout for hooks
|
||||
cwd: Working directory for command hooks
|
||||
"""
|
||||
self._registry = registry or HookRegistry()
|
||||
self._default_timeout = default_timeout
|
||||
self._cwd = cwd or os.getcwd()
|
||||
|
||||
@property
|
||||
def registry(self) -> HookRegistry:
|
||||
"""Get the hook registry."""
|
||||
return self._registry
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
event: HookEvent,
|
||||
input_data: HookInput,
|
||||
target: Optional[str] = None
|
||||
) -> List[HookExecutionResult]:
|
||||
"""
|
||||
Execute all hooks for an event.
|
||||
|
||||
Args:
|
||||
event: The event to execute hooks for
|
||||
input_data: Input data for the hooks
|
||||
target: Optional target to filter hooks (e.g., tool name)
|
||||
|
||||
Returns:
|
||||
List of execution results
|
||||
"""
|
||||
hooks = self._registry.get_hooks(event, target)
|
||||
|
||||
if not hooks:
|
||||
return []
|
||||
|
||||
# Separate sequential and parallel hooks
|
||||
sequential_hooks = [h for h in hooks if h.sequential]
|
||||
parallel_hooks = [h for h in hooks if not h.sequential]
|
||||
|
||||
results = []
|
||||
|
||||
# Execute parallel hooks first
|
||||
if parallel_hooks:
|
||||
parallel_results = await self._execute_parallel(
|
||||
parallel_hooks, event, input_data
|
||||
)
|
||||
results.extend(parallel_results)
|
||||
|
||||
# Execute sequential hooks
|
||||
if sequential_hooks:
|
||||
sequential_results = await self._execute_sequential(
|
||||
sequential_hooks, event, input_data
|
||||
)
|
||||
results.extend(sequential_results)
|
||||
|
||||
return results
|
||||
|
||||
def execute_sync(
|
||||
self,
|
||||
event: HookEvent,
|
||||
input_data: HookInput,
|
||||
target: Optional[str] = None
|
||||
) -> List[HookExecutionResult]:
|
||||
"""
|
||||
Synchronous version of execute.
|
||||
|
||||
Args:
|
||||
event: The event to execute hooks for
|
||||
input_data: Input data for the hooks
|
||||
target: Optional target to filter hooks
|
||||
|
||||
Returns:
|
||||
List of execution results
|
||||
"""
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
# If we're in an async context, create a task
|
||||
future = asyncio.ensure_future(self.execute(event, input_data, target))
|
||||
return loop.run_until_complete(future)
|
||||
except RuntimeError:
|
||||
# No event loop running, create one
|
||||
return asyncio.run(self.execute(event, input_data, target))
|
||||
|
||||
async def _execute_parallel(
|
||||
self,
|
||||
hooks: List[HookDefinition],
|
||||
event: HookEvent,
|
||||
input_data: HookInput
|
||||
) -> List[HookExecutionResult]:
|
||||
"""Execute hooks in parallel."""
|
||||
tasks = [
|
||||
self._execute_single(hook, event, input_data)
|
||||
for hook in hooks
|
||||
]
|
||||
return await asyncio.gather(*tasks)
|
||||
|
||||
async def _execute_sequential(
|
||||
self,
|
||||
hooks: List[HookDefinition],
|
||||
event: HookEvent,
|
||||
input_data: HookInput
|
||||
) -> List[HookExecutionResult]:
|
||||
"""Execute hooks sequentially, passing modified input to next hook."""
|
||||
results = []
|
||||
current_input = input_data
|
||||
|
||||
for hook in hooks:
|
||||
result = await self._execute_single(hook, event, current_input)
|
||||
results.append(result)
|
||||
|
||||
# If hook modified the input, use it for next hook
|
||||
if result.success and result.output and result.output.modified_input:
|
||||
# Update input with modifications
|
||||
for key, value in result.output.modified_input.items():
|
||||
if hasattr(current_input, key):
|
||||
setattr(current_input, key, value)
|
||||
|
||||
# Stop if hook blocked
|
||||
if result.output and result.output.is_denied():
|
||||
break
|
||||
|
||||
return results
|
||||
|
||||
async def _execute_single(
|
||||
self,
|
||||
hook: HookDefinition,
|
||||
event: HookEvent,
|
||||
input_data: HookInput
|
||||
) -> HookExecutionResult:
|
||||
"""Execute a single hook."""
|
||||
start_time = time.time()
|
||||
|
||||
try:
|
||||
if isinstance(hook, CommandHook):
|
||||
return await self._execute_command_hook(hook, event, input_data, start_time)
|
||||
elif isinstance(hook, FunctionHook):
|
||||
return await self._execute_function_hook(hook, event, input_data, start_time)
|
||||
else:
|
||||
raise ValueError(f"Unknown hook type: {type(hook)}")
|
||||
except Exception as e:
|
||||
duration = (time.time() - start_time) * 1000
|
||||
logger.warning(f"Hook '{hook.name}' failed: {e}")
|
||||
return HookExecutionResult(
|
||||
hook_id=hook.id,
|
||||
hook_name=hook.name or "unknown",
|
||||
event=event,
|
||||
success=False,
|
||||
error=str(e),
|
||||
duration_ms=duration
|
||||
)
|
||||
|
||||
async def _execute_command_hook(
|
||||
self,
|
||||
hook: CommandHook,
|
||||
event: HookEvent,
|
||||
input_data: HookInput,
|
||||
start_time: float
|
||||
) -> HookExecutionResult:
|
||||
"""Execute a command hook."""
|
||||
timeout = hook.timeout or self._default_timeout
|
||||
|
||||
# Prepare environment
|
||||
env = os.environ.copy()
|
||||
env.update(hook.env)
|
||||
env["PRAISON_PROJECT_DIR"] = self._cwd
|
||||
env["PRAISON_EVENT"] = event.value
|
||||
|
||||
# Expand command variables
|
||||
command = self._expand_command(hook.command, input_data)
|
||||
|
||||
try:
|
||||
# Create subprocess
|
||||
process = await asyncio.create_subprocess_shell(
|
||||
command,
|
||||
stdin=asyncio.subprocess.PIPE,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
env=env,
|
||||
cwd=self._cwd
|
||||
)
|
||||
|
||||
# Send input as JSON
|
||||
input_json = json.dumps(input_data.to_dict()).encode()
|
||||
|
||||
try:
|
||||
stdout, stderr = await asyncio.wait_for(
|
||||
process.communicate(input=input_json),
|
||||
timeout=timeout
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
process.kill()
|
||||
await process.wait()
|
||||
duration = (time.time() - start_time) * 1000
|
||||
return HookExecutionResult(
|
||||
hook_id=hook.id,
|
||||
hook_name=hook.name or "unknown",
|
||||
event=event,
|
||||
success=False,
|
||||
error=f"Hook timed out after {timeout}s",
|
||||
duration_ms=duration
|
||||
)
|
||||
|
||||
duration = (time.time() - start_time) * 1000
|
||||
exit_code = process.returncode
|
||||
stdout_str = stdout.decode() if stdout else ""
|
||||
stderr_str = stderr.decode() if stderr else ""
|
||||
|
||||
# Parse output
|
||||
output = self._parse_command_output(stdout_str, stderr_str, exit_code)
|
||||
|
||||
return HookExecutionResult(
|
||||
hook_id=hook.id,
|
||||
hook_name=hook.name or "unknown",
|
||||
event=event,
|
||||
success=exit_code == EXIT_CODE_SUCCESS,
|
||||
output=output,
|
||||
stdout=stdout_str,
|
||||
stderr=stderr_str,
|
||||
exit_code=exit_code,
|
||||
duration_ms=duration
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
duration = (time.time() - start_time) * 1000
|
||||
return HookExecutionResult(
|
||||
hook_id=hook.id,
|
||||
hook_name=hook.name or "unknown",
|
||||
event=event,
|
||||
success=False,
|
||||
error=str(e),
|
||||
duration_ms=duration
|
||||
)
|
||||
|
||||
async def _execute_function_hook(
|
||||
self,
|
||||
hook: FunctionHook,
|
||||
event: HookEvent,
|
||||
input_data: HookInput,
|
||||
start_time: float
|
||||
) -> HookExecutionResult:
|
||||
"""Execute a function hook."""
|
||||
if hook.func is None:
|
||||
duration = (time.time() - start_time) * 1000
|
||||
return HookExecutionResult(
|
||||
hook_id=hook.id,
|
||||
hook_name=hook.name or "unknown",
|
||||
event=event,
|
||||
success=False,
|
||||
error="Hook function is None",
|
||||
duration_ms=duration
|
||||
)
|
||||
|
||||
timeout = hook.timeout or self._default_timeout
|
||||
|
||||
try:
|
||||
if hook.is_async:
|
||||
# Execute async function with timeout
|
||||
result = await asyncio.wait_for(
|
||||
hook.func(input_data),
|
||||
timeout=timeout
|
||||
)
|
||||
else:
|
||||
# Execute sync function in thread pool with timeout
|
||||
loop = asyncio.get_event_loop()
|
||||
result = await asyncio.wait_for(
|
||||
loop.run_in_executor(None, hook.func, input_data),
|
||||
timeout=timeout
|
||||
)
|
||||
|
||||
duration = (time.time() - start_time) * 1000
|
||||
|
||||
# Normalize result
|
||||
if result is None:
|
||||
result = HookResult.allow()
|
||||
elif not isinstance(result, HookResult):
|
||||
result = HookResult.allow()
|
||||
|
||||
return HookExecutionResult(
|
||||
hook_id=hook.id,
|
||||
hook_name=hook.name or "unknown",
|
||||
event=event,
|
||||
success=True,
|
||||
output=result,
|
||||
duration_ms=duration
|
||||
)
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
duration = (time.time() - start_time) * 1000
|
||||
return HookExecutionResult(
|
||||
hook_id=hook.id,
|
||||
hook_name=hook.name or "unknown",
|
||||
event=event,
|
||||
success=False,
|
||||
error=f"Hook timed out after {timeout}s",
|
||||
duration_ms=duration
|
||||
)
|
||||
except Exception as e:
|
||||
duration = (time.time() - start_time) * 1000
|
||||
return HookExecutionResult(
|
||||
hook_id=hook.id,
|
||||
hook_name=hook.name or "unknown",
|
||||
event=event,
|
||||
success=False,
|
||||
error=str(e),
|
||||
duration_ms=duration
|
||||
)
|
||||
|
||||
def _expand_command(self, command: str, input_data: HookInput) -> str:
|
||||
"""Expand variables in command string."""
|
||||
return command.replace(
|
||||
"$PRAISON_PROJECT_DIR", self._cwd
|
||||
).replace(
|
||||
"$PRAISON_CWD", input_data.cwd
|
||||
)
|
||||
|
||||
def _parse_command_output(
|
||||
self,
|
||||
stdout: str,
|
||||
stderr: str,
|
||||
exit_code: int
|
||||
) -> HookResult:
|
||||
"""Parse command output into HookResult."""
|
||||
# Try to parse JSON from stdout
|
||||
if stdout.strip():
|
||||
try:
|
||||
data = json.loads(stdout.strip())
|
||||
|
||||
# Handle double-encoded JSON
|
||||
if isinstance(data, str):
|
||||
data = json.loads(data)
|
||||
|
||||
return HookResult(
|
||||
decision=data.get("decision", "allow"),
|
||||
reason=data.get("reason"),
|
||||
modified_input=data.get("modified_input"),
|
||||
additional_context=data.get("additional_context"),
|
||||
suppress_output=data.get("suppress_output", False)
|
||||
)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
# Convert exit code to decision
|
||||
if exit_code == EXIT_CODE_SUCCESS:
|
||||
return HookResult(
|
||||
decision="allow",
|
||||
additional_context=stdout.strip() if stdout.strip() else None
|
||||
)
|
||||
elif exit_code == EXIT_CODE_BLOCKING_ERROR:
|
||||
return HookResult(
|
||||
decision="deny",
|
||||
reason=stderr.strip() or stdout.strip() or "Blocked by hook"
|
||||
)
|
||||
else:
|
||||
return HookResult(
|
||||
decision="allow",
|
||||
additional_context=f"Warning: {stderr.strip() or stdout.strip()}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def is_blocked(results: List[HookExecutionResult]) -> bool:
|
||||
"""Check if any hook blocked execution."""
|
||||
for result in results:
|
||||
if result.output and result.output.is_denied():
|
||||
return True
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def get_blocking_reason(results: List[HookExecutionResult]) -> Optional[str]:
|
||||
"""Get the reason for blocking from results."""
|
||||
for result in results:
|
||||
if result.output and result.output.is_denied():
|
||||
return result.output.reason
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def aggregate_context(results: List[HookExecutionResult]) -> Optional[str]:
|
||||
"""Aggregate additional context from all results."""
|
||||
contexts = []
|
||||
for result in results:
|
||||
if result.output and result.output.additional_context:
|
||||
contexts.append(result.output.additional_context)
|
||||
return "\n".join(contexts) if contexts else None
|
||||
@@ -0,0 +1,197 @@
|
||||
"""
|
||||
Hook Types for PraisonAI Agents.
|
||||
|
||||
Defines the core types, enums, and dataclasses for the hook system.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from enum import Enum
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional, Dict, Any, List, Callable, Union, Literal
|
||||
|
||||
|
||||
class HookEvent(str, Enum):
|
||||
"""Event names for the hook system."""
|
||||
BEFORE_TOOL = "before_tool"
|
||||
AFTER_TOOL = "after_tool"
|
||||
BEFORE_AGENT = "before_agent"
|
||||
AFTER_AGENT = "after_agent"
|
||||
SESSION_START = "session_start"
|
||||
SESSION_END = "session_end"
|
||||
BEFORE_LLM = "before_llm"
|
||||
AFTER_LLM = "after_llm"
|
||||
ON_ERROR = "on_error"
|
||||
ON_RETRY = "on_retry"
|
||||
|
||||
|
||||
# Decision types for hook outputs
|
||||
HookDecision = Literal["allow", "deny", "block", "ask", None]
|
||||
|
||||
|
||||
@dataclass
|
||||
class HookInput:
|
||||
"""Base hook input - common fields for all events."""
|
||||
session_id: str
|
||||
cwd: str
|
||||
event_name: str
|
||||
timestamp: str
|
||||
agent_name: Optional[str] = None
|
||||
extra: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary for JSON serialization."""
|
||||
return {
|
||||
"session_id": self.session_id,
|
||||
"cwd": self.cwd,
|
||||
"event_name": self.event_name,
|
||||
"timestamp": self.timestamp,
|
||||
"agent_name": self.agent_name,
|
||||
**self.extra
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class HookOutput:
|
||||
"""Base hook output - common fields for all events."""
|
||||
proceed: bool = True
|
||||
stop_reason: Optional[str] = None
|
||||
suppress_output: bool = False
|
||||
system_message: Optional[str] = None
|
||||
decision: HookDecision = None
|
||||
reason: Optional[str] = None
|
||||
modified_data: Optional[Dict[str, Any]] = None
|
||||
|
||||
def is_blocking(self) -> bool:
|
||||
"""Check if this output represents a blocking decision."""
|
||||
return self.decision in ("block", "deny")
|
||||
|
||||
def should_stop(self) -> bool:
|
||||
"""Check if execution should stop."""
|
||||
return not self.proceed
|
||||
|
||||
def get_reason(self) -> str:
|
||||
"""Get the effective reason for blocking or stopping."""
|
||||
return self.reason or self.stop_reason or "No reason provided"
|
||||
|
||||
|
||||
@dataclass
|
||||
class HookResult:
|
||||
"""Result from a hook execution."""
|
||||
decision: HookDecision = "allow"
|
||||
reason: Optional[str] = None
|
||||
modified_input: Optional[Dict[str, Any]] = None
|
||||
additional_context: Optional[str] = None
|
||||
suppress_output: bool = False
|
||||
|
||||
@classmethod
|
||||
def allow(cls, reason: Optional[str] = None) -> "HookResult":
|
||||
"""Create an allow result."""
|
||||
return cls(decision="allow", reason=reason)
|
||||
|
||||
@classmethod
|
||||
def deny(cls, reason: str) -> "HookResult":
|
||||
"""Create a deny result."""
|
||||
return cls(decision="deny", reason=reason)
|
||||
|
||||
@classmethod
|
||||
def block(cls, reason: str) -> "HookResult":
|
||||
"""Create a block result."""
|
||||
return cls(decision="block", reason=reason)
|
||||
|
||||
@classmethod
|
||||
def ask(cls, reason: str) -> "HookResult":
|
||||
"""Create an ask result (requires user confirmation)."""
|
||||
return cls(decision="ask", reason=reason)
|
||||
|
||||
def is_allowed(self) -> bool:
|
||||
"""Check if the result allows execution."""
|
||||
return self.decision in ("allow", None)
|
||||
|
||||
def is_denied(self) -> bool:
|
||||
"""Check if the result denies execution."""
|
||||
return self.decision in ("deny", "block")
|
||||
|
||||
|
||||
@dataclass
|
||||
class HookDefinition:
|
||||
"""Hook definition with matcher and configuration."""
|
||||
id: str = field(default_factory=lambda: str(uuid.uuid4())[:8])
|
||||
event: HookEvent = HookEvent.BEFORE_TOOL
|
||||
matcher: Optional[str] = None # Regex pattern to match tool names
|
||||
sequential: bool = False # Execute hooks sequentially vs parallel
|
||||
enabled: bool = True
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
timeout: float = 60.0 # Timeout in seconds
|
||||
|
||||
def matches(self, target: str) -> bool:
|
||||
"""Check if this hook matches the target (tool name, etc.)."""
|
||||
if self.matcher is None:
|
||||
return True
|
||||
|
||||
import re
|
||||
try:
|
||||
return bool(re.match(self.matcher, target))
|
||||
except re.error:
|
||||
# Invalid regex, fall back to simple string match
|
||||
return self.matcher in target
|
||||
|
||||
|
||||
@dataclass
|
||||
class CommandHook(HookDefinition):
|
||||
"""Hook that executes a shell command."""
|
||||
command: str = ""
|
||||
shell: bool = True
|
||||
env: Dict[str, str] = field(default_factory=dict)
|
||||
|
||||
def __post_init__(self):
|
||||
if not self.name:
|
||||
self.name = f"command_hook_{self.id}"
|
||||
|
||||
|
||||
@dataclass
|
||||
class FunctionHook(HookDefinition):
|
||||
"""Hook that executes a Python function."""
|
||||
func: Optional[Callable[[HookInput], HookResult]] = None
|
||||
is_async: bool = False
|
||||
|
||||
def __post_init__(self):
|
||||
if not self.name and self.func:
|
||||
self.name = self.func.__name__
|
||||
elif not self.name:
|
||||
self.name = f"function_hook_{self.id}"
|
||||
|
||||
|
||||
@dataclass
|
||||
class HookExecutionResult:
|
||||
"""Result of executing a single hook."""
|
||||
hook_id: str
|
||||
hook_name: str
|
||||
event: HookEvent
|
||||
success: bool
|
||||
output: Optional[HookResult] = None
|
||||
stdout: Optional[str] = None
|
||||
stderr: Optional[str] = None
|
||||
exit_code: Optional[int] = None
|
||||
duration_ms: float = 0.0
|
||||
error: Optional[str] = None
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary."""
|
||||
return {
|
||||
"hook_id": self.hook_id,
|
||||
"hook_name": self.hook_name,
|
||||
"event": self.event.value,
|
||||
"success": self.success,
|
||||
"output": self.output.__dict__ if self.output else None,
|
||||
"stdout": self.stdout,
|
||||
"stderr": self.stderr,
|
||||
"exit_code": self.exit_code,
|
||||
"duration_ms": self.duration_ms,
|
||||
"error": self.error
|
||||
}
|
||||
|
||||
|
||||
# Type alias for hook functions
|
||||
HookFunction = Callable[[HookInput], Union[HookResult, None]]
|
||||
AsyncHookFunction = Callable[[HookInput], Any] # Returns Awaitable[HookResult]
|
||||
@@ -27,6 +27,7 @@ from .model_router import (
|
||||
TaskComplexity,
|
||||
create_routing_agent
|
||||
)
|
||||
from .rate_limiter import RateLimiter
|
||||
|
||||
__all__ = [
|
||||
"LLM",
|
||||
@@ -46,5 +47,6 @@ __all__ = [
|
||||
"ModelRouter",
|
||||
"ModelProfile",
|
||||
"TaskComplexity",
|
||||
"create_routing_agent"
|
||||
"create_routing_agent",
|
||||
"RateLimiter"
|
||||
]
|
||||
@@ -0,0 +1,147 @@
|
||||
"""
|
||||
Rate Limiter for PraisonAI Agents.
|
||||
|
||||
Provides token bucket rate limiting for LLM API calls.
|
||||
|
||||
Zero Performance Impact:
|
||||
- When rate_limiter is None, no overhead
|
||||
- Uses monotonic clock for accuracy
|
||||
- Injectable clock/sleep for deterministic testing
|
||||
|
||||
Usage:
|
||||
from praisonaiagents.llm import RateLimiter
|
||||
from praisonaiagents import Agent
|
||||
|
||||
limiter = RateLimiter(requests_per_minute=60)
|
||||
agent = Agent(name="Test", rate_limiter=limiter)
|
||||
"""
|
||||
|
||||
import time
|
||||
import asyncio
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional, Callable
|
||||
|
||||
|
||||
@dataclass
|
||||
class RateLimiter:
|
||||
"""Token bucket rate limiter for API calls.
|
||||
|
||||
Uses the token bucket algorithm:
|
||||
- Tokens are added at a fixed rate (requests_per_minute / 60 per second)
|
||||
- Each request consumes one token
|
||||
- If no tokens available, wait until one is available
|
||||
- Burst allows multiple requests in quick succession
|
||||
|
||||
Args:
|
||||
requests_per_minute: Maximum requests per minute
|
||||
tokens_per_minute: Optional token-based rate limiting (for future use)
|
||||
burst: Maximum burst size (default: 1)
|
||||
|
||||
Example:
|
||||
limiter = RateLimiter(requests_per_minute=60) # 1 req/sec
|
||||
limiter.acquire() # Blocks if rate exceeded
|
||||
|
||||
# Async usage
|
||||
await limiter.acquire_async()
|
||||
"""
|
||||
requests_per_minute: int
|
||||
tokens_per_minute: Optional[int] = None
|
||||
burst: int = 1
|
||||
|
||||
# Internal state
|
||||
_tokens: float = field(default=None, init=False, repr=False)
|
||||
_last_update: float = field(default=None, init=False, repr=False)
|
||||
_lock: asyncio.Lock = field(default=None, init=False, repr=False)
|
||||
|
||||
# Injectable functions for testing
|
||||
_get_time: Callable[[], float] = field(default=None, init=False, repr=False)
|
||||
_sleep: Callable[[float], None] = field(default=None, init=False, repr=False)
|
||||
_async_sleep: Callable[[float], None] = field(default=None, init=False, repr=False)
|
||||
|
||||
def __post_init__(self):
|
||||
"""Initialize internal state."""
|
||||
self._tokens = float(self.burst)
|
||||
self._last_update = time.monotonic()
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
# Default implementations (can be overridden for testing)
|
||||
if self._get_time is None:
|
||||
self._get_time = time.monotonic
|
||||
if self._sleep is None:
|
||||
self._sleep = time.sleep
|
||||
if self._async_sleep is None:
|
||||
self._async_sleep = asyncio.sleep
|
||||
|
||||
@property
|
||||
def _rate(self) -> float:
|
||||
"""Tokens per second."""
|
||||
return self.requests_per_minute / 60.0
|
||||
|
||||
def _refill(self) -> None:
|
||||
"""Refill tokens based on elapsed time."""
|
||||
now = self._get_time()
|
||||
elapsed = now - self._last_update
|
||||
self._tokens = min(self.burst, self._tokens + elapsed * self._rate)
|
||||
self._last_update = now
|
||||
|
||||
def _wait_time(self) -> float:
|
||||
"""Calculate time to wait for next token."""
|
||||
if self._tokens >= 1.0:
|
||||
return 0.0
|
||||
return (1.0 - self._tokens) / self._rate
|
||||
|
||||
def acquire(self) -> None:
|
||||
"""Acquire a token, blocking if necessary.
|
||||
|
||||
This is the synchronous version. Use acquire_async() for async code.
|
||||
"""
|
||||
self._refill()
|
||||
|
||||
wait = self._wait_time()
|
||||
if wait > 0:
|
||||
self._sleep(wait)
|
||||
self._refill()
|
||||
|
||||
self._tokens -= 1.0
|
||||
|
||||
async def acquire_async(self) -> None:
|
||||
"""Acquire a token asynchronously, waiting if necessary.
|
||||
|
||||
This is the async version. Use acquire() for sync code.
|
||||
"""
|
||||
async with self._lock:
|
||||
self._refill()
|
||||
|
||||
wait = self._wait_time()
|
||||
if wait > 0:
|
||||
await self._async_sleep(wait)
|
||||
self._refill()
|
||||
|
||||
self._tokens -= 1.0
|
||||
|
||||
def try_acquire(self) -> bool:
|
||||
"""Try to acquire a token without blocking.
|
||||
|
||||
Returns:
|
||||
True if token acquired, False if rate limit exceeded
|
||||
"""
|
||||
self._refill()
|
||||
|
||||
if self._tokens >= 1.0:
|
||||
self._tokens -= 1.0
|
||||
return True
|
||||
return False
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Reset the rate limiter to initial state."""
|
||||
self._tokens = float(self.burst)
|
||||
self._last_update = self._get_time()
|
||||
|
||||
@property
|
||||
def available_tokens(self) -> float:
|
||||
"""Get current available tokens (for monitoring)."""
|
||||
self._refill()
|
||||
return self._tokens
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"RateLimiter(rpm={self.requests_per_minute}, burst={self.burst})"
|
||||
@@ -0,0 +1,65 @@
|
||||
"""
|
||||
LSP Integration Module for PraisonAI Agents.
|
||||
|
||||
Provides Language Server Protocol integration for code intelligence:
|
||||
- Diagnostics (errors, warnings)
|
||||
- Code completion suggestions
|
||||
- Go to definition
|
||||
- Find references
|
||||
- Hover information
|
||||
|
||||
Zero Performance Impact:
|
||||
- All imports are lazy loaded via __getattr__
|
||||
- LSP client only initialized when needed
|
||||
- No overhead when not in use
|
||||
|
||||
Usage:
|
||||
from praisonaiagents.lsp import LSPClient
|
||||
|
||||
# Create client for a language
|
||||
client = LSPClient(language="python")
|
||||
|
||||
# Start the language server
|
||||
await client.start()
|
||||
|
||||
# Get diagnostics for a file
|
||||
diagnostics = await client.get_diagnostics("/path/to/file.py")
|
||||
|
||||
# Get completions at a position
|
||||
completions = await client.get_completions("/path/to/file.py", line=10, character=5)
|
||||
"""
|
||||
|
||||
__all__ = [
|
||||
# Core client
|
||||
"LSPClient",
|
||||
# Data types
|
||||
"Diagnostic",
|
||||
"DiagnosticSeverity",
|
||||
"CompletionItem",
|
||||
"Location",
|
||||
"Position",
|
||||
"Range",
|
||||
# Configuration
|
||||
"LSPConfig",
|
||||
]
|
||||
|
||||
|
||||
def __getattr__(name: str):
|
||||
"""Lazy load module components to avoid import overhead."""
|
||||
if name == "LSPClient":
|
||||
from .client import LSPClient
|
||||
return LSPClient
|
||||
|
||||
if name in ("Diagnostic", "DiagnosticSeverity", "CompletionItem",
|
||||
"Location", "Position", "Range"):
|
||||
from .types import (
|
||||
Diagnostic, DiagnosticSeverity, CompletionItem,
|
||||
Location, Position, Range
|
||||
)
|
||||
return locals()[name]
|
||||
|
||||
if name == "LSPConfig":
|
||||
from .config import LSPConfig
|
||||
return LSPConfig
|
||||
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
@@ -0,0 +1,489 @@
|
||||
"""
|
||||
LSP Client for PraisonAI Agents.
|
||||
|
||||
Provides Language Server Protocol client implementation.
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Optional, List, Dict, Any
|
||||
from pathlib import Path
|
||||
|
||||
from .types import (
|
||||
Diagnostic, DiagnosticSeverity, CompletionItem, Location,
|
||||
Position, Range, TextDocumentItem, TextDocumentIdentifier,
|
||||
TextDocumentPositionParams
|
||||
)
|
||||
from .config import LSPConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LSPClient:
|
||||
"""
|
||||
Language Server Protocol client.
|
||||
|
||||
Communicates with language servers to provide code intelligence
|
||||
features like diagnostics, completions, and navigation.
|
||||
|
||||
Example:
|
||||
client = LSPClient(language="python")
|
||||
await client.start()
|
||||
|
||||
# Get diagnostics
|
||||
diagnostics = await client.get_diagnostics("/path/to/file.py")
|
||||
|
||||
# Get completions
|
||||
completions = await client.get_completions(
|
||||
"/path/to/file.py", line=10, character=5
|
||||
)
|
||||
|
||||
await client.stop()
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
language: str,
|
||||
command: Optional[str] = None,
|
||||
args: Optional[List[str]] = None,
|
||||
root_uri: Optional[str] = None
|
||||
):
|
||||
"""
|
||||
Initialize the LSP client.
|
||||
|
||||
Args:
|
||||
language: Programming language (python, javascript, etc.)
|
||||
command: Language server command (uses default if not specified)
|
||||
args: Command arguments
|
||||
root_uri: Workspace root URI
|
||||
"""
|
||||
self.config = LSPConfig(
|
||||
language=language,
|
||||
command=command,
|
||||
args=args or [],
|
||||
root_uri=root_uri
|
||||
)
|
||||
|
||||
self._process: Optional[asyncio.subprocess.Process] = None
|
||||
self._request_id = 0
|
||||
self._pending_requests: Dict[int, asyncio.Future] = {}
|
||||
self._diagnostics: Dict[str, List[Diagnostic]] = {}
|
||||
self._initialized = False
|
||||
self._reader_task: Optional[asyncio.Task] = None
|
||||
|
||||
@property
|
||||
def is_running(self) -> bool:
|
||||
"""Check if the language server is running."""
|
||||
return self._process is not None and self._process.returncode is None
|
||||
|
||||
async def start(self) -> bool:
|
||||
"""
|
||||
Start the language server.
|
||||
|
||||
Returns:
|
||||
True if started successfully
|
||||
"""
|
||||
if self.is_running:
|
||||
return True
|
||||
|
||||
try:
|
||||
cmd = [self.config.command] + self.config.args
|
||||
|
||||
self._process = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdin=asyncio.subprocess.PIPE,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE
|
||||
)
|
||||
|
||||
# Start reader task
|
||||
self._reader_task = asyncio.create_task(self._read_responses())
|
||||
|
||||
# Initialize the server
|
||||
root_uri = self.config.root_uri or f"file://{os.getcwd()}"
|
||||
|
||||
result = await self._send_request("initialize", {
|
||||
"processId": os.getpid(),
|
||||
"rootUri": root_uri,
|
||||
"capabilities": {
|
||||
"textDocument": {
|
||||
"completion": {"completionItem": {"snippetSupport": True}},
|
||||
"hover": {},
|
||||
"definition": {},
|
||||
"references": {},
|
||||
"publishDiagnostics": {}
|
||||
}
|
||||
},
|
||||
"initializationOptions": self.config.initialization_options
|
||||
})
|
||||
|
||||
if result:
|
||||
await self._send_notification("initialized", {})
|
||||
self._initialized = True
|
||||
logger.info(f"LSP server started: {self.config.command}")
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
except FileNotFoundError:
|
||||
logger.error(f"Language server not found: {self.config.command}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start LSP server: {e}")
|
||||
return False
|
||||
|
||||
async def stop(self):
|
||||
"""Stop the language server."""
|
||||
if not self.is_running:
|
||||
return
|
||||
|
||||
try:
|
||||
await self._send_request("shutdown", None)
|
||||
await self._send_notification("exit", None)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if self._reader_task:
|
||||
self._reader_task.cancel()
|
||||
try:
|
||||
await self._reader_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
if self._process:
|
||||
self._process.terminate()
|
||||
try:
|
||||
await asyncio.wait_for(self._process.wait(), timeout=5.0)
|
||||
except asyncio.TimeoutError:
|
||||
self._process.kill()
|
||||
|
||||
self._process = None
|
||||
self._initialized = False
|
||||
|
||||
async def open_document(self, file_path: str, text: Optional[str] = None) -> bool:
|
||||
"""
|
||||
Open a document in the language server.
|
||||
|
||||
Args:
|
||||
file_path: Path to the file
|
||||
text: File content (reads from disk if not provided)
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
if not self._initialized:
|
||||
return False
|
||||
|
||||
if text is None:
|
||||
try:
|
||||
with open(file_path, "r") as f:
|
||||
text = f.read()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to read file: {e}")
|
||||
return False
|
||||
|
||||
uri = f"file://{os.path.abspath(file_path)}"
|
||||
|
||||
await self._send_notification("textDocument/didOpen", {
|
||||
"textDocument": TextDocumentItem(
|
||||
uri=uri,
|
||||
language_id=self.config.language,
|
||||
version=1,
|
||||
text=text
|
||||
).to_dict()
|
||||
})
|
||||
|
||||
return True
|
||||
|
||||
async def close_document(self, file_path: str):
|
||||
"""Close a document in the language server."""
|
||||
if not self._initialized:
|
||||
return
|
||||
|
||||
uri = f"file://{os.path.abspath(file_path)}"
|
||||
|
||||
await self._send_notification("textDocument/didClose", {
|
||||
"textDocument": TextDocumentIdentifier(uri=uri).to_dict()
|
||||
})
|
||||
|
||||
async def get_diagnostics(self, file_path: str) -> List[Diagnostic]:
|
||||
"""
|
||||
Get diagnostics for a file.
|
||||
|
||||
Args:
|
||||
file_path: Path to the file
|
||||
|
||||
Returns:
|
||||
List of diagnostics
|
||||
"""
|
||||
uri = f"file://{os.path.abspath(file_path)}"
|
||||
return self._diagnostics.get(uri, [])
|
||||
|
||||
async def get_completions(
|
||||
self,
|
||||
file_path: str,
|
||||
line: int,
|
||||
character: int
|
||||
) -> List[CompletionItem]:
|
||||
"""
|
||||
Get completions at a position.
|
||||
|
||||
Args:
|
||||
file_path: Path to the file
|
||||
line: Line number (0-indexed)
|
||||
character: Character position (0-indexed)
|
||||
|
||||
Returns:
|
||||
List of completion items
|
||||
"""
|
||||
if not self._initialized:
|
||||
return []
|
||||
|
||||
uri = f"file://{os.path.abspath(file_path)}"
|
||||
|
||||
result = await self._send_request("textDocument/completion", {
|
||||
"textDocument": {"uri": uri},
|
||||
"position": {"line": line, "character": character}
|
||||
})
|
||||
|
||||
if result is None:
|
||||
return []
|
||||
|
||||
items = result.get("items", result) if isinstance(result, dict) else result
|
||||
|
||||
return [
|
||||
CompletionItem.from_dict(item)
|
||||
for item in (items if isinstance(items, list) else [])
|
||||
]
|
||||
|
||||
async def get_definition(
|
||||
self,
|
||||
file_path: str,
|
||||
line: int,
|
||||
character: int
|
||||
) -> List[Location]:
|
||||
"""
|
||||
Get definition location(s) for a symbol.
|
||||
|
||||
Args:
|
||||
file_path: Path to the file
|
||||
line: Line number (0-indexed)
|
||||
character: Character position (0-indexed)
|
||||
|
||||
Returns:
|
||||
List of locations
|
||||
"""
|
||||
if not self._initialized:
|
||||
return []
|
||||
|
||||
uri = f"file://{os.path.abspath(file_path)}"
|
||||
|
||||
result = await self._send_request("textDocument/definition", {
|
||||
"textDocument": {"uri": uri},
|
||||
"position": {"line": line, "character": character}
|
||||
})
|
||||
|
||||
if result is None:
|
||||
return []
|
||||
|
||||
if isinstance(result, dict):
|
||||
return [Location.from_dict(result)]
|
||||
elif isinstance(result, list):
|
||||
return [Location.from_dict(loc) for loc in result]
|
||||
|
||||
return []
|
||||
|
||||
async def get_references(
|
||||
self,
|
||||
file_path: str,
|
||||
line: int,
|
||||
character: int,
|
||||
include_declaration: bool = True
|
||||
) -> List[Location]:
|
||||
"""
|
||||
Get references to a symbol.
|
||||
|
||||
Args:
|
||||
file_path: Path to the file
|
||||
line: Line number (0-indexed)
|
||||
character: Character position (0-indexed)
|
||||
include_declaration: Include the declaration in results
|
||||
|
||||
Returns:
|
||||
List of locations
|
||||
"""
|
||||
if not self._initialized:
|
||||
return []
|
||||
|
||||
uri = f"file://{os.path.abspath(file_path)}"
|
||||
|
||||
result = await self._send_request("textDocument/references", {
|
||||
"textDocument": {"uri": uri},
|
||||
"position": {"line": line, "character": character},
|
||||
"context": {"includeDeclaration": include_declaration}
|
||||
})
|
||||
|
||||
if result is None:
|
||||
return []
|
||||
|
||||
return [Location.from_dict(loc) for loc in result]
|
||||
|
||||
async def get_hover(
|
||||
self,
|
||||
file_path: str,
|
||||
line: int,
|
||||
character: int
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Get hover information for a position.
|
||||
|
||||
Args:
|
||||
file_path: Path to the file
|
||||
line: Line number (0-indexed)
|
||||
character: Character position (0-indexed)
|
||||
|
||||
Returns:
|
||||
Hover content as string
|
||||
"""
|
||||
if not self._initialized:
|
||||
return None
|
||||
|
||||
uri = f"file://{os.path.abspath(file_path)}"
|
||||
|
||||
result = await self._send_request("textDocument/hover", {
|
||||
"textDocument": {"uri": uri},
|
||||
"position": {"line": line, "character": character}
|
||||
})
|
||||
|
||||
if result is None:
|
||||
return None
|
||||
|
||||
contents = result.get("contents")
|
||||
if isinstance(contents, str):
|
||||
return contents
|
||||
elif isinstance(contents, dict):
|
||||
return contents.get("value", str(contents))
|
||||
elif isinstance(contents, list):
|
||||
return "\n".join(
|
||||
c.get("value", str(c)) if isinstance(c, dict) else str(c)
|
||||
for c in contents
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
async def _send_request(self, method: str, params: Any) -> Any:
|
||||
"""Send a request and wait for response."""
|
||||
if not self.is_running:
|
||||
return None
|
||||
|
||||
self._request_id += 1
|
||||
request_id = self._request_id
|
||||
|
||||
message = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": request_id,
|
||||
"method": method,
|
||||
"params": params
|
||||
}
|
||||
|
||||
future = asyncio.get_event_loop().create_future()
|
||||
self._pending_requests[request_id] = future
|
||||
|
||||
await self._send_message(message)
|
||||
|
||||
try:
|
||||
result = await asyncio.wait_for(future, timeout=self.config.timeout)
|
||||
return result
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning(f"Request timed out: {method}")
|
||||
self._pending_requests.pop(request_id, None)
|
||||
return None
|
||||
|
||||
async def _send_notification(self, method: str, params: Any):
|
||||
"""Send a notification (no response expected)."""
|
||||
if not self.is_running:
|
||||
return
|
||||
|
||||
message = {
|
||||
"jsonrpc": "2.0",
|
||||
"method": method,
|
||||
"params": params
|
||||
}
|
||||
|
||||
await self._send_message(message)
|
||||
|
||||
async def _send_message(self, message: Dict[str, Any]):
|
||||
"""Send a JSON-RPC message."""
|
||||
if self._process is None or self._process.stdin is None:
|
||||
return
|
||||
|
||||
content = json.dumps(message)
|
||||
header = f"Content-Length: {len(content)}\r\n\r\n"
|
||||
|
||||
self._process.stdin.write(header.encode() + content.encode())
|
||||
await self._process.stdin.drain()
|
||||
|
||||
async def _read_responses(self):
|
||||
"""Read responses from the language server."""
|
||||
if self._process is None or self._process.stdout is None:
|
||||
return
|
||||
|
||||
try:
|
||||
while True:
|
||||
# Read header
|
||||
header = b""
|
||||
while b"\r\n\r\n" not in header:
|
||||
chunk = await self._process.stdout.read(1)
|
||||
if not chunk:
|
||||
return
|
||||
header += chunk
|
||||
|
||||
# Parse content length
|
||||
content_length = 0
|
||||
for line in header.decode().split("\r\n"):
|
||||
if line.startswith("Content-Length:"):
|
||||
content_length = int(line.split(":")[1].strip())
|
||||
break
|
||||
|
||||
if content_length == 0:
|
||||
continue
|
||||
|
||||
# Read content
|
||||
content = await self._process.stdout.read(content_length)
|
||||
message = json.loads(content.decode())
|
||||
|
||||
await self._handle_message(message)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error reading LSP response: {e}")
|
||||
|
||||
async def _handle_message(self, message: Dict[str, Any]):
|
||||
"""Handle an incoming message."""
|
||||
if "id" in message:
|
||||
# Response to a request
|
||||
request_id = message["id"]
|
||||
if request_id in self._pending_requests:
|
||||
future = self._pending_requests.pop(request_id)
|
||||
if "error" in message:
|
||||
future.set_exception(
|
||||
RuntimeError(message["error"].get("message", "Unknown error"))
|
||||
)
|
||||
else:
|
||||
future.set_result(message.get("result"))
|
||||
|
||||
elif "method" in message:
|
||||
# Notification from server
|
||||
method = message["method"]
|
||||
params = message.get("params", {})
|
||||
|
||||
if method == "textDocument/publishDiagnostics":
|
||||
uri = params.get("uri", "")
|
||||
diagnostics = [
|
||||
Diagnostic.from_dict(d)
|
||||
for d in params.get("diagnostics", [])
|
||||
]
|
||||
self._diagnostics[uri] = diagnostics
|
||||
@@ -0,0 +1,52 @@
|
||||
"""
|
||||
LSP Configuration for PraisonAI Agents.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional, Dict, List
|
||||
|
||||
|
||||
# Default language server commands
|
||||
DEFAULT_SERVERS = {
|
||||
"python": {
|
||||
"command": "pylsp",
|
||||
"args": [],
|
||||
},
|
||||
"javascript": {
|
||||
"command": "typescript-language-server",
|
||||
"args": ["--stdio"],
|
||||
},
|
||||
"typescript": {
|
||||
"command": "typescript-language-server",
|
||||
"args": ["--stdio"],
|
||||
},
|
||||
"rust": {
|
||||
"command": "rust-analyzer",
|
||||
"args": [],
|
||||
},
|
||||
"go": {
|
||||
"command": "gopls",
|
||||
"args": [],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class LSPConfig:
|
||||
"""Configuration for LSP client."""
|
||||
language: str
|
||||
command: Optional[str] = None
|
||||
args: List[str] = field(default_factory=list)
|
||||
root_uri: Optional[str] = None
|
||||
initialization_options: Dict = field(default_factory=dict)
|
||||
timeout: float = 30.0
|
||||
|
||||
def __post_init__(self):
|
||||
# Use default server if not specified
|
||||
if self.command is None:
|
||||
if self.language in DEFAULT_SERVERS:
|
||||
server = DEFAULT_SERVERS[self.language]
|
||||
self.command = server["command"]
|
||||
self.args = server["args"]
|
||||
else:
|
||||
raise ValueError(f"No default server for language: {self.language}")
|
||||
@@ -0,0 +1,162 @@
|
||||
"""
|
||||
LSP Types for PraisonAI Agents.
|
||||
|
||||
Defines the core types for Language Server Protocol integration.
|
||||
"""
|
||||
|
||||
from enum import IntEnum
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional, List, Dict, Any
|
||||
|
||||
|
||||
class DiagnosticSeverity(IntEnum):
|
||||
"""Severity of a diagnostic."""
|
||||
ERROR = 1
|
||||
WARNING = 2
|
||||
INFORMATION = 3
|
||||
HINT = 4
|
||||
|
||||
|
||||
@dataclass
|
||||
class Position:
|
||||
"""Position in a text document (0-indexed)."""
|
||||
line: int
|
||||
character: int
|
||||
|
||||
def to_dict(self) -> Dict[str, int]:
|
||||
return {"line": self.line, "character": self.character}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, int]) -> "Position":
|
||||
return cls(line=data["line"], character=data["character"])
|
||||
|
||||
|
||||
@dataclass
|
||||
class Range:
|
||||
"""Range in a text document."""
|
||||
start: Position
|
||||
end: Position
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {"start": self.start.to_dict(), "end": self.end.to_dict()}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> "Range":
|
||||
return cls(
|
||||
start=Position.from_dict(data["start"]),
|
||||
end=Position.from_dict(data["end"])
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Location:
|
||||
"""Location in a document."""
|
||||
uri: str
|
||||
range: Range
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {"uri": self.uri, "range": self.range.to_dict()}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> "Location":
|
||||
return cls(uri=data["uri"], range=Range.from_dict(data["range"]))
|
||||
|
||||
|
||||
@dataclass
|
||||
class Diagnostic:
|
||||
"""Represents a diagnostic (error, warning, etc.)."""
|
||||
range: Range
|
||||
message: str
|
||||
severity: DiagnosticSeverity = DiagnosticSeverity.ERROR
|
||||
code: Optional[str] = None
|
||||
source: Optional[str] = None
|
||||
related_information: List[Dict[str, Any]] = field(default_factory=list)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"range": self.range.to_dict(),
|
||||
"message": self.message,
|
||||
"severity": self.severity.value,
|
||||
"code": self.code,
|
||||
"source": self.source
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> "Diagnostic":
|
||||
return cls(
|
||||
range=Range.from_dict(data["range"]),
|
||||
message=data["message"],
|
||||
severity=DiagnosticSeverity(data.get("severity", 1)),
|
||||
code=data.get("code"),
|
||||
source=data.get("source")
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CompletionItem:
|
||||
"""Represents a completion item."""
|
||||
label: str
|
||||
kind: int = 1 # Text
|
||||
detail: Optional[str] = None
|
||||
documentation: Optional[str] = None
|
||||
insert_text: Optional[str] = None
|
||||
sort_text: Optional[str] = None
|
||||
filter_text: Optional[str] = None
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"label": self.label,
|
||||
"kind": self.kind,
|
||||
"detail": self.detail,
|
||||
"documentation": self.documentation,
|
||||
"insertText": self.insert_text
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> "CompletionItem":
|
||||
return cls(
|
||||
label=data["label"],
|
||||
kind=data.get("kind", 1),
|
||||
detail=data.get("detail"),
|
||||
documentation=data.get("documentation"),
|
||||
insert_text=data.get("insertText")
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class TextDocumentItem:
|
||||
"""Represents a text document."""
|
||||
uri: str
|
||||
language_id: str
|
||||
version: int
|
||||
text: str
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"uri": self.uri,
|
||||
"languageId": self.language_id,
|
||||
"version": self.version,
|
||||
"text": self.text
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class TextDocumentIdentifier:
|
||||
"""Identifies a text document."""
|
||||
uri: str
|
||||
|
||||
def to_dict(self) -> Dict[str, str]:
|
||||
return {"uri": self.uri}
|
||||
|
||||
|
||||
@dataclass
|
||||
class TextDocumentPositionParams:
|
||||
"""Parameters for text document position requests."""
|
||||
text_document: TextDocumentIdentifier
|
||||
position: Position
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"textDocument": self.text_document.to_dict(),
|
||||
"position": self.position.to_dict()
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
"""
|
||||
Observability interface for PraisonAI Agents.
|
||||
|
||||
This module provides the protocol and types for observability/tracing.
|
||||
Implementations are provided by the wrapper layer (praisonai_tools.observability).
|
||||
|
||||
Usage (simplest - recommended):
|
||||
from praisonaiagents import Agent, obs
|
||||
|
||||
agent = Agent(
|
||||
name="Assistant",
|
||||
observability=obs.auto(), # Auto-detect from env vars
|
||||
)
|
||||
agent.chat("Hello!") # Auto-traces to configured provider
|
||||
|
||||
Alternative (explicit provider):
|
||||
observability=obs.langfuse() # Langfuse
|
||||
observability=obs.langsmith() # LangSmith
|
||||
observability=obs.agentops() # AgentOps
|
||||
observability=obs.arize() # Arize Phoenix
|
||||
observability=obs.datadog() # Datadog
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, List, Optional, Protocol, runtime_checkable
|
||||
from dataclasses import dataclass, field
|
||||
import time
|
||||
|
||||
|
||||
@dataclass
|
||||
class Span:
|
||||
"""Represents a span in a trace."""
|
||||
span_id: str
|
||||
trace_id: str
|
||||
name: str
|
||||
parent_span_id: Optional[str] = None
|
||||
start_time: float = field(default_factory=time.time)
|
||||
end_time: Optional[float] = None
|
||||
attributes: Dict[str, Any] = field(default_factory=dict)
|
||||
status: str = "ok"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Trace:
|
||||
"""Represents a complete trace."""
|
||||
trace_id: str
|
||||
session_id: Optional[str] = None
|
||||
agent_name: Optional[str] = None
|
||||
user_id: Optional[str] = None
|
||||
start_time: float = field(default_factory=time.time)
|
||||
end_time: Optional[float] = None
|
||||
spans: List[Span] = field(default_factory=list)
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
status: str = "ok"
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class ObservabilityAdapter(Protocol):
|
||||
"""Protocol for observability adapters."""
|
||||
|
||||
def on_trace_start(
|
||||
self,
|
||||
trace_id: str,
|
||||
session_id: Optional[str] = None,
|
||||
agent_name: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> None:
|
||||
"""Called when a trace starts."""
|
||||
...
|
||||
|
||||
def on_trace_end(
|
||||
self,
|
||||
trace_id: str,
|
||||
status: str = "ok",
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> None:
|
||||
"""Called when a trace ends."""
|
||||
...
|
||||
|
||||
def on_span_start(
|
||||
self,
|
||||
span_id: str,
|
||||
trace_id: str,
|
||||
name: str,
|
||||
parent_span_id: Optional[str] = None,
|
||||
attributes: Optional[Dict[str, Any]] = None,
|
||||
) -> None:
|
||||
"""Called when a span starts."""
|
||||
...
|
||||
|
||||
def on_span_end(
|
||||
self,
|
||||
span_id: str,
|
||||
status: str = "ok",
|
||||
attributes: Optional[Dict[str, Any]] = None,
|
||||
) -> None:
|
||||
"""Called when a span ends."""
|
||||
...
|
||||
|
||||
def on_llm_call(
|
||||
self,
|
||||
span_id: str,
|
||||
model: str,
|
||||
messages: List[Dict[str, Any]],
|
||||
response: Optional[str] = None,
|
||||
tokens: Optional[Dict[str, int]] = None,
|
||||
latency_ms: Optional[float] = None,
|
||||
) -> None:
|
||||
"""Called when an LLM call is made."""
|
||||
...
|
||||
|
||||
def on_tool_call(
|
||||
self,
|
||||
span_id: str,
|
||||
tool_name: str,
|
||||
args: Dict[str, Any],
|
||||
result: Any,
|
||||
latency_ms: Optional[float] = None,
|
||||
) -> None:
|
||||
"""Called when a tool is invoked."""
|
||||
...
|
||||
|
||||
def flush(self) -> None:
|
||||
"""Flush any pending data."""
|
||||
...
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close the adapter and release resources."""
|
||||
...
|
||||
|
||||
|
||||
class _LazyObsModule:
|
||||
"""
|
||||
Lazy proxy for observability backends.
|
||||
|
||||
Allows `from praisonaiagents import obs` without importing heavy deps.
|
||||
Actual provider classes are loaded only when accessed.
|
||||
"""
|
||||
|
||||
_PROVIDERS = {
|
||||
"langfuse": "praisonai_tools.observability.providers.langfuse_provider",
|
||||
"langsmith": "praisonai_tools.observability.providers.langsmith_provider",
|
||||
"agentops": "praisonai_tools.observability.providers.agentops_provider",
|
||||
"arize": "praisonai_tools.observability.providers.arize_phoenix_provider",
|
||||
"phoenix": "praisonai_tools.observability.providers.arize_phoenix_provider",
|
||||
"datadog": "praisonai_tools.observability.providers.datadog_provider",
|
||||
"mlflow": "praisonai_tools.observability.providers.mlflow_provider",
|
||||
"openlit": "praisonai_tools.observability.providers.openlit_provider",
|
||||
"opik": "praisonai_tools.observability.providers.opik_provider",
|
||||
"portkey": "praisonai_tools.observability.providers.portkey_provider",
|
||||
"traceloop": "praisonai_tools.observability.providers.traceloop_provider",
|
||||
"weave": "praisonai_tools.observability.providers.weave_provider",
|
||||
"braintrust": "praisonai_tools.observability.providers.braintrust_provider",
|
||||
"langtrace": "praisonai_tools.observability.providers.langtrace_provider",
|
||||
"langwatch": "praisonai_tools.observability.providers.langwatch_provider",
|
||||
"maxim": "praisonai_tools.observability.providers.maxim_provider",
|
||||
}
|
||||
|
||||
def __getattr__(self, name: str):
|
||||
if name in self._PROVIDERS:
|
||||
return self._create_provider_factory(name)
|
||||
raise AttributeError(f"module 'obs' has no attribute {name!r}")
|
||||
|
||||
def _create_provider_factory(self, provider_name: str):
|
||||
"""Create a factory function for a provider."""
|
||||
def factory(**kwargs):
|
||||
try:
|
||||
import importlib
|
||||
module = importlib.import_module(self._PROVIDERS[provider_name])
|
||||
# Get the provider class (naming convention: XxxProvider)
|
||||
class_name = f"{provider_name.title().replace('_', '')}Provider"
|
||||
provider_cls = getattr(module, class_name, None)
|
||||
if provider_cls:
|
||||
return provider_cls(**kwargs)
|
||||
# Fallback: try to find any class ending with Provider
|
||||
for attr_name in dir(module):
|
||||
if attr_name.endswith("Provider"):
|
||||
return getattr(module, attr_name)(**kwargs)
|
||||
raise AttributeError(f"No provider class found in {module}")
|
||||
except ImportError as e:
|
||||
raise ImportError(
|
||||
f"Observability provider '{provider_name}' requires praisonai-tools. "
|
||||
f"Install with: pip install praisonai-tools\n"
|
||||
f"Original error: {e}"
|
||||
) from e
|
||||
return factory
|
||||
|
||||
def auto(self, **kwargs):
|
||||
"""
|
||||
Auto-detect observability provider from environment variables.
|
||||
|
||||
Checks for common env vars like LANGFUSE_PUBLIC_KEY, LANGSMITH_API_KEY, etc.
|
||||
Returns None if no provider is detected.
|
||||
"""
|
||||
import os
|
||||
|
||||
# Check for common provider env vars
|
||||
provider_env_map = {
|
||||
"LANGFUSE_PUBLIC_KEY": "langfuse",
|
||||
"LANGSMITH_API_KEY": "langsmith",
|
||||
"AGENTOPS_API_KEY": "agentops",
|
||||
"ARIZE_API_KEY": "arize",
|
||||
"DATADOG_API_KEY": "datadog",
|
||||
"MLFLOW_TRACKING_URI": "mlflow",
|
||||
"OPENLIT_API_KEY": "openlit",
|
||||
"OPIK_API_KEY": "opik",
|
||||
"PORTKEY_API_KEY": "portkey",
|
||||
"TRACELOOP_API_KEY": "traceloop",
|
||||
"WANDB_API_KEY": "weave",
|
||||
"BRAINTRUST_API_KEY": "braintrust",
|
||||
}
|
||||
|
||||
for env_var, provider in provider_env_map.items():
|
||||
if os.getenv(env_var):
|
||||
return self._create_provider_factory(provider)(**kwargs)
|
||||
|
||||
return None
|
||||
|
||||
def __repr__(self):
|
||||
return "<module 'praisonaiagents.obs' (lazy observability backends)>"
|
||||
|
||||
def __call__(self, provider: str = "auto", **kwargs):
|
||||
"""
|
||||
Shortcut: obs("langfuse") or obs() for auto-detection.
|
||||
"""
|
||||
if provider == "auto":
|
||||
return self.auto(**kwargs)
|
||||
return self._create_provider_factory(provider)(**kwargs)
|
||||
|
||||
|
||||
# Singleton lazy module instance
|
||||
obs = _LazyObsModule()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ObservabilityAdapter",
|
||||
"Span",
|
||||
"Trace",
|
||||
"obs",
|
||||
]
|
||||
@@ -0,0 +1,77 @@
|
||||
"""
|
||||
Exec Policy Engine for PraisonAI Agents.
|
||||
|
||||
Provides policy-based execution control:
|
||||
- Define rules for what agents can/cannot do
|
||||
- Tool execution policies
|
||||
- Resource access control
|
||||
- Rate limiting and quotas
|
||||
|
||||
Zero Performance Impact:
|
||||
- All imports are lazy loaded via __getattr__
|
||||
- Policies only evaluated when enabled
|
||||
- No overhead when no policies defined
|
||||
|
||||
Usage:
|
||||
from praisonaiagents.policy import PolicyEngine, Policy, PolicyRule
|
||||
|
||||
# Create a policy engine
|
||||
engine = PolicyEngine()
|
||||
|
||||
# Add a policy
|
||||
policy = Policy(
|
||||
name="no_delete",
|
||||
rules=[
|
||||
PolicyRule(
|
||||
action="deny",
|
||||
resource="tool:delete_*",
|
||||
reason="Delete operations are not allowed"
|
||||
)
|
||||
]
|
||||
)
|
||||
engine.add_policy(policy)
|
||||
|
||||
# Check if action is allowed
|
||||
result = engine.check("tool:delete_file", context={})
|
||||
"""
|
||||
|
||||
__all__ = [
|
||||
# Core classes
|
||||
"PolicyEngine",
|
||||
"Policy",
|
||||
"PolicyRule",
|
||||
# Result types
|
||||
"PolicyResult",
|
||||
"PolicyAction",
|
||||
# Configuration
|
||||
"PolicyConfig",
|
||||
# Convenience functions
|
||||
"create_deny_tools_policy",
|
||||
"create_allow_tools_policy",
|
||||
"create_read_only_policy",
|
||||
]
|
||||
|
||||
|
||||
def __getattr__(name: str):
|
||||
"""Lazy load module components to avoid import overhead."""
|
||||
if name == "PolicyEngine":
|
||||
from .engine import PolicyEngine
|
||||
return PolicyEngine
|
||||
|
||||
if name in ("Policy", "PolicyRule"):
|
||||
from .policy import Policy, PolicyRule
|
||||
return locals()[name]
|
||||
|
||||
if name in ("PolicyResult", "PolicyAction"):
|
||||
from .types import PolicyResult, PolicyAction
|
||||
return locals()[name]
|
||||
|
||||
if name == "PolicyConfig":
|
||||
from .config import PolicyConfig
|
||||
return PolicyConfig
|
||||
|
||||
if name in ("create_deny_tools_policy", "create_allow_tools_policy", "create_read_only_policy"):
|
||||
from .engine import create_deny_tools_policy, create_allow_tools_policy, create_read_only_policy
|
||||
return locals()[name]
|
||||
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
@@ -0,0 +1,15 @@
|
||||
"""
|
||||
Policy Configuration for PraisonAI Agents.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional, List
|
||||
|
||||
|
||||
@dataclass
|
||||
class PolicyConfig:
|
||||
"""Configuration for the policy engine."""
|
||||
enabled: bool = True
|
||||
default_action: str = "allow" # Default action when no policy matches
|
||||
log_decisions: bool = True
|
||||
strict_mode: bool = False # If True, deny when no policy matches
|
||||
@@ -0,0 +1,307 @@
|
||||
"""
|
||||
Policy Engine for PraisonAI Agents.
|
||||
|
||||
Manages and evaluates policies for execution control.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Optional, List, Dict, Any
|
||||
|
||||
from .types import PolicyAction, PolicyResult
|
||||
from .policy import Policy, PolicyRule
|
||||
from .config import PolicyConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PolicyEngine:
|
||||
"""
|
||||
Policy Engine for execution control.
|
||||
|
||||
Manages policies and evaluates them against resources
|
||||
to determine what actions are allowed.
|
||||
|
||||
Example:
|
||||
engine = PolicyEngine()
|
||||
|
||||
# Add a policy to block delete operations
|
||||
engine.add_policy(Policy(
|
||||
name="no_delete",
|
||||
rules=[
|
||||
PolicyRule(
|
||||
action=PolicyAction.DENY,
|
||||
resource="tool:delete_*",
|
||||
reason="Delete operations are not allowed"
|
||||
)
|
||||
]
|
||||
))
|
||||
|
||||
# Check if action is allowed
|
||||
result = engine.check("tool:delete_file", {})
|
||||
if not result.allowed:
|
||||
print(f"Denied: {result.reason}")
|
||||
"""
|
||||
|
||||
def __init__(self, config: Optional[PolicyConfig] = None):
|
||||
"""
|
||||
Initialize the policy engine.
|
||||
|
||||
Args:
|
||||
config: Engine configuration
|
||||
"""
|
||||
self.config = config or PolicyConfig()
|
||||
self._policies: Dict[str, Policy] = {}
|
||||
|
||||
@property
|
||||
def policies(self) -> List[Policy]:
|
||||
"""Get all policies sorted by priority."""
|
||||
return sorted(
|
||||
self._policies.values(),
|
||||
key=lambda p: p.priority,
|
||||
reverse=True
|
||||
)
|
||||
|
||||
def add_policy(self, policy: Policy):
|
||||
"""
|
||||
Add a policy to the engine.
|
||||
|
||||
Args:
|
||||
policy: Policy to add
|
||||
"""
|
||||
self._policies[policy.name] = policy
|
||||
logger.debug(f"Added policy: {policy.name}")
|
||||
|
||||
def remove_policy(self, name: str) -> bool:
|
||||
"""
|
||||
Remove a policy by name.
|
||||
|
||||
Args:
|
||||
name: Policy name
|
||||
|
||||
Returns:
|
||||
True if removed, False if not found
|
||||
"""
|
||||
if name in self._policies:
|
||||
del self._policies[name]
|
||||
logger.debug(f"Removed policy: {name}")
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_policy(self, name: str) -> Optional[Policy]:
|
||||
"""Get a policy by name."""
|
||||
return self._policies.get(name)
|
||||
|
||||
def enable_policy(self, name: str) -> bool:
|
||||
"""Enable a policy."""
|
||||
policy = self._policies.get(name)
|
||||
if policy:
|
||||
policy.enabled = True
|
||||
return True
|
||||
return False
|
||||
|
||||
def disable_policy(self, name: str) -> bool:
|
||||
"""Disable a policy."""
|
||||
policy = self._policies.get(name)
|
||||
if policy:
|
||||
policy.enabled = False
|
||||
return True
|
||||
return False
|
||||
|
||||
def check(
|
||||
self,
|
||||
resource: str,
|
||||
context: Optional[Dict[str, Any]] = None
|
||||
) -> PolicyResult:
|
||||
"""
|
||||
Check if an action is allowed.
|
||||
|
||||
Args:
|
||||
resource: Resource identifier (e.g., "tool:read_file")
|
||||
context: Additional context for policy evaluation
|
||||
|
||||
Returns:
|
||||
PolicyResult indicating if action is allowed
|
||||
"""
|
||||
if not self.config.enabled:
|
||||
return PolicyResult.allow("Policy engine disabled")
|
||||
|
||||
context = context or {}
|
||||
|
||||
# Evaluate policies in priority order
|
||||
for policy in self.policies:
|
||||
result = policy.evaluate(resource, context)
|
||||
if result is not None:
|
||||
if self.config.log_decisions:
|
||||
logger.info(
|
||||
f"Policy decision: {result.action.value} for {resource} "
|
||||
f"(policy: {result.policy_name}, reason: {result.reason})"
|
||||
)
|
||||
return result
|
||||
|
||||
# No policy matched - use default action
|
||||
if self.config.strict_mode:
|
||||
return PolicyResult.deny(
|
||||
"No policy matched and strict mode is enabled"
|
||||
)
|
||||
|
||||
return PolicyResult.allow("No policy matched")
|
||||
|
||||
def check_tool(
|
||||
self,
|
||||
tool_name: str,
|
||||
tool_input: Optional[Dict[str, Any]] = None
|
||||
) -> PolicyResult:
|
||||
"""
|
||||
Check if a tool execution is allowed.
|
||||
|
||||
Args:
|
||||
tool_name: Name of the tool
|
||||
tool_input: Tool input parameters
|
||||
|
||||
Returns:
|
||||
PolicyResult
|
||||
"""
|
||||
return self.check(
|
||||
f"tool:{tool_name}",
|
||||
{"tool_name": tool_name, "tool_input": tool_input or {}}
|
||||
)
|
||||
|
||||
def check_file(
|
||||
self,
|
||||
operation: str,
|
||||
file_path: str
|
||||
) -> PolicyResult:
|
||||
"""
|
||||
Check if a file operation is allowed.
|
||||
|
||||
Args:
|
||||
operation: Operation type (read, write, delete)
|
||||
file_path: Path to the file
|
||||
|
||||
Returns:
|
||||
PolicyResult
|
||||
"""
|
||||
return self.check(
|
||||
f"file:{operation}",
|
||||
{"operation": operation, "file_path": file_path}
|
||||
)
|
||||
|
||||
def clear(self):
|
||||
"""Remove all policies."""
|
||||
self._policies.clear()
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert engine state to dictionary."""
|
||||
return {
|
||||
"config": {
|
||||
"enabled": self.config.enabled,
|
||||
"default_action": self.config.default_action,
|
||||
"strict_mode": self.config.strict_mode
|
||||
},
|
||||
"policies": [p.to_dict() for p in self.policies]
|
||||
}
|
||||
|
||||
def load_from_dict(self, data: Dict[str, Any]):
|
||||
"""Load policies from a dictionary."""
|
||||
for policy_data in data.get("policies", []):
|
||||
policy = Policy.from_dict(policy_data)
|
||||
self.add_policy(policy)
|
||||
|
||||
|
||||
# Convenience functions for common policies
|
||||
|
||||
def create_deny_tools_policy(
|
||||
tool_patterns: List[str],
|
||||
name: str = "deny_tools",
|
||||
reason: str = "Tool not allowed"
|
||||
) -> Policy:
|
||||
"""
|
||||
Create a policy that denies specific tools.
|
||||
|
||||
Args:
|
||||
tool_patterns: List of tool name patterns to deny
|
||||
name: Policy name
|
||||
reason: Reason for denial
|
||||
|
||||
Returns:
|
||||
Policy
|
||||
"""
|
||||
rules = [
|
||||
PolicyRule(
|
||||
action=PolicyAction.DENY,
|
||||
resource=f"tool:{pattern}",
|
||||
reason=reason,
|
||||
name=f"deny_{pattern}"
|
||||
)
|
||||
for pattern in tool_patterns
|
||||
]
|
||||
|
||||
return Policy(name=name, rules=rules)
|
||||
|
||||
|
||||
def create_allow_tools_policy(
|
||||
tool_patterns: List[str],
|
||||
name: str = "allow_tools"
|
||||
) -> Policy:
|
||||
"""
|
||||
Create a policy that allows specific tools.
|
||||
|
||||
Args:
|
||||
tool_patterns: List of tool name patterns to allow
|
||||
name: Policy name
|
||||
|
||||
Returns:
|
||||
Policy
|
||||
"""
|
||||
rules = [
|
||||
PolicyRule(
|
||||
action=PolicyAction.ALLOW,
|
||||
resource=f"tool:{pattern}",
|
||||
name=f"allow_{pattern}"
|
||||
)
|
||||
for pattern in tool_patterns
|
||||
]
|
||||
|
||||
return Policy(name=name, rules=rules, priority=10)
|
||||
|
||||
|
||||
def create_read_only_policy(name: str = "read_only") -> Policy:
|
||||
"""
|
||||
Create a read-only policy that denies write operations.
|
||||
|
||||
Args:
|
||||
name: Policy name
|
||||
|
||||
Returns:
|
||||
Policy
|
||||
"""
|
||||
return Policy(
|
||||
name=name,
|
||||
rules=[
|
||||
PolicyRule(
|
||||
action=PolicyAction.DENY,
|
||||
resource="file:write",
|
||||
reason="Write operations not allowed in read-only mode",
|
||||
name="deny_write"
|
||||
),
|
||||
PolicyRule(
|
||||
action=PolicyAction.DENY,
|
||||
resource="file:delete",
|
||||
reason="Delete operations not allowed in read-only mode",
|
||||
name="deny_delete"
|
||||
),
|
||||
PolicyRule(
|
||||
action=PolicyAction.DENY,
|
||||
resource="tool:*_write*",
|
||||
reason="Write tools not allowed in read-only mode",
|
||||
name="deny_write_tools"
|
||||
),
|
||||
PolicyRule(
|
||||
action=PolicyAction.DENY,
|
||||
resource="tool:*_delete*",
|
||||
reason="Delete tools not allowed in read-only mode",
|
||||
name="deny_delete_tools"
|
||||
),
|
||||
],
|
||||
priority=100
|
||||
)
|
||||
@@ -0,0 +1,174 @@
|
||||
"""
|
||||
Policy and PolicyRule for PraisonAI Agents.
|
||||
|
||||
Defines policies and rules for execution control.
|
||||
"""
|
||||
|
||||
import re
|
||||
import fnmatch
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional, Dict, Any, List, Callable
|
||||
|
||||
from .types import PolicyAction, PolicyResult
|
||||
|
||||
|
||||
@dataclass
|
||||
class PolicyRule:
|
||||
"""
|
||||
A single rule within a policy.
|
||||
|
||||
Rules match against resources and contexts to determine
|
||||
what action to take.
|
||||
"""
|
||||
action: PolicyAction
|
||||
resource: Optional[str] = None # Pattern to match (supports wildcards)
|
||||
condition: Optional[Callable[[Dict[str, Any]], bool]] = None
|
||||
reason: Optional[str] = None
|
||||
name: Optional[str] = None
|
||||
priority: int = 0 # Higher priority rules are evaluated first
|
||||
|
||||
def matches(self, resource: str, context: Dict[str, Any]) -> bool:
|
||||
"""
|
||||
Check if this rule matches the given resource and context.
|
||||
|
||||
Args:
|
||||
resource: Resource identifier (e.g., "tool:read_file")
|
||||
context: Additional context for the check
|
||||
|
||||
Returns:
|
||||
True if the rule matches
|
||||
"""
|
||||
# Check resource pattern
|
||||
if self.resource:
|
||||
if not self._match_pattern(self.resource, resource):
|
||||
return False
|
||||
|
||||
# Check condition
|
||||
if self.condition:
|
||||
try:
|
||||
if not self.condition(context):
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def _match_pattern(self, pattern: str, value: str) -> bool:
|
||||
"""Match a pattern against a value (supports wildcards)."""
|
||||
# Convert glob pattern to regex
|
||||
if "*" in pattern or "?" in pattern:
|
||||
return fnmatch.fnmatch(value, pattern)
|
||||
return pattern == value
|
||||
|
||||
def evaluate(self, resource: str, context: Dict[str, Any]) -> Optional[PolicyResult]:
|
||||
"""
|
||||
Evaluate this rule against a resource.
|
||||
|
||||
Args:
|
||||
resource: Resource identifier
|
||||
context: Additional context
|
||||
|
||||
Returns:
|
||||
PolicyResult if rule matches, None otherwise
|
||||
"""
|
||||
if not self.matches(resource, context):
|
||||
return None
|
||||
|
||||
return PolicyResult(
|
||||
allowed=self.action in (PolicyAction.ALLOW, PolicyAction.LOG),
|
||||
action=self.action,
|
||||
rule_name=self.name,
|
||||
reason=self.reason
|
||||
)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary."""
|
||||
return {
|
||||
"action": self.action.value,
|
||||
"resource": self.resource,
|
||||
"reason": self.reason,
|
||||
"name": self.name,
|
||||
"priority": self.priority
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class Policy:
|
||||
"""
|
||||
A collection of rules that define execution policy.
|
||||
|
||||
Policies are evaluated in order, with higher priority rules
|
||||
taking precedence.
|
||||
"""
|
||||
name: str
|
||||
rules: List[PolicyRule] = field(default_factory=list)
|
||||
description: Optional[str] = None
|
||||
enabled: bool = True
|
||||
priority: int = 0 # Higher priority policies are evaluated first
|
||||
|
||||
def add_rule(self, rule: PolicyRule):
|
||||
"""Add a rule to this policy."""
|
||||
self.rules.append(rule)
|
||||
# Sort by priority (descending)
|
||||
self.rules.sort(key=lambda r: r.priority, reverse=True)
|
||||
|
||||
def remove_rule(self, rule_name: str) -> bool:
|
||||
"""Remove a rule by name."""
|
||||
for i, rule in enumerate(self.rules):
|
||||
if rule.name == rule_name:
|
||||
self.rules.pop(i)
|
||||
return True
|
||||
return False
|
||||
|
||||
def evaluate(self, resource: str, context: Dict[str, Any]) -> Optional[PolicyResult]:
|
||||
"""
|
||||
Evaluate this policy against a resource.
|
||||
|
||||
Args:
|
||||
resource: Resource identifier
|
||||
context: Additional context
|
||||
|
||||
Returns:
|
||||
PolicyResult if any rule matches, None otherwise
|
||||
"""
|
||||
if not self.enabled:
|
||||
return None
|
||||
|
||||
for rule in self.rules:
|
||||
result = rule.evaluate(resource, context)
|
||||
if result is not None:
|
||||
result.policy_name = self.name
|
||||
return result
|
||||
|
||||
return None
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary."""
|
||||
return {
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"enabled": self.enabled,
|
||||
"priority": self.priority,
|
||||
"rules": [r.to_dict() for r in self.rules]
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> "Policy":
|
||||
"""Create a policy from a dictionary."""
|
||||
rules = []
|
||||
for rule_data in data.get("rules", []):
|
||||
rules.append(PolicyRule(
|
||||
action=PolicyAction(rule_data["action"]),
|
||||
resource=rule_data.get("resource"),
|
||||
reason=rule_data.get("reason"),
|
||||
name=rule_data.get("name"),
|
||||
priority=rule_data.get("priority", 0)
|
||||
))
|
||||
|
||||
return cls(
|
||||
name=data["name"],
|
||||
rules=rules,
|
||||
description=data.get("description"),
|
||||
enabled=data.get("enabled", True),
|
||||
priority=data.get("priority", 0)
|
||||
)
|
||||
@@ -0,0 +1,60 @@
|
||||
"""
|
||||
Policy Types for PraisonAI Agents.
|
||||
|
||||
Defines the core types for the policy engine.
|
||||
"""
|
||||
|
||||
from enum import Enum
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional, Dict, Any, List
|
||||
|
||||
|
||||
class PolicyAction(str, Enum):
|
||||
"""Action to take when a policy matches."""
|
||||
ALLOW = "allow"
|
||||
DENY = "deny"
|
||||
ASK = "ask" # Require user confirmation
|
||||
LOG = "log" # Allow but log the action
|
||||
RATE_LIMIT = "rate_limit"
|
||||
|
||||
|
||||
@dataclass
|
||||
class PolicyResult:
|
||||
"""Result of a policy check."""
|
||||
allowed: bool
|
||||
action: PolicyAction
|
||||
policy_name: Optional[str] = None
|
||||
rule_name: Optional[str] = None
|
||||
reason: Optional[str] = None
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
@classmethod
|
||||
def allow(cls, reason: Optional[str] = None) -> "PolicyResult":
|
||||
"""Create an allow result."""
|
||||
return cls(allowed=True, action=PolicyAction.ALLOW, reason=reason)
|
||||
|
||||
@classmethod
|
||||
def deny(cls, reason: str, policy_name: Optional[str] = None) -> "PolicyResult":
|
||||
"""Create a deny result."""
|
||||
return cls(
|
||||
allowed=False,
|
||||
action=PolicyAction.DENY,
|
||||
policy_name=policy_name,
|
||||
reason=reason
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def ask(cls, reason: str) -> "PolicyResult":
|
||||
"""Create an ask result (requires confirmation)."""
|
||||
return cls(allowed=False, action=PolicyAction.ASK, reason=reason)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary."""
|
||||
return {
|
||||
"allowed": self.allowed,
|
||||
"action": self.action.value,
|
||||
"policy_name": self.policy_name,
|
||||
"rule_name": self.rule_name,
|
||||
"reason": self.reason,
|
||||
"metadata": self.metadata
|
||||
}
|
||||
@@ -28,20 +28,31 @@ def format_skill_for_prompt(skill: SkillMetadata) -> str:
|
||||
</skill>"""
|
||||
|
||||
|
||||
def generate_skills_xml(skills: List[SkillMetadata]) -> str:
|
||||
def generate_skills_xml(skills: List[SkillMetadata], working_directory: str = None) -> str:
|
||||
"""Generate XML block for available skills.
|
||||
|
||||
Args:
|
||||
skills: List of SkillMetadata instances
|
||||
working_directory: Current working directory for path resolution
|
||||
|
||||
Returns:
|
||||
XML string with <available_skills> block
|
||||
"""
|
||||
import os
|
||||
cwd = working_directory or os.getcwd()
|
||||
|
||||
if not skills:
|
||||
return "<available_skills>\n</available_skills>"
|
||||
return f"""<available_skills>
|
||||
<working_directory>{html.escape(cwd)}</working_directory>
|
||||
<note>When using run_skill_script, pass file paths relative to the working directory or as absolute paths. The tool will resolve relative paths automatically.</note>
|
||||
</available_skills>"""
|
||||
|
||||
skill_entries = "\n".join(format_skill_for_prompt(s) for s in skills)
|
||||
return f"<available_skills>\n{skill_entries}\n</available_skills>"
|
||||
return f"""<available_skills>
|
||||
<working_directory>{html.escape(cwd)}</working_directory>
|
||||
<note>When using run_skill_script, pass file paths relative to the working directory or as absolute paths. The tool will resolve relative paths automatically.</note>
|
||||
{skill_entries}
|
||||
</available_skills>"""
|
||||
|
||||
|
||||
def to_prompt(skill_dirs: List[Path]) -> str:
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
"""
|
||||
Extended Thinking Budgets Module for PraisonAI Agents.
|
||||
|
||||
Provides configurable thinking budgets for LLM reasoning:
|
||||
- Token budgets for extended thinking
|
||||
- Time budgets for reasoning
|
||||
- Adaptive budget allocation
|
||||
- Budget tracking and reporting
|
||||
|
||||
Zero Performance Impact:
|
||||
- All imports are lazy loaded via __getattr__
|
||||
- Budgets only applied when configured
|
||||
- No overhead when not in use
|
||||
|
||||
Usage:
|
||||
from praisonaiagents.thinking import ThinkingBudget, ThinkingConfig
|
||||
|
||||
# Create a thinking budget
|
||||
budget = ThinkingBudget(
|
||||
max_tokens=16000,
|
||||
max_time_seconds=60,
|
||||
adaptive=True
|
||||
)
|
||||
|
||||
# Apply to agent
|
||||
agent = Agent(
|
||||
instructions="...",
|
||||
thinking_budget=budget
|
||||
)
|
||||
"""
|
||||
|
||||
__all__ = [
|
||||
# Core classes
|
||||
"ThinkingBudget",
|
||||
"ThinkingConfig",
|
||||
# Tracking
|
||||
"ThinkingUsage",
|
||||
"ThinkingTracker",
|
||||
]
|
||||
|
||||
|
||||
def __getattr__(name: str):
|
||||
"""Lazy load module components to avoid import overhead."""
|
||||
if name == "ThinkingBudget":
|
||||
from .budget import ThinkingBudget
|
||||
return ThinkingBudget
|
||||
|
||||
if name == "ThinkingConfig":
|
||||
from .config import ThinkingConfig
|
||||
return ThinkingConfig
|
||||
|
||||
if name == "ThinkingUsage":
|
||||
from .tracker import ThinkingUsage
|
||||
return ThinkingUsage
|
||||
|
||||
if name == "ThinkingTracker":
|
||||
from .tracker import ThinkingTracker
|
||||
return ThinkingTracker
|
||||
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
@@ -0,0 +1,131 @@
|
||||
"""
|
||||
Thinking Budget for PraisonAI Agents.
|
||||
|
||||
Defines budget constraints for extended thinking.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional, Dict, Any
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class BudgetLevel(str, Enum):
|
||||
"""Predefined budget levels."""
|
||||
MINIMAL = "minimal" # 2000 tokens
|
||||
LOW = "low" # 4000 tokens
|
||||
MEDIUM = "medium" # 8000 tokens
|
||||
HIGH = "high" # 16000 tokens
|
||||
MAXIMUM = "maximum" # 32000 tokens
|
||||
|
||||
|
||||
# Token allocations for each level
|
||||
BUDGET_TOKENS = {
|
||||
BudgetLevel.MINIMAL: 2000,
|
||||
BudgetLevel.LOW: 4000,
|
||||
BudgetLevel.MEDIUM: 8000,
|
||||
BudgetLevel.HIGH: 16000,
|
||||
BudgetLevel.MAXIMUM: 32000,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ThinkingBudget:
|
||||
"""
|
||||
Budget constraints for extended thinking.
|
||||
|
||||
Controls how much thinking/reasoning the LLM can do
|
||||
before producing a response.
|
||||
"""
|
||||
max_tokens: int = 8000
|
||||
max_time_seconds: Optional[float] = None
|
||||
adaptive: bool = True
|
||||
level: Optional[BudgetLevel] = None
|
||||
|
||||
# Adaptive settings
|
||||
min_tokens: int = 1000
|
||||
complexity_multiplier: float = 1.0
|
||||
|
||||
def __post_init__(self):
|
||||
# If level is specified, use its token allocation
|
||||
if self.level:
|
||||
self.max_tokens = BUDGET_TOKENS.get(self.level, self.max_tokens)
|
||||
|
||||
@classmethod
|
||||
def from_level(cls, level: BudgetLevel, **kwargs) -> "ThinkingBudget":
|
||||
"""Create a budget from a predefined level."""
|
||||
return cls(
|
||||
max_tokens=BUDGET_TOKENS[level],
|
||||
level=level,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def minimal(cls) -> "ThinkingBudget":
|
||||
"""Create a minimal budget."""
|
||||
return cls.from_level(BudgetLevel.MINIMAL)
|
||||
|
||||
@classmethod
|
||||
def low(cls) -> "ThinkingBudget":
|
||||
"""Create a low budget."""
|
||||
return cls.from_level(BudgetLevel.LOW)
|
||||
|
||||
@classmethod
|
||||
def medium(cls) -> "ThinkingBudget":
|
||||
"""Create a medium budget."""
|
||||
return cls.from_level(BudgetLevel.MEDIUM)
|
||||
|
||||
@classmethod
|
||||
def high(cls) -> "ThinkingBudget":
|
||||
"""Create a high budget."""
|
||||
return cls.from_level(BudgetLevel.HIGH)
|
||||
|
||||
@classmethod
|
||||
def maximum(cls) -> "ThinkingBudget":
|
||||
"""Create a maximum budget."""
|
||||
return cls.from_level(BudgetLevel.MAXIMUM)
|
||||
|
||||
def get_tokens_for_complexity(self, complexity: float) -> int:
|
||||
"""
|
||||
Get token budget based on task complexity.
|
||||
|
||||
Args:
|
||||
complexity: Complexity score (0.0 to 1.0)
|
||||
|
||||
Returns:
|
||||
Adjusted token budget
|
||||
"""
|
||||
if not self.adaptive:
|
||||
return self.max_tokens
|
||||
|
||||
# Scale between min and max based on complexity
|
||||
range_tokens = self.max_tokens - self.min_tokens
|
||||
adjusted = self.min_tokens + int(range_tokens * complexity * self.complexity_multiplier)
|
||||
|
||||
return min(adjusted, self.max_tokens)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary."""
|
||||
return {
|
||||
"max_tokens": self.max_tokens,
|
||||
"max_time_seconds": self.max_time_seconds,
|
||||
"adaptive": self.adaptive,
|
||||
"level": self.level.value if self.level else None,
|
||||
"min_tokens": self.min_tokens,
|
||||
"complexity_multiplier": self.complexity_multiplier
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> "ThinkingBudget":
|
||||
"""Create from dictionary."""
|
||||
level = None
|
||||
if data.get("level"):
|
||||
level = BudgetLevel(data["level"])
|
||||
|
||||
return cls(
|
||||
max_tokens=data.get("max_tokens", 8000),
|
||||
max_time_seconds=data.get("max_time_seconds"),
|
||||
adaptive=data.get("adaptive", True),
|
||||
level=level,
|
||||
min_tokens=data.get("min_tokens", 1000),
|
||||
complexity_multiplier=data.get("complexity_multiplier", 1.0)
|
||||
)
|
||||
@@ -0,0 +1,17 @@
|
||||
"""
|
||||
Thinking Configuration for PraisonAI Agents.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class ThinkingConfig:
|
||||
"""Configuration for extended thinking."""
|
||||
enabled: bool = True
|
||||
default_budget_tokens: int = 8000
|
||||
max_budget_tokens: int = 32000
|
||||
default_time_seconds: Optional[float] = None
|
||||
adaptive: bool = True # Adjust budget based on task complexity
|
||||
log_thinking: bool = False # Log thinking process
|
||||
@@ -0,0 +1,173 @@
|
||||
"""
|
||||
Thinking Tracker for PraisonAI Agents.
|
||||
|
||||
Tracks thinking usage and provides reporting.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional, Dict, Any, List
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
@dataclass
|
||||
class ThinkingUsage:
|
||||
"""Usage statistics for a single thinking session."""
|
||||
tokens_used: int = 0
|
||||
time_seconds: float = 0.0
|
||||
budget_tokens: int = 0
|
||||
budget_time: Optional[float] = None
|
||||
complexity: float = 0.5
|
||||
started_at: Optional[datetime] = None
|
||||
ended_at: Optional[datetime] = None
|
||||
|
||||
@property
|
||||
def tokens_remaining(self) -> int:
|
||||
"""Get remaining token budget."""
|
||||
return max(0, self.budget_tokens - self.tokens_used)
|
||||
|
||||
@property
|
||||
def time_remaining(self) -> Optional[float]:
|
||||
"""Get remaining time budget."""
|
||||
if self.budget_time is None:
|
||||
return None
|
||||
return max(0.0, self.budget_time - self.time_seconds)
|
||||
|
||||
@property
|
||||
def token_utilization(self) -> float:
|
||||
"""Get token utilization percentage."""
|
||||
if self.budget_tokens == 0:
|
||||
return 0.0
|
||||
return self.tokens_used / self.budget_tokens
|
||||
|
||||
@property
|
||||
def is_over_budget(self) -> bool:
|
||||
"""Check if over token budget."""
|
||||
return self.tokens_used > self.budget_tokens
|
||||
|
||||
@property
|
||||
def is_over_time(self) -> bool:
|
||||
"""Check if over time budget."""
|
||||
if self.budget_time is None:
|
||||
return False
|
||||
return self.time_seconds > self.budget_time
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary."""
|
||||
return {
|
||||
"tokens_used": self.tokens_used,
|
||||
"time_seconds": self.time_seconds,
|
||||
"budget_tokens": self.budget_tokens,
|
||||
"budget_time": self.budget_time,
|
||||
"complexity": self.complexity,
|
||||
"tokens_remaining": self.tokens_remaining,
|
||||
"token_utilization": self.token_utilization,
|
||||
"is_over_budget": self.is_over_budget
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ThinkingTracker:
|
||||
"""
|
||||
Tracks thinking usage across multiple sessions.
|
||||
|
||||
Provides aggregate statistics and reporting.
|
||||
"""
|
||||
sessions: List[ThinkingUsage] = field(default_factory=list)
|
||||
total_tokens_used: int = 0
|
||||
total_time_seconds: float = 0.0
|
||||
|
||||
def start_session(
|
||||
self,
|
||||
budget_tokens: int,
|
||||
budget_time: Optional[float] = None,
|
||||
complexity: float = 0.5
|
||||
) -> ThinkingUsage:
|
||||
"""
|
||||
Start a new thinking session.
|
||||
|
||||
Args:
|
||||
budget_tokens: Token budget for this session
|
||||
budget_time: Optional time budget
|
||||
complexity: Task complexity (0.0 to 1.0)
|
||||
|
||||
Returns:
|
||||
ThinkingUsage for tracking
|
||||
"""
|
||||
usage = ThinkingUsage(
|
||||
budget_tokens=budget_tokens,
|
||||
budget_time=budget_time,
|
||||
complexity=complexity,
|
||||
started_at=datetime.now()
|
||||
)
|
||||
self.sessions.append(usage)
|
||||
return usage
|
||||
|
||||
def end_session(
|
||||
self,
|
||||
usage: ThinkingUsage,
|
||||
tokens_used: int,
|
||||
time_seconds: float
|
||||
):
|
||||
"""
|
||||
End a thinking session.
|
||||
|
||||
Args:
|
||||
usage: The usage object from start_session
|
||||
tokens_used: Actual tokens used
|
||||
time_seconds: Actual time taken
|
||||
"""
|
||||
usage.tokens_used = tokens_used
|
||||
usage.time_seconds = time_seconds
|
||||
usage.ended_at = datetime.now()
|
||||
|
||||
self.total_tokens_used += tokens_used
|
||||
self.total_time_seconds += time_seconds
|
||||
|
||||
@property
|
||||
def session_count(self) -> int:
|
||||
"""Get number of sessions."""
|
||||
return len(self.sessions)
|
||||
|
||||
@property
|
||||
def average_tokens_per_session(self) -> float:
|
||||
"""Get average tokens per session."""
|
||||
if not self.sessions:
|
||||
return 0.0
|
||||
return self.total_tokens_used / len(self.sessions)
|
||||
|
||||
@property
|
||||
def average_time_per_session(self) -> float:
|
||||
"""Get average time per session."""
|
||||
if not self.sessions:
|
||||
return 0.0
|
||||
return self.total_time_seconds / len(self.sessions)
|
||||
|
||||
@property
|
||||
def average_utilization(self) -> float:
|
||||
"""Get average budget utilization."""
|
||||
if not self.sessions:
|
||||
return 0.0
|
||||
return sum(s.token_utilization for s in self.sessions) / len(self.sessions)
|
||||
|
||||
@property
|
||||
def over_budget_count(self) -> int:
|
||||
"""Get number of sessions that went over budget."""
|
||||
return sum(1 for s in self.sessions if s.is_over_budget)
|
||||
|
||||
def get_summary(self) -> Dict[str, Any]:
|
||||
"""Get summary statistics."""
|
||||
return {
|
||||
"session_count": self.session_count,
|
||||
"total_tokens_used": self.total_tokens_used,
|
||||
"total_time_seconds": self.total_time_seconds,
|
||||
"average_tokens_per_session": self.average_tokens_per_session,
|
||||
"average_time_per_session": self.average_time_per_session,
|
||||
"average_utilization": self.average_utilization,
|
||||
"over_budget_count": self.over_budget_count
|
||||
}
|
||||
|
||||
def clear(self):
|
||||
"""Clear all tracking data."""
|
||||
self.sessions.clear()
|
||||
self.total_tokens_used = 0
|
||||
self.total_time_seconds = 0.0
|
||||
@@ -2,6 +2,9 @@
|
||||
from importlib import import_module
|
||||
from typing import Any
|
||||
|
||||
# Export Injected type for tool state injection
|
||||
from .injected import Injected
|
||||
|
||||
# Map of function names to their module and class (if any)
|
||||
TOOL_MAPPINGS = {
|
||||
# Direct functions
|
||||
@@ -222,6 +225,14 @@ TOOL_MAPPINGS = {
|
||||
# Unified Web Search (auto-fallback across providers)
|
||||
'search_web': ('.web_search', None),
|
||||
'get_available_providers': ('.web_search', None),
|
||||
|
||||
# Skill Tools (for Agent Skills script execution)
|
||||
'run_skill_script': ('.skill_tools', None),
|
||||
'read_skill_file': ('.skill_tools', None),
|
||||
'list_skill_scripts': ('.skill_tools', None),
|
||||
'create_skill_tools': ('.skill_tools', None),
|
||||
'SkillTools': ('.skill_tools', 'SkillTools'),
|
||||
'skill_tools': ('.skill_tools', None),
|
||||
}
|
||||
|
||||
_instances = {} # Cache for class instances
|
||||
@@ -259,11 +270,12 @@ def __getattr__(name: str) -> Any:
|
||||
'exa_search_async', 'exa_search_contents_async', 'exa_answer_async',
|
||||
'crawl4ai', 'crawl4ai_many', 'crawl4ai_extract', 'crawl4ai_llm_extract',
|
||||
'crawl4ai_sync', 'crawl4ai_extract_sync',
|
||||
'search_web', 'get_available_providers'
|
||||
'search_web', 'get_available_providers',
|
||||
'run_skill_script', 'read_skill_file', 'list_skill_scripts', 'create_skill_tools'
|
||||
]:
|
||||
return getattr(module, name)
|
||||
if name in ['file_tools', 'pandas_tools', 'wikipedia_tools',
|
||||
'newspaper_tools', 'arxiv_tools', 'spider_tools', 'duckdb_tools', 'mongodb_tools', 'csv_tools', 'json_tools', 'excel_tools', 'xml_tools', 'yaml_tools', 'calculator_tools', 'python_tools', 'shell_tools', 'cot_tools', 'tavily_tools', 'youdotcom_tools', 'exa_tools', 'crawl4ai_tools']:
|
||||
'newspaper_tools', 'arxiv_tools', 'spider_tools', 'duckdb_tools', 'mongodb_tools', 'csv_tools', 'json_tools', 'excel_tools', 'xml_tools', 'yaml_tools', 'calculator_tools', 'python_tools', 'shell_tools', 'cot_tools', 'tavily_tools', 'youdotcom_tools', 'exa_tools', 'crawl4ai_tools', 'skill_tools']:
|
||||
return module # Returns the callable module
|
||||
return getattr(module, name)
|
||||
else:
|
||||
@@ -277,4 +289,4 @@ def __getattr__(name: str) -> Any:
|
||||
method = getattr(_instances[class_name], name)
|
||||
return method
|
||||
|
||||
__all__ = list(TOOL_MAPPINGS.keys())
|
||||
__all__ = list(TOOL_MAPPINGS.keys()) + ['Injected']
|
||||
@@ -14,6 +14,14 @@ Usage:
|
||||
@tool(name="web_search", description="Search the internet")
|
||||
def search(query: str, max_results: int = 5) -> list:
|
||||
return [...]
|
||||
|
||||
# With injected state:
|
||||
from praisonaiagents.tools import Injected
|
||||
|
||||
@tool
|
||||
def my_tool(query: str, state: Injected[dict]) -> str:
|
||||
'''Tool with injected state.'''
|
||||
return f"session={state.get('session_id')}"
|
||||
"""
|
||||
|
||||
import inspect
|
||||
@@ -22,12 +30,19 @@ import logging
|
||||
from typing import Any, Callable, Dict, Optional, Union, get_type_hints
|
||||
|
||||
from .base import BaseTool
|
||||
from .injected import (
|
||||
is_injected_type,
|
||||
get_injected_params,
|
||||
inject_state_into_kwargs,
|
||||
filter_injected_from_schema
|
||||
)
|
||||
|
||||
|
||||
class FunctionTool(BaseTool):
|
||||
"""A BaseTool wrapper for plain functions.
|
||||
|
||||
Created automatically by the @tool decorator.
|
||||
Supports Injected[T] parameters for state injection.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
@@ -42,6 +57,9 @@ class FunctionTool(BaseTool):
|
||||
self.description = description or func.__doc__ or f"Tool: {self.name}"
|
||||
self.version = version
|
||||
|
||||
# Detect injected parameters
|
||||
self._injected_params = get_injected_params(func)
|
||||
|
||||
# Generate schema from the original function, not run()
|
||||
self.parameters = self._generate_schema_from_func(func)
|
||||
|
||||
@@ -55,8 +73,16 @@ class FunctionTool(BaseTool):
|
||||
if not self.description:
|
||||
self.description = self.__class__.__doc__ or f"Tool: {self.name}"
|
||||
|
||||
@property
|
||||
def injected_params(self) -> Dict[str, Any]:
|
||||
"""Get the injected parameters for this tool."""
|
||||
return self._injected_params
|
||||
|
||||
def _generate_schema_from_func(self, func: Callable) -> Dict[str, Any]:
|
||||
"""Generate JSON Schema from the wrapped function's signature."""
|
||||
"""Generate JSON Schema from the wrapped function's signature.
|
||||
|
||||
Injected parameters are excluded from the schema.
|
||||
"""
|
||||
schema = {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
@@ -71,8 +97,17 @@ class FunctionTool(BaseTool):
|
||||
if param_name in ('self', 'cls'):
|
||||
continue
|
||||
|
||||
# Skip injected parameters - they don't go in schema
|
||||
if param_name in self._injected_params:
|
||||
continue
|
||||
|
||||
# Get type hint
|
||||
param_type = hints.get(param_name, Any)
|
||||
|
||||
# Double-check it's not an Injected type
|
||||
if is_injected_type(param_type):
|
||||
continue
|
||||
|
||||
json_type = BaseTool._python_type_to_json(param_type)
|
||||
|
||||
schema["properties"][param_name] = {"type": json_type}
|
||||
@@ -86,11 +121,18 @@ class FunctionTool(BaseTool):
|
||||
return schema
|
||||
|
||||
def run(self, **kwargs) -> Any:
|
||||
"""Execute the wrapped function."""
|
||||
"""Execute the wrapped function with injected state."""
|
||||
# Inject state for any Injected parameters
|
||||
kwargs = inject_state_into_kwargs(kwargs, self._injected_params)
|
||||
return self._func(**kwargs)
|
||||
|
||||
def __call__(self, *args, **kwargs) -> Any:
|
||||
"""Allow calling with positional args like the original function."""
|
||||
"""Allow calling with positional args like the original function.
|
||||
|
||||
Injects state for Injected parameters.
|
||||
"""
|
||||
# Inject state for any Injected parameters
|
||||
kwargs = inject_state_into_kwargs(kwargs, self._injected_params)
|
||||
return self._func(*args, **kwargs)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
"""
|
||||
Injected State for PraisonAI Tools.
|
||||
|
||||
Provides a mechanism for tools to receive injected state (agent context)
|
||||
without exposing it in the tool's public schema.
|
||||
|
||||
Zero Performance Impact:
|
||||
- When no Injected params, no overhead
|
||||
- Context is thread-local for safety
|
||||
- Lazy evaluation of state
|
||||
|
||||
Usage:
|
||||
from praisonaiagents import tool
|
||||
from praisonaiagents.tools import Injected
|
||||
|
||||
@tool
|
||||
def my_tool(query: str, state: Injected[dict]) -> str:
|
||||
'''Tool with injected state.'''
|
||||
session_id = state.get('session_id')
|
||||
return f"Query: {query}, Session: {session_id}"
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, Generic, List, Optional, TypeVar, get_type_hints, get_origin, get_args
|
||||
from contextvars import ContextVar
|
||||
from contextlib import contextmanager
|
||||
|
||||
|
||||
T = TypeVar('T')
|
||||
|
||||
|
||||
class Injected(Generic[T]):
|
||||
"""Type marker for injected parameters.
|
||||
|
||||
Parameters annotated with Injected[T] will:
|
||||
- Not appear in the tool's public schema
|
||||
- Be automatically injected at runtime with the current agent state
|
||||
|
||||
Supported types:
|
||||
- Injected[dict] - Receives state as a dictionary
|
||||
- Injected[AgentState] - Receives state as AgentState dataclass
|
||||
|
||||
Example:
|
||||
@tool
|
||||
def my_tool(query: str, state: Injected[dict]) -> str:
|
||||
return f"session={state.get('session_id')}"
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgentState:
|
||||
"""State object injected into tools.
|
||||
|
||||
Contains context about the current agent execution.
|
||||
"""
|
||||
agent_id: str
|
||||
run_id: str
|
||||
session_id: str
|
||||
last_user_message: Optional[str] = None
|
||||
last_agent_message: Optional[str] = None
|
||||
memory: Optional[Any] = None
|
||||
previous_tool_results: List[Any] = field(default_factory=list)
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary for Injected[dict] usage."""
|
||||
return {
|
||||
'agent_id': self.agent_id,
|
||||
'run_id': self.run_id,
|
||||
'session_id': self.session_id,
|
||||
'last_user_message': self.last_user_message,
|
||||
'last_agent_message': self.last_agent_message,
|
||||
'previous_tool_results': self.previous_tool_results,
|
||||
'metadata': self.metadata,
|
||||
}
|
||||
|
||||
|
||||
# Thread-local context for current state
|
||||
_current_state: ContextVar[Optional[AgentState]] = ContextVar('current_state', default=None)
|
||||
|
||||
|
||||
def get_current_state() -> Optional[AgentState]:
|
||||
"""Get the current injection state."""
|
||||
return _current_state.get()
|
||||
|
||||
|
||||
def set_current_state(state: Optional[AgentState]) -> None:
|
||||
"""Set the current injection state."""
|
||||
_current_state.set(state)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def with_injection_context(state: AgentState):
|
||||
"""Context manager for setting injection state.
|
||||
|
||||
Usage:
|
||||
with with_injection_context(state):
|
||||
result = my_tool(query="test")
|
||||
"""
|
||||
token = _current_state.set(state)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
_current_state.reset(token)
|
||||
|
||||
|
||||
def is_injected_type(annotation: Any) -> bool:
|
||||
"""Check if a type annotation is Injected[T]."""
|
||||
origin = get_origin(annotation)
|
||||
if origin is Injected:
|
||||
return True
|
||||
# Handle string annotations
|
||||
if isinstance(annotation, str) and 'Injected' in annotation:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def get_injected_type(annotation: Any) -> Optional[type]:
|
||||
"""Get the inner type T from Injected[T]."""
|
||||
origin = get_origin(annotation)
|
||||
if origin is Injected:
|
||||
args = get_args(annotation)
|
||||
if args:
|
||||
return args[0]
|
||||
return None
|
||||
|
||||
|
||||
def resolve_injected_value(annotation: Any, state: Optional[AgentState]) -> Any:
|
||||
"""Resolve the value to inject based on annotation type.
|
||||
|
||||
Args:
|
||||
annotation: The Injected[T] annotation
|
||||
state: The current agent state
|
||||
|
||||
Returns:
|
||||
The resolved value (dict or AgentState)
|
||||
"""
|
||||
if state is None:
|
||||
# Return empty dict if no state available
|
||||
return {}
|
||||
|
||||
inner_type = get_injected_type(annotation)
|
||||
|
||||
if inner_type is dict or inner_type is Dict:
|
||||
return state.to_dict()
|
||||
elif inner_type is AgentState:
|
||||
return state
|
||||
else:
|
||||
# Default to dict
|
||||
return state.to_dict()
|
||||
|
||||
|
||||
def get_injected_params(func) -> Dict[str, Any]:
|
||||
"""Get all injected parameters from a function.
|
||||
|
||||
Returns:
|
||||
Dict mapping param name to its Injected[T] annotation
|
||||
"""
|
||||
try:
|
||||
hints = get_type_hints(func)
|
||||
except Exception:
|
||||
# If we can't get hints, return empty
|
||||
return {}
|
||||
|
||||
injected = {}
|
||||
for name, hint in hints.items():
|
||||
if name == 'return':
|
||||
continue
|
||||
if is_injected_type(hint):
|
||||
injected[name] = hint
|
||||
|
||||
return injected
|
||||
|
||||
|
||||
def filter_injected_from_schema(properties: Dict[str, Any], required: List[str],
|
||||
injected_params: Dict[str, Any]) -> tuple:
|
||||
"""Remove injected parameters from schema.
|
||||
|
||||
Args:
|
||||
properties: Schema properties dict
|
||||
required: List of required param names
|
||||
injected_params: Dict of injected param names
|
||||
|
||||
Returns:
|
||||
Tuple of (filtered_properties, filtered_required)
|
||||
"""
|
||||
filtered_props = {k: v for k, v in properties.items() if k not in injected_params}
|
||||
filtered_required = [r for r in required if r not in injected_params]
|
||||
return filtered_props, filtered_required
|
||||
|
||||
|
||||
def inject_state_into_kwargs(kwargs: Dict[str, Any], injected_params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Inject state values into kwargs for injected parameters.
|
||||
|
||||
Args:
|
||||
kwargs: The original kwargs
|
||||
injected_params: Dict of injected param names to annotations
|
||||
|
||||
Returns:
|
||||
Updated kwargs with injected values
|
||||
"""
|
||||
if not injected_params:
|
||||
return kwargs
|
||||
|
||||
state = get_current_state()
|
||||
result = kwargs.copy()
|
||||
|
||||
for param_name, annotation in injected_params.items():
|
||||
if param_name not in result:
|
||||
result[param_name] = resolve_injected_value(annotation, state)
|
||||
|
||||
return result
|
||||
@@ -0,0 +1,280 @@
|
||||
"""Tools for executing Agent Skills scripts.
|
||||
|
||||
This module provides tools for running scripts bundled with Agent Skills.
|
||||
Skills are modular packages that extend agent capabilities through SKILL.md
|
||||
files and optional scripts.
|
||||
|
||||
Usage:
|
||||
from praisonaiagents.tools import run_skill_script
|
||||
result = run_skill_script("./my-skill/scripts/skill.py", "./data.csv")
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
import os
|
||||
import logging
|
||||
|
||||
|
||||
class SkillTools:
|
||||
"""Tools for executing Agent Skills scripts."""
|
||||
|
||||
def __init__(self, working_directory: str = None):
|
||||
"""Initialize SkillTools.
|
||||
|
||||
Args:
|
||||
working_directory: Base directory for resolving relative paths.
|
||||
Defaults to current working directory.
|
||||
"""
|
||||
self._working_directory = working_directory or os.getcwd()
|
||||
|
||||
@property
|
||||
def working_directory(self) -> str:
|
||||
"""Get the working directory for path resolution."""
|
||||
return self._working_directory
|
||||
|
||||
@working_directory.setter
|
||||
def working_directory(self, value: str):
|
||||
"""Set the working directory for path resolution."""
|
||||
self._working_directory = value
|
||||
|
||||
def run_skill_script(
|
||||
self,
|
||||
script_path: str,
|
||||
args: str = "",
|
||||
timeout: int = 60
|
||||
) -> str:
|
||||
"""
|
||||
Execute a skill script from a skill's scripts/ directory.
|
||||
|
||||
Args:
|
||||
script_path: Path to the script file (e.g., ./csv-analyzer/scripts/skill.py)
|
||||
args: Arguments to pass to the script (e.g., file path to analyze)
|
||||
timeout: Maximum execution time in seconds (default: 60)
|
||||
|
||||
Returns:
|
||||
The output from running the script, or error message if failed
|
||||
"""
|
||||
try:
|
||||
# Resolve the script path
|
||||
script_path = os.path.expanduser(script_path)
|
||||
if not os.path.isabs(script_path):
|
||||
script_path = os.path.join(self._working_directory, script_path)
|
||||
script_path = os.path.abspath(script_path)
|
||||
|
||||
if not os.path.exists(script_path):
|
||||
return f"Error: Script not found at {script_path}"
|
||||
|
||||
# Resolve args paths relative to working directory
|
||||
resolved_args = []
|
||||
if args:
|
||||
for arg in args.split():
|
||||
# Skip flags
|
||||
if arg.startswith('-'):
|
||||
resolved_args.append(arg)
|
||||
continue
|
||||
|
||||
# Try to resolve as a path relative to working directory
|
||||
potential_path = os.path.join(self._working_directory, arg)
|
||||
if os.path.exists(potential_path):
|
||||
resolved_args.append(os.path.abspath(potential_path))
|
||||
elif os.path.isabs(arg) and os.path.exists(arg):
|
||||
# Already absolute and exists
|
||||
resolved_args.append(arg)
|
||||
else:
|
||||
# Keep original arg if path doesn't exist
|
||||
resolved_args.append(arg)
|
||||
|
||||
# Determine how to run the script based on extension
|
||||
ext = os.path.splitext(script_path)[1].lower()
|
||||
|
||||
if ext == '.py':
|
||||
cmd = ['python', script_path] + resolved_args
|
||||
elif ext == '.sh':
|
||||
cmd = ['bash', script_path] + resolved_args
|
||||
elif ext == '.js':
|
||||
cmd = ['node', script_path] + resolved_args
|
||||
else:
|
||||
# Try to run directly
|
||||
cmd = [script_path] + resolved_args
|
||||
|
||||
logging.debug(f"Running skill script: {' '.join(cmd)}")
|
||||
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
cwd=self._working_directory
|
||||
)
|
||||
|
||||
output = result.stdout
|
||||
if result.stderr:
|
||||
output += f"\nStderr: {result.stderr}"
|
||||
if result.returncode != 0:
|
||||
output += f"\nExit code: {result.returncode}"
|
||||
|
||||
return output if output else "Script executed successfully (no output)"
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
return f"Error: Script execution timed out after {timeout} seconds"
|
||||
except FileNotFoundError as e:
|
||||
return f"Error: Could not find interpreter or script: {str(e)}"
|
||||
except PermissionError as e:
|
||||
return f"Error: Permission denied: {str(e)}"
|
||||
except Exception as e:
|
||||
return f"Error executing script: {str(e)}"
|
||||
|
||||
def read_skill_file(
|
||||
self,
|
||||
skill_path: str,
|
||||
file_path: str,
|
||||
encoding: str = 'utf-8'
|
||||
) -> str:
|
||||
"""
|
||||
Read a file from a skill directory.
|
||||
|
||||
Args:
|
||||
skill_path: Path to the skill directory (e.g., ./csv-analyzer)
|
||||
file_path: Relative path within the skill (e.g., SKILL.md, scripts/skill.py)
|
||||
encoding: File encoding (default: utf-8)
|
||||
|
||||
Returns:
|
||||
File contents or error message
|
||||
"""
|
||||
try:
|
||||
# Resolve skill path
|
||||
skill_path = os.path.expanduser(skill_path)
|
||||
if not os.path.isabs(skill_path):
|
||||
skill_path = os.path.join(self._working_directory, skill_path)
|
||||
skill_path = os.path.abspath(skill_path)
|
||||
|
||||
if not os.path.exists(skill_path):
|
||||
return f"Error: Skill directory not found at {skill_path}"
|
||||
|
||||
if not os.path.isdir(skill_path):
|
||||
return f"Error: {skill_path} is not a directory"
|
||||
|
||||
# Resolve file path within skill
|
||||
full_path = os.path.join(skill_path, file_path)
|
||||
full_path = os.path.abspath(full_path)
|
||||
|
||||
# Security check: ensure file is within skill directory
|
||||
if not full_path.startswith(skill_path):
|
||||
return f"Error: Path traversal detected - {file_path} is outside skill directory"
|
||||
|
||||
if not os.path.exists(full_path):
|
||||
return f"Error: File not found at {full_path}"
|
||||
|
||||
with open(full_path, 'r', encoding=encoding) as f:
|
||||
return f.read()
|
||||
|
||||
except UnicodeDecodeError as e:
|
||||
return f"Error: Could not decode file with {encoding} encoding: {str(e)}"
|
||||
except PermissionError as e:
|
||||
return f"Error: Permission denied reading file: {str(e)}"
|
||||
except Exception as e:
|
||||
return f"Error reading skill file: {str(e)}"
|
||||
|
||||
def list_skill_scripts(self, skill_path: str) -> str:
|
||||
"""
|
||||
List available scripts in a skill's scripts/ directory.
|
||||
|
||||
Args:
|
||||
skill_path: Path to the skill directory
|
||||
|
||||
Returns:
|
||||
JSON string with list of scripts or error message
|
||||
"""
|
||||
import json
|
||||
|
||||
try:
|
||||
# Resolve skill path
|
||||
skill_path = os.path.expanduser(skill_path)
|
||||
if not os.path.isabs(skill_path):
|
||||
skill_path = os.path.join(self._working_directory, skill_path)
|
||||
skill_path = os.path.abspath(skill_path)
|
||||
|
||||
if not os.path.exists(skill_path):
|
||||
return f"Error: Skill directory not found at {skill_path}"
|
||||
|
||||
scripts_dir = os.path.join(skill_path, "scripts")
|
||||
if not os.path.exists(scripts_dir):
|
||||
return json.dumps({"scripts": [], "message": "No scripts/ directory found"})
|
||||
|
||||
scripts = []
|
||||
for item in os.listdir(scripts_dir):
|
||||
item_path = os.path.join(scripts_dir, item)
|
||||
if os.path.isfile(item_path):
|
||||
ext = os.path.splitext(item)[1].lower()
|
||||
if ext in ['.py', '.sh', '.js', '.rb', '.pl']:
|
||||
scripts.append({
|
||||
"name": item,
|
||||
"path": os.path.join("scripts", item),
|
||||
"type": ext[1:] # Remove the dot
|
||||
})
|
||||
|
||||
return json.dumps({"scripts": scripts}, indent=2)
|
||||
|
||||
except Exception as e:
|
||||
return f"Error listing skill scripts: {str(e)}"
|
||||
|
||||
|
||||
# Create default instance for direct function access
|
||||
_skill_tools = SkillTools()
|
||||
|
||||
def run_skill_script(script_path: str, args: str = "", timeout: int = 60) -> str:
|
||||
"""
|
||||
Execute a skill script from a skill's scripts/ directory.
|
||||
|
||||
Args:
|
||||
script_path: Path to the script file (e.g., ./csv-analyzer/scripts/script.py)
|
||||
args: Arguments to pass to the script (e.g., file path to analyze)
|
||||
timeout: Maximum execution time in seconds (default: 60)
|
||||
|
||||
Returns:
|
||||
The output from running the script, or error message if failed
|
||||
"""
|
||||
return _skill_tools.run_skill_script(script_path, args, timeout)
|
||||
|
||||
|
||||
def read_skill_file(skill_path: str, file_path: str, encoding: str = 'utf-8') -> str:
|
||||
"""
|
||||
Read a file from a skill directory.
|
||||
|
||||
Args:
|
||||
skill_path: Path to the skill directory (e.g., ./csv-analyzer)
|
||||
file_path: Relative path within the skill (e.g., SKILL.md, scripts/script.py)
|
||||
encoding: File encoding (default: utf-8)
|
||||
|
||||
Returns:
|
||||
File contents or error message
|
||||
"""
|
||||
return _skill_tools.read_skill_file(skill_path, file_path, encoding)
|
||||
|
||||
|
||||
def list_skill_scripts(skill_path: str) -> str:
|
||||
"""
|
||||
List available scripts in a skill's scripts/ directory.
|
||||
|
||||
Args:
|
||||
skill_path: Path to the skill directory
|
||||
|
||||
Returns:
|
||||
JSON string with list of scripts or error message
|
||||
"""
|
||||
return _skill_tools.list_skill_scripts(skill_path)
|
||||
|
||||
|
||||
def create_skill_tools(working_directory: str = None) -> SkillTools:
|
||||
"""
|
||||
Create a SkillTools instance with a specific working directory.
|
||||
|
||||
This is useful when you need to resolve paths relative to a specific
|
||||
directory rather than the current working directory.
|
||||
|
||||
Args:
|
||||
working_directory: Base directory for resolving relative paths
|
||||
|
||||
Returns:
|
||||
SkillTools instance
|
||||
"""
|
||||
return SkillTools(working_directory)
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "praisonaiagents"
|
||||
version = "0.1.24"
|
||||
version = "0.3.0"
|
||||
description = "Praison AI agents for completing complex tasks with Self Reflection Agents"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Unit tests for background module."""
|
||||
@@ -0,0 +1,401 @@
|
||||
"""
|
||||
Unit tests for the Background module.
|
||||
|
||||
Tests cover:
|
||||
- BackgroundTask creation and status management
|
||||
- BackgroundRunner task submission
|
||||
- Task cancellation and timeout
|
||||
- Concurrent execution
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
|
||||
from praisonaiagents.background.task import BackgroundTask, TaskStatus
|
||||
from praisonaiagents.background.config import BackgroundConfig
|
||||
from praisonaiagents.background.runner import BackgroundRunner
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# BackgroundTask Tests
|
||||
# =============================================================================
|
||||
|
||||
class TestBackgroundTask:
|
||||
"""Tests for BackgroundTask class."""
|
||||
|
||||
def test_task_creation(self):
|
||||
"""Test creating a task."""
|
||||
task = BackgroundTask(name="test_task")
|
||||
assert task.name == "test_task"
|
||||
assert task.status == TaskStatus.PENDING
|
||||
assert task.progress == 0.0
|
||||
assert not task.is_running
|
||||
assert not task.is_completed
|
||||
|
||||
def test_task_start(self):
|
||||
"""Test starting a task."""
|
||||
task = BackgroundTask(name="test")
|
||||
task.start()
|
||||
|
||||
assert task.status == TaskStatus.RUNNING
|
||||
assert task.is_running
|
||||
assert task.started_at is not None
|
||||
|
||||
def test_task_complete(self):
|
||||
"""Test completing a task."""
|
||||
task = BackgroundTask(name="test")
|
||||
task.start()
|
||||
task.complete(result="success")
|
||||
|
||||
assert task.status == TaskStatus.COMPLETED
|
||||
assert task.is_completed
|
||||
assert task.is_successful
|
||||
assert task.result == "success"
|
||||
assert task.progress == 1.0
|
||||
assert task.completed_at is not None
|
||||
|
||||
def test_task_fail(self):
|
||||
"""Test failing a task."""
|
||||
task = BackgroundTask(name="test")
|
||||
task.start()
|
||||
task.fail("Something went wrong")
|
||||
|
||||
assert task.status == TaskStatus.FAILED
|
||||
assert task.is_completed
|
||||
assert not task.is_successful
|
||||
assert task.error == "Something went wrong"
|
||||
|
||||
def test_task_cancel(self):
|
||||
"""Test cancelling a task."""
|
||||
task = BackgroundTask(name="test")
|
||||
task.start()
|
||||
task.cancel()
|
||||
|
||||
assert task.status == TaskStatus.CANCELLED
|
||||
assert task.is_completed
|
||||
assert task.should_cancel()
|
||||
|
||||
def test_task_progress(self):
|
||||
"""Test updating task progress."""
|
||||
task = BackgroundTask(name="test")
|
||||
task.start()
|
||||
|
||||
task.update_progress(0.5, "Halfway done")
|
||||
|
||||
assert task.progress == 0.5
|
||||
assert task.metadata.get("last_message") == "Halfway done"
|
||||
|
||||
def test_task_progress_bounds(self):
|
||||
"""Test that progress is bounded."""
|
||||
task = BackgroundTask(name="test")
|
||||
|
||||
task.update_progress(-0.5)
|
||||
assert task.progress == 0.0
|
||||
|
||||
task.update_progress(1.5)
|
||||
assert task.progress == 1.0
|
||||
|
||||
def test_task_duration(self):
|
||||
"""Test task duration calculation."""
|
||||
task = BackgroundTask(name="test")
|
||||
|
||||
assert task.duration_seconds is None
|
||||
|
||||
task.start()
|
||||
assert task.duration_seconds is not None
|
||||
assert task.duration_seconds >= 0
|
||||
|
||||
def test_task_to_dict(self):
|
||||
"""Test task serialization."""
|
||||
task = BackgroundTask(name="test")
|
||||
task.start()
|
||||
task.complete("result")
|
||||
|
||||
data = task.to_dict()
|
||||
|
||||
assert data["name"] == "test"
|
||||
assert data["status"] == "completed"
|
||||
assert data["result"] == "result"
|
||||
assert "created_at" in data
|
||||
assert "started_at" in data
|
||||
assert "completed_at" in data
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# BackgroundConfig Tests
|
||||
# =============================================================================
|
||||
|
||||
class TestBackgroundConfig:
|
||||
"""Tests for BackgroundConfig class."""
|
||||
|
||||
def test_config_defaults(self):
|
||||
"""Test default configuration."""
|
||||
config = BackgroundConfig()
|
||||
|
||||
assert config.max_concurrent_tasks == 5
|
||||
assert config.default_timeout is None
|
||||
assert config.auto_cleanup
|
||||
assert config.cleanup_delay == 300.0
|
||||
|
||||
def test_config_custom(self):
|
||||
"""Test custom configuration."""
|
||||
config = BackgroundConfig(
|
||||
max_concurrent_tasks=10,
|
||||
default_timeout=60.0,
|
||||
auto_cleanup=False
|
||||
)
|
||||
|
||||
assert config.max_concurrent_tasks == 10
|
||||
assert config.default_timeout == 60.0
|
||||
assert not config.auto_cleanup
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# BackgroundRunner Tests
|
||||
# =============================================================================
|
||||
|
||||
class TestBackgroundRunner:
|
||||
"""Tests for BackgroundRunner class."""
|
||||
|
||||
@pytest.fixture
|
||||
def runner(self):
|
||||
"""Create a test runner."""
|
||||
return BackgroundRunner()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_submit_sync_function(self, runner):
|
||||
"""Test submitting a sync function."""
|
||||
def sync_func(x):
|
||||
return x * 2
|
||||
|
||||
task = await runner.submit(func=sync_func, args=(5,), name="double")
|
||||
result = await task.wait(timeout=5.0)
|
||||
|
||||
assert result == 10
|
||||
assert task.is_successful
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_submit_async_function(self, runner):
|
||||
"""Test submitting an async function."""
|
||||
async def async_func(x):
|
||||
await asyncio.sleep(0.1)
|
||||
return x * 3
|
||||
|
||||
task = await runner.submit(func=async_func, args=(5,), name="triple")
|
||||
result = await task.wait(timeout=5.0)
|
||||
|
||||
assert result == 15
|
||||
assert task.is_successful
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_submit_with_kwargs(self, runner):
|
||||
"""Test submitting with keyword arguments."""
|
||||
def func_with_kwargs(a, b=10):
|
||||
return a + b
|
||||
|
||||
task = await runner.submit(
|
||||
func=func_with_kwargs,
|
||||
args=(5,),
|
||||
kwargs={"b": 20}
|
||||
)
|
||||
result = await task.wait(timeout=5.0)
|
||||
|
||||
assert result == 25
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_task_timeout(self, runner):
|
||||
"""Test task timeout."""
|
||||
async def slow_func():
|
||||
await asyncio.sleep(10)
|
||||
return "done"
|
||||
|
||||
task = await runner.submit(func=slow_func, timeout=0.1)
|
||||
|
||||
await asyncio.sleep(0.3)
|
||||
|
||||
assert task.status == TaskStatus.FAILED
|
||||
assert "timed out" in task.error
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_task_failure(self, runner):
|
||||
"""Test task that raises exception."""
|
||||
def failing_func():
|
||||
raise ValueError("Test error")
|
||||
|
||||
task = await runner.submit(func=failing_func)
|
||||
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
assert task.status == TaskStatus.FAILED
|
||||
assert "Test error" in task.error
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_task(self, runner):
|
||||
"""Test cancelling a task."""
|
||||
async def long_running():
|
||||
await asyncio.sleep(10)
|
||||
return "done"
|
||||
|
||||
task = await runner.submit(func=long_running)
|
||||
|
||||
await asyncio.sleep(0.1)
|
||||
result = await runner.cancel_task(task.id)
|
||||
|
||||
assert result
|
||||
assert task.status == TaskStatus.CANCELLED
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_task(self, runner):
|
||||
"""Test getting a task by ID."""
|
||||
task = await runner.submit(func=lambda: "test")
|
||||
|
||||
retrieved = runner.get_task(task.id)
|
||||
|
||||
assert retrieved is not None
|
||||
assert retrieved.id == task.id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_tasks(self, runner):
|
||||
"""Test listing tasks."""
|
||||
await runner.submit(func=lambda: "a", name="task_a")
|
||||
await runner.submit(func=lambda: "b", name="task_b")
|
||||
|
||||
await asyncio.sleep(0.2)
|
||||
|
||||
tasks = runner.list_tasks()
|
||||
|
||||
assert len(tasks) == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_tasks_by_status(self, runner):
|
||||
"""Test listing tasks filtered by status."""
|
||||
await runner.submit(func=lambda: "a")
|
||||
await runner.submit(func=lambda: "b")
|
||||
|
||||
await asyncio.sleep(0.2)
|
||||
|
||||
completed = runner.list_tasks(status=TaskStatus.COMPLETED)
|
||||
|
||||
assert len(completed) == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_clear_completed(self, runner):
|
||||
"""Test clearing completed tasks."""
|
||||
await runner.submit(func=lambda: "a")
|
||||
await runner.submit(func=lambda: "b")
|
||||
|
||||
await asyncio.sleep(0.2)
|
||||
|
||||
assert len(runner.tasks) == 2
|
||||
|
||||
runner.clear_completed()
|
||||
|
||||
assert len(runner.tasks) == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_complete_callback(self, runner):
|
||||
"""Test on_complete callback."""
|
||||
callback_called = []
|
||||
|
||||
def on_complete(task):
|
||||
callback_called.append(task.id)
|
||||
|
||||
task = await runner.submit(
|
||||
func=lambda: "result",
|
||||
on_complete=on_complete
|
||||
)
|
||||
|
||||
await task.wait(timeout=5.0)
|
||||
|
||||
assert task.id in callback_called
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_execution(self, runner):
|
||||
"""Test concurrent task execution."""
|
||||
results = []
|
||||
|
||||
async def task_func(n):
|
||||
await asyncio.sleep(0.1)
|
||||
results.append(n)
|
||||
return n
|
||||
|
||||
tasks = []
|
||||
for i in range(3):
|
||||
task = await runner.submit(func=task_func, args=(i,))
|
||||
tasks.append(task)
|
||||
|
||||
await runner.wait_all(timeout=5.0)
|
||||
|
||||
assert len(results) == 3
|
||||
assert all(t.is_successful for t in tasks)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_max_concurrent_limit(self):
|
||||
"""Test max concurrent tasks limit."""
|
||||
config = BackgroundConfig(max_concurrent_tasks=2)
|
||||
runner = BackgroundRunner(config=config)
|
||||
|
||||
execution_times = []
|
||||
|
||||
async def tracked_func(n):
|
||||
execution_times.append((n, "start", asyncio.get_event_loop().time()))
|
||||
await asyncio.sleep(0.2)
|
||||
execution_times.append((n, "end", asyncio.get_event_loop().time()))
|
||||
return n
|
||||
|
||||
tasks = []
|
||||
for i in range(4):
|
||||
task = await runner.submit(func=tracked_func, args=(i,))
|
||||
tasks.append(task)
|
||||
|
||||
await runner.wait_all(timeout=5.0)
|
||||
|
||||
# All tasks should complete
|
||||
assert all(t.is_successful for t in tasks)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wait_all(self, runner):
|
||||
"""Test waiting for all tasks."""
|
||||
async def slow_func(n):
|
||||
await asyncio.sleep(0.1 * n)
|
||||
return n
|
||||
|
||||
for i in range(1, 4):
|
||||
await runner.submit(func=slow_func, args=(i,))
|
||||
|
||||
tasks = await runner.wait_all(timeout=5.0)
|
||||
|
||||
assert len(tasks) == 3
|
||||
assert all(t.is_completed for t in tasks)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_all(self, runner):
|
||||
"""Test cancelling all tasks."""
|
||||
async def long_func():
|
||||
await asyncio.sleep(10)
|
||||
|
||||
for _ in range(3):
|
||||
await runner.submit(func=long_func)
|
||||
|
||||
await asyncio.sleep(0.1)
|
||||
await runner.cancel_all()
|
||||
|
||||
assert all(t.status == TaskStatus.CANCELLED for t in runner.tasks)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_runner(self, runner):
|
||||
"""Test stopping the runner."""
|
||||
async def long_func():
|
||||
await asyncio.sleep(10)
|
||||
|
||||
await runner.submit(func=long_func)
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
await runner.stop()
|
||||
|
||||
assert all(t.status == TaskStatus.CANCELLED for t in runner.tasks)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -0,0 +1 @@
|
||||
"""Unit tests for checkpoints module."""
|
||||
@@ -0,0 +1,417 @@
|
||||
"""
|
||||
Unit tests for the Checkpoints module.
|
||||
|
||||
Tests cover:
|
||||
- CheckpointConfig creation and validation
|
||||
- Checkpoint data types
|
||||
- CheckpointService initialization
|
||||
- Save, restore, and diff operations
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import asyncio
|
||||
import os
|
||||
import tempfile
|
||||
import shutil
|
||||
from datetime import datetime
|
||||
|
||||
from praisonaiagents.checkpoints.types import (
|
||||
CheckpointConfig, Checkpoint, CheckpointDiff, FileDiff,
|
||||
CheckpointResult, CheckpointEvent
|
||||
)
|
||||
from praisonaiagents.checkpoints.service import CheckpointService
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# CheckpointConfig Tests
|
||||
# =============================================================================
|
||||
|
||||
class TestCheckpointConfig:
|
||||
"""Tests for CheckpointConfig class."""
|
||||
|
||||
def test_config_creation(self):
|
||||
"""Test creating a config."""
|
||||
config = CheckpointConfig(workspace_dir="/tmp/test")
|
||||
assert config.workspace_dir == "/tmp/test"
|
||||
assert config.enabled
|
||||
assert config.auto_checkpoint
|
||||
assert config.max_checkpoints == 100
|
||||
|
||||
def test_config_expands_paths(self):
|
||||
"""Test that paths are expanded."""
|
||||
config = CheckpointConfig(
|
||||
workspace_dir="~/test",
|
||||
storage_dir="~/.praison/checkpoints"
|
||||
)
|
||||
assert not config.workspace_dir.startswith("~")
|
||||
assert not config.storage_dir.startswith("~")
|
||||
|
||||
def test_config_default_storage(self):
|
||||
"""Test default storage directory."""
|
||||
config = CheckpointConfig(workspace_dir="/tmp/test")
|
||||
assert ".praison/checkpoints" in config.storage_dir
|
||||
|
||||
def test_config_exclude_patterns(self):
|
||||
"""Test default exclude patterns."""
|
||||
config = CheckpointConfig(workspace_dir="/tmp/test")
|
||||
assert ".git" in config.exclude_patterns
|
||||
assert "__pycache__" in config.exclude_patterns
|
||||
assert "node_modules" in config.exclude_patterns
|
||||
|
||||
def test_get_checkpoint_dir(self):
|
||||
"""Test checkpoint directory generation."""
|
||||
config = CheckpointConfig(workspace_dir="/tmp/test")
|
||||
checkpoint_dir = config.get_checkpoint_dir()
|
||||
assert config.storage_dir in checkpoint_dir
|
||||
# Should be a hash-based subdirectory
|
||||
assert len(os.path.basename(checkpoint_dir)) == 12
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Checkpoint Data Type Tests
|
||||
# =============================================================================
|
||||
|
||||
class TestCheckpointTypes:
|
||||
"""Tests for checkpoint data types."""
|
||||
|
||||
def test_checkpoint_creation(self):
|
||||
"""Test creating a checkpoint."""
|
||||
checkpoint = Checkpoint(
|
||||
id="abc123def456",
|
||||
short_id="abc123de",
|
||||
message="Test checkpoint",
|
||||
timestamp=datetime.now()
|
||||
)
|
||||
assert checkpoint.id == "abc123def456"
|
||||
assert checkpoint.short_id == "abc123de"
|
||||
assert checkpoint.message == "Test checkpoint"
|
||||
|
||||
def test_checkpoint_from_git_commit(self):
|
||||
"""Test creating checkpoint from git commit."""
|
||||
checkpoint = Checkpoint.from_git_commit(
|
||||
commit_hash="abc123def456789",
|
||||
message="Test message",
|
||||
timestamp="2024-01-01T00:00:00"
|
||||
)
|
||||
assert checkpoint.id == "abc123def456789"
|
||||
assert checkpoint.short_id == "abc123de"
|
||||
assert checkpoint.message == "Test message"
|
||||
|
||||
def test_checkpoint_to_dict(self):
|
||||
"""Test checkpoint serialization."""
|
||||
checkpoint = Checkpoint(
|
||||
id="abc123",
|
||||
short_id="abc1",
|
||||
message="Test",
|
||||
timestamp=datetime(2024, 1, 1)
|
||||
)
|
||||
data = checkpoint.to_dict()
|
||||
assert data["id"] == "abc123"
|
||||
assert data["message"] == "Test"
|
||||
assert "timestamp" in data
|
||||
|
||||
def test_file_diff(self):
|
||||
"""Test FileDiff creation."""
|
||||
diff = FileDiff(
|
||||
path="test.py",
|
||||
absolute_path="/tmp/test.py",
|
||||
status="modified",
|
||||
additions=10,
|
||||
deletions=5
|
||||
)
|
||||
assert diff.path == "test.py"
|
||||
assert diff.status == "modified"
|
||||
assert diff.additions == 10
|
||||
|
||||
def test_checkpoint_diff(self):
|
||||
"""Test CheckpointDiff creation."""
|
||||
files = [
|
||||
FileDiff("a.py", "/tmp/a.py", "added", 10, 0),
|
||||
FileDiff("b.py", "/tmp/b.py", "modified", 5, 3)
|
||||
]
|
||||
diff = CheckpointDiff(
|
||||
from_checkpoint="abc123",
|
||||
to_checkpoint="def456",
|
||||
files=files
|
||||
)
|
||||
assert diff.total_additions == 15
|
||||
assert diff.total_deletions == 3
|
||||
|
||||
def test_checkpoint_result_ok(self):
|
||||
"""Test successful checkpoint result."""
|
||||
checkpoint = Checkpoint(
|
||||
id="abc123",
|
||||
short_id="abc1",
|
||||
message="Test",
|
||||
timestamp=datetime.now()
|
||||
)
|
||||
result = CheckpointResult.ok(checkpoint)
|
||||
assert result.success
|
||||
assert result.checkpoint == checkpoint
|
||||
assert result.error is None
|
||||
|
||||
def test_checkpoint_result_fail(self):
|
||||
"""Test failed checkpoint result."""
|
||||
result = CheckpointResult.fail("Something went wrong")
|
||||
assert not result.success
|
||||
assert result.checkpoint is None
|
||||
assert result.error == "Something went wrong"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# CheckpointService Tests
|
||||
# =============================================================================
|
||||
|
||||
class TestCheckpointService:
|
||||
"""Tests for CheckpointService class."""
|
||||
|
||||
@pytest.fixture
|
||||
def temp_workspace(self):
|
||||
"""Create a temporary workspace directory."""
|
||||
workspace = tempfile.mkdtemp(prefix="praison_test_")
|
||||
yield workspace
|
||||
# Cleanup
|
||||
if os.path.exists(workspace):
|
||||
shutil.rmtree(workspace)
|
||||
|
||||
@pytest.fixture
|
||||
def temp_storage(self):
|
||||
"""Create a temporary storage directory."""
|
||||
storage = tempfile.mkdtemp(prefix="praison_checkpoints_")
|
||||
yield storage
|
||||
# Cleanup
|
||||
if os.path.exists(storage):
|
||||
shutil.rmtree(storage)
|
||||
|
||||
def test_service_creation(self, temp_workspace, temp_storage):
|
||||
"""Test creating a checkpoint service."""
|
||||
service = CheckpointService(
|
||||
workspace_dir=temp_workspace,
|
||||
storage_dir=temp_storage
|
||||
)
|
||||
assert service.workspace_dir == temp_workspace
|
||||
assert not service.is_initialized
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_service_initialize(self, temp_workspace, temp_storage):
|
||||
"""Test initializing the service."""
|
||||
service = CheckpointService(
|
||||
workspace_dir=temp_workspace,
|
||||
storage_dir=temp_storage
|
||||
)
|
||||
|
||||
result = await service.initialize()
|
||||
|
||||
assert result
|
||||
assert service.is_initialized
|
||||
assert os.path.exists(service.git_dir)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_service_save_checkpoint(self, temp_workspace, temp_storage):
|
||||
"""Test saving a checkpoint."""
|
||||
service = CheckpointService(
|
||||
workspace_dir=temp_workspace,
|
||||
storage_dir=temp_storage
|
||||
)
|
||||
await service.initialize()
|
||||
|
||||
# Create a file
|
||||
test_file = os.path.join(temp_workspace, "test.txt")
|
||||
with open(test_file, "w") as f:
|
||||
f.write("Hello, World!")
|
||||
|
||||
# Save checkpoint
|
||||
result = await service.save("First checkpoint")
|
||||
|
||||
assert result.success
|
||||
assert result.checkpoint is not None
|
||||
assert result.checkpoint.message == "First checkpoint"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_service_save_no_changes(self, temp_workspace, temp_storage):
|
||||
"""Test saving checkpoint with no changes."""
|
||||
service = CheckpointService(
|
||||
workspace_dir=temp_workspace,
|
||||
storage_dir=temp_storage
|
||||
)
|
||||
await service.initialize()
|
||||
|
||||
# Try to save without changes
|
||||
result = await service.save("Empty checkpoint")
|
||||
|
||||
assert not result.success
|
||||
assert "No changes" in result.error
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_service_save_allow_empty(self, temp_workspace, temp_storage):
|
||||
"""Test saving empty checkpoint when allowed."""
|
||||
service = CheckpointService(
|
||||
workspace_dir=temp_workspace,
|
||||
storage_dir=temp_storage
|
||||
)
|
||||
await service.initialize()
|
||||
|
||||
# Save empty checkpoint
|
||||
result = await service.save("Empty checkpoint", allow_empty=True)
|
||||
|
||||
assert result.success
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_service_restore(self, temp_workspace, temp_storage):
|
||||
"""Test restoring to a checkpoint."""
|
||||
service = CheckpointService(
|
||||
workspace_dir=temp_workspace,
|
||||
storage_dir=temp_storage
|
||||
)
|
||||
await service.initialize()
|
||||
|
||||
# Create initial file
|
||||
test_file = os.path.join(temp_workspace, "test.txt")
|
||||
with open(test_file, "w") as f:
|
||||
f.write("Version 1")
|
||||
|
||||
# Save checkpoint
|
||||
result1 = await service.save("Version 1")
|
||||
checkpoint_id = result1.checkpoint.id
|
||||
|
||||
# Modify file
|
||||
with open(test_file, "w") as f:
|
||||
f.write("Version 2")
|
||||
|
||||
# Restore to checkpoint
|
||||
result = await service.restore(checkpoint_id)
|
||||
|
||||
assert result.success
|
||||
|
||||
# Verify file content
|
||||
with open(test_file, "r") as f:
|
||||
content = f.read()
|
||||
assert content == "Version 1"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_service_list_checkpoints(self, temp_workspace, temp_storage):
|
||||
"""Test listing checkpoints."""
|
||||
service = CheckpointService(
|
||||
workspace_dir=temp_workspace,
|
||||
storage_dir=temp_storage
|
||||
)
|
||||
await service.initialize()
|
||||
|
||||
# Create some checkpoints
|
||||
test_file = os.path.join(temp_workspace, "test.txt")
|
||||
|
||||
with open(test_file, "w") as f:
|
||||
f.write("v1")
|
||||
await service.save("Checkpoint 1")
|
||||
|
||||
with open(test_file, "w") as f:
|
||||
f.write("v2")
|
||||
await service.save("Checkpoint 2")
|
||||
|
||||
# List checkpoints
|
||||
checkpoints = await service.list_checkpoints()
|
||||
|
||||
# Should have initial + 2 checkpoints
|
||||
assert len(checkpoints) >= 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_service_diff(self, temp_workspace, temp_storage):
|
||||
"""Test getting diff between checkpoints."""
|
||||
service = CheckpointService(
|
||||
workspace_dir=temp_workspace,
|
||||
storage_dir=temp_storage
|
||||
)
|
||||
await service.initialize()
|
||||
|
||||
# Create file and checkpoint
|
||||
test_file = os.path.join(temp_workspace, "test.txt")
|
||||
with open(test_file, "w") as f:
|
||||
f.write("Hello")
|
||||
await service.save("Initial")
|
||||
|
||||
# Modify file
|
||||
with open(test_file, "w") as f:
|
||||
f.write("Hello World")
|
||||
|
||||
# Get diff
|
||||
diff = await service.diff()
|
||||
|
||||
assert diff.from_checkpoint != ""
|
||||
assert len(diff.files) > 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_service_protected_path(self, temp_storage):
|
||||
"""Test that protected paths are rejected."""
|
||||
service = CheckpointService(
|
||||
workspace_dir=os.path.expanduser("~"),
|
||||
storage_dir=temp_storage
|
||||
)
|
||||
|
||||
result = await service.initialize()
|
||||
|
||||
assert not result
|
||||
assert not service.is_initialized
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_service_event_handlers(self, temp_workspace, temp_storage):
|
||||
"""Test event handlers."""
|
||||
service = CheckpointService(
|
||||
workspace_dir=temp_workspace,
|
||||
storage_dir=temp_storage
|
||||
)
|
||||
|
||||
events_received = []
|
||||
|
||||
def on_initialized(data):
|
||||
events_received.append(("initialized", data))
|
||||
|
||||
def on_checkpoint(data):
|
||||
events_received.append(("checkpoint", data))
|
||||
|
||||
service.on(CheckpointEvent.INITIALIZED, on_initialized)
|
||||
service.on(CheckpointEvent.CHECKPOINT_CREATED, on_checkpoint)
|
||||
|
||||
await service.initialize()
|
||||
|
||||
test_file = os.path.join(temp_workspace, "test.txt")
|
||||
with open(test_file, "w") as f:
|
||||
f.write("test")
|
||||
await service.save("Test")
|
||||
|
||||
assert len(events_received) == 2
|
||||
assert events_received[0][0] == "initialized"
|
||||
assert events_received[1][0] == "checkpoint"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_service_cleanup(self, temp_workspace, temp_storage):
|
||||
"""Test service cleanup."""
|
||||
service = CheckpointService(
|
||||
workspace_dir=temp_workspace,
|
||||
storage_dir=temp_storage
|
||||
)
|
||||
await service.initialize()
|
||||
|
||||
await service.cleanup()
|
||||
|
||||
assert not service.is_initialized
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_service_delete_all(self, temp_workspace, temp_storage):
|
||||
"""Test deleting all checkpoints."""
|
||||
service = CheckpointService(
|
||||
workspace_dir=temp_workspace,
|
||||
storage_dir=temp_storage
|
||||
)
|
||||
await service.initialize()
|
||||
|
||||
checkpoint_dir = service.checkpoint_dir
|
||||
assert os.path.exists(checkpoint_dir)
|
||||
|
||||
result = await service.delete_all()
|
||||
|
||||
assert result
|
||||
assert not os.path.exists(checkpoint_dir)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
Loaded 100 of 195 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user