Compare commits

...
4 Commits
75 changed files with 10960 additions and 17 deletions

No files matched your search

+287 -1
View File
@@ -261,6 +261,12 @@ 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) |
| ↳ 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) |
@@ -930,8 +936,191 @@ 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. Field Names Reference (A-I-G-S)
---
### 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:
@@ -2535,6 +2724,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 "praisonai[tools]"` | - | [docs](https://docs.praison.ai/docs/databases/overview) |
| Weaviate | Knowledge | `pip install "praisonai[tools]"` | - | [docs](https://docs.praison.ai/docs/databases/overview) |
| LanceDB | Knowledge | `pip install "praisonai[tools]"` | - | [docs](https://docs.praison.ai/docs/databases/overview) |
| Milvus | Knowledge | `pip install "praisonai[tools]"` | - | [docs](https://docs.praison.ai/docs/databases/overview) |
| PGVector | Knowledge | `pip install "praisonai[tools]"` | - | [docs](https://docs.praison.ai/docs/databases/overview) |
| 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:
+1 -1
View File
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=2.4.0" \
"praisonai>=2.4.2" \
"praisonai[chat]" \
"embedchain[github,youtube]"
+1 -1
View File
@@ -20,7 +20,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=2.4.0" \
"praisonai>=2.4.2" \
"praisonai[ui]" \
"praisonai[chat]" \
"praisonai[realtime]" \
+1 -1
View File
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=2.4.0" \
"praisonai>=2.4.2" \
"praisonai[ui]" \
"praisonai[crewai]"
+93
View File
@@ -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
```
+124
View File
@@ -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
```
+79
View File
@@ -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 ===")
+43
View File
@@ -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")
+83
View File
@@ -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")
+66
View File
@@ -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")
+63
View File
@@ -0,0 +1,63 @@
"""
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
from praisonai.db import PostgresDB
# Create database adapter
db = PostgresDB(
host="localhost",
port=5432,
database="praisonai",
user="postgres",
password="praison123"
)
# Create agent with persistence enabled
agent = Agent(
name="Assistant",
instructions="You are a helpful assistant. Keep responses brief.",
db=db,
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.close()
print("\n=== Session persisted! ===")
print("Run again with same session_id to resume.")
+41
View File
@@ -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")
+67
View File
@@ -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 ===")
+44
View File
@@ -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
View File
@@ -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
View File
@@ -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()
+48
View File
@@ -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
View File
@@ -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
View File
@@ -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()
+40
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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()
@@ -54,6 +54,7 @@ except ImportError:
MCP = None
from .session import Session
from .memory.memory import Memory
from .db import db
# Workflows - import from dedicated workflows module
from .workflows import (
Workflow, WorkflowStep, WorkflowContext, StepResult,
@@ -257,6 +258,7 @@ __all__ = [
'AutoAgents',
'Session',
'Memory',
'db',
'display_interaction',
'display_self_reflection',
'display_instruction',
@@ -252,7 +252,9 @@ 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
):
"""Initialize an Agent instance.
@@ -615,6 +617,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."""
@@ -1957,7 +1964,108 @@ Your Goal: {self.goal}"""
# expand=False
# )
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):
# 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
@@ -2054,6 +2162,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
@@ -2083,6 +2193,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:
@@ -2127,6 +2239,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()
@@ -2166,6 +2280,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)
@@ -2181,6 +2297,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
@@ -2269,11 +2387,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
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "praisonaiagents"
version = "0.2.0"
version = "0.2.1"
description = "Praison AI agents for completing complex tasks with Self Reflection Agents"
readme = "README.md"
requires-python = ">=3.10"
+1 -1
View File
@@ -2422,7 +2422,7 @@ wheels = [
[[package]]
name = "praisonaiagents"
version = "0.2.0"
version = "0.2.1"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },
+287 -1
View File
@@ -261,6 +261,12 @@ 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) |
| ↳ 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) |
@@ -930,8 +936,191 @@ 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. Field Names Reference (A-I-G-S)
---
### 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:
@@ -2535,6 +2724,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 "praisonai[tools]"` | - | [docs](https://docs.praison.ai/docs/databases/overview) |
| Weaviate | Knowledge | `pip install "praisonai[tools]"` | - | [docs](https://docs.praison.ai/docs/databases/overview) |
| LanceDB | Knowledge | `pip install "praisonai[tools]"` | - | [docs](https://docs.praison.ai/docs/databases/overview) |
| Milvus | Knowledge | `pip install "praisonai[tools]"` | - | [docs](https://docs.praison.ai/docs/databases/overview) |
| PGVector | Knowledge | `pip install "praisonai[tools]"` | - | [docs](https://docs.praison.ai/docs/databases/overview) |
| 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:
+2 -2
View File
@@ -3,8 +3,8 @@ class Praisonai < Formula
desc "AI tools for various AI applications"
homepage "https://github.com/MervinPraison/PraisonAI"
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v2.4.0.tar.gz"
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v2.4.0.tar.gz | shasum -a 256`.split.first
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v2.4.2.tar.gz"
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v2.4.2.tar.gz | shasum -a 256`.split.first
license "MIT"
depends_on "[email protected]"
@@ -0,0 +1,637 @@
"""
Persistence CLI feature for PraisonAI.
Provides commands for database persistence configuration and management:
- praisonai persistence doctor - Validate DB connectivity
- praisonai persistence run - Run agent with persistence
- praisonai persistence resume - Resume a session
"""
import os
import sys
import logging
from typing import Optional
logger = logging.getLogger(__name__)
def handle_persistence_command(args: list) -> bool:
"""
Handle persistence subcommands.
Args:
args: Command line arguments after 'persistence'
Returns:
True if command was handled, False otherwise
"""
if not args:
print_persistence_help()
return True
subcommand = args[0].lower()
sub_args = args[1:]
if subcommand == "doctor":
return handle_doctor(sub_args)
elif subcommand == "run":
return handle_run(sub_args)
elif subcommand == "resume":
return handle_resume(sub_args)
elif subcommand == "export":
return handle_export(sub_args)
elif subcommand == "import":
return handle_import(sub_args)
elif subcommand == "migrate":
return handle_migrate(sub_args)
elif subcommand == "status":
return handle_status(sub_args)
elif subcommand in ("--help", "-h", "help"):
print_persistence_help()
return True
else:
print(f"Unknown persistence subcommand: {subcommand}")
print_persistence_help()
return True
def print_persistence_help():
"""Print persistence command help."""
help_text = """
PraisonAI Persistence Commands
==============================
Usage: praisonai persistence <command> [options]
Commands:
doctor Validate database connectivity and configuration
run Run an agent with persistence enabled
resume Resume an existing session
Doctor Command:
praisonai persistence doctor [options]
Options:
--conversation-url URL Conversation store URL (postgres/mysql/sqlite)
--knowledge-url URL Knowledge store URL (qdrant/chroma/pinecone)
--state-url URL State store URL (redis/memory)
--all Test all configured stores
Environment Variables:
PRAISON_CONVERSATION_URL Default conversation store URL
PRAISON_KNOWLEDGE_URL Default knowledge store URL
PRAISON_STATE_URL Default state store URL
Run Command:
praisonai persistence run [options] "prompt"
Options:
--session-id ID Session identifier (auto-generated if not provided)
--user-id ID User identifier (default: "default")
--conversation-url URL Conversation store URL
--knowledge-url URL Knowledge store URL
--state-url URL State store URL
--agent-name NAME Agent name (default: "Assistant")
--agent-instructions TEXT Agent instructions
--dry-run Show configuration without running
Resume Command:
praisonai persistence resume --session-id ID [options]
Options:
--session-id ID Session to resume (required)
--conversation-url URL Conversation store URL
--show-history Display conversation history
--continue "prompt" Continue with a new prompt
Export/Import Commands:
praisonai persistence export --session-id ID [options]
praisonai persistence import --file FILE [options]
Options:
--session-id ID Session to export (required for export)
--conversation-url URL Conversation store URL
--file FILE File path for export/import (JSONL format)
--output FILE Output file path (default: session-{id}.jsonl)
Examples:
# Check PostgreSQL connectivity
praisonai persistence doctor --conversation-url "postgresql://localhost/mydb"
# Run agent with persistence
praisonai persistence run --session-id "my-session" "Hello, remember my name is Alice"
# Resume a session
praisonai persistence resume --session-id "my-session" --show-history
# Continue a session
praisonai persistence resume --session-id "my-session" --continue "What's my name?"
# Export a session
praisonai persistence export --session-id "my-session" --output session.jsonl
# Import a session
praisonai persistence import --file session.jsonl
"""
print(help_text)
def parse_persistence_args(args: list) -> dict:
"""Parse persistence command arguments."""
config = {
"session_id": None,
"user_id": "default",
"conversation_url": os.getenv("PRAISON_CONVERSATION_URL"),
"knowledge_url": os.getenv("PRAISON_KNOWLEDGE_URL"),
"state_url": os.getenv("PRAISON_STATE_URL"),
"agent_name": "Assistant",
"agent_instructions": "You are a helpful assistant.",
"dry_run": False,
"show_history": False,
"all_stores": False,
"prompt": None,
"continue_prompt": None,
"output_file": None,
"input_file": None,
}
i = 0
while i < len(args):
arg = args[i]
if arg == "--session-id" and i + 1 < len(args):
config["session_id"] = args[i + 1]
i += 2
elif arg == "--user-id" and i + 1 < len(args):
config["user_id"] = args[i + 1]
i += 2
elif arg == "--conversation-url" and i + 1 < len(args):
config["conversation_url"] = args[i + 1]
i += 2
elif arg == "--knowledge-url" and i + 1 < len(args):
config["knowledge_url"] = args[i + 1]
i += 2
elif arg == "--state-url" and i + 1 < len(args):
config["state_url"] = args[i + 1]
i += 2
elif arg == "--agent-name" and i + 1 < len(args):
config["agent_name"] = args[i + 1]
i += 2
elif arg == "--agent-instructions" and i + 1 < len(args):
config["agent_instructions"] = args[i + 1]
i += 2
elif arg == "--dry-run":
config["dry_run"] = True
i += 1
elif arg == "--show-history":
config["show_history"] = True
i += 1
elif arg == "--all":
config["all_stores"] = True
i += 1
elif arg == "--continue" and i + 1 < len(args):
config["continue_prompt"] = args[i + 1]
i += 2
elif arg == "--output" and i + 1 < len(args):
config["output_file"] = args[i + 1]
i += 2
elif arg == "--file" and i + 1 < len(args):
config["input_file"] = args[i + 1]
i += 2
elif not arg.startswith("-"):
config["prompt"] = arg
i += 1
else:
i += 1
return config
def handle_doctor(args: list) -> bool:
"""Handle persistence doctor command."""
config = parse_persistence_args(args)
print("=" * 50)
print("PraisonAI Persistence Doctor")
print("=" * 50)
results = []
# Test conversation store
if config["conversation_url"] or config["all_stores"]:
url = config["conversation_url"]
if url:
print(f"\n[Conversation Store] Testing: {_mask_url(url)}")
success, msg = _test_conversation_store(url)
results.append(("Conversation", success, msg))
print(f" {'✅' if success else '❌'} {msg}")
else:
print("\n[Conversation Store] Not configured (set --conversation-url or PRAISON_CONVERSATION_URL)")
# Test knowledge store
if config["knowledge_url"] or config["all_stores"]:
url = config["knowledge_url"]
if url:
print(f"\n[Knowledge Store] Testing: {_mask_url(url)}")
success, msg = _test_knowledge_store(url)
results.append(("Knowledge", success, msg))
print(f" {'✅' if success else '❌'} {msg}")
else:
print("\n[Knowledge Store] Not configured (set --knowledge-url or PRAISON_KNOWLEDGE_URL)")
# Test state store
if config["state_url"] or config["all_stores"]:
url = config["state_url"]
if url:
print(f"\n[State Store] Testing: {_mask_url(url)}")
success, msg = _test_state_store(url)
results.append(("State", success, msg))
print(f" {'✅' if success else '❌'} {msg}")
else:
print("\n[State Store] Not configured (set --state-url or PRAISON_STATE_URL)")
# Summary
print("\n" + "=" * 50)
if results:
passed = sum(1 for _, s, _ in results if s)
total = len(results)
print(f"Results: {passed}/{total} stores connected successfully")
else:
print("No stores configured. Use --conversation-url, --knowledge-url, or --state-url")
print("=" * 50)
return True
def handle_run(args: list) -> bool:
"""Handle persistence run command."""
config = parse_persistence_args(args)
if not config["prompt"]:
print("Error: No prompt provided")
print("Usage: praisonai persistence run [options] \"your prompt\"")
return True
if not config["conversation_url"]:
print("Error: No conversation URL configured")
print("Set --conversation-url or PRAISON_CONVERSATION_URL")
return True
if config["dry_run"]:
print("=" * 50)
print("Dry Run - Configuration")
print("=" * 50)
print(f"Session ID: {config['session_id'] or '(auto-generated)'}")
print(f"User ID: {config['user_id']}")
print(f"Conversation URL: {_mask_url(config['conversation_url'])}")
print(f"Agent Name: {config['agent_name']}")
print(f"Prompt: {config['prompt'][:50]}...")
return True
# Run agent with persistence
try:
from praisonai.db import PraisonDB
from praisonaiagents import Agent
db = PraisonDB(
database_url=config["conversation_url"],
state_url=config["state_url"],
knowledge_url=config["knowledge_url"],
)
agent = Agent(
name=config["agent_name"],
instructions=config["agent_instructions"],
db=db,
session_id=config["session_id"],
verbose=True
)
print(f"\n[Session: {agent.session_id}]")
response = agent.chat(config["prompt"])
print(f"\nAgent: {response}")
db.close()
except Exception as e:
print(f"Error: {e}")
return True
return True
def handle_resume(args: list) -> bool:
"""Handle persistence resume command."""
config = parse_persistence_args(args)
if not config["session_id"]:
print("Error: --session-id is required for resume")
return True
if not config["conversation_url"]:
print("Error: No conversation URL configured")
print("Set --conversation-url or PRAISON_CONVERSATION_URL")
return True
try:
from praisonai.db import PraisonDB
db = PraisonDB(database_url=config["conversation_url"])
# Get session history
history = db.on_agent_start(
agent_name="Resume",
session_id=config["session_id"],
user_id=config["user_id"]
)
print(f"\n[Session: {config['session_id']}]")
print(f"Messages in history: {len(history)}")
if config["show_history"]:
print("\n--- Conversation History ---")
for msg in history:
role = msg.role.upper()
content = msg.content[:100] + "..." if len(msg.content) > 100 else msg.content
print(f"[{role}] {content}")
print("--- End History ---\n")
if config["continue_prompt"]:
from praisonaiagents import Agent
agent = Agent(
name=config["agent_name"],
instructions=config["agent_instructions"],
db=db,
session_id=config["session_id"],
verbose=True
)
response = agent.chat(config["continue_prompt"])
print(f"\nAgent: {response}")
db.close()
except Exception as e:
print(f"Error: {e}")
return True
return True
def handle_export(args: list) -> bool:
"""Handle persistence export command."""
config = parse_persistence_args(args)
if not config["session_id"]:
print("Error: --session-id is required for export")
return True
if not config["conversation_url"]:
print("Error: No conversation URL configured")
return True
try:
from praisonai.db import PraisonDB
import json
db = PraisonDB(database_url=config["conversation_url"])
# Export session
data = db.export_session(config["session_id"])
if not data:
print(f"Error: Session {config['session_id']} not found")
db.close()
return True
# Determine output file
output_file = config.get("output_file") or f"session-{config['session_id']}.jsonl"
with open(output_file, 'w') as f:
f.write(json.dumps(data, default=str) + '\n')
print(f"Exported session to: {output_file}")
print(f"Messages: {len(data.get('messages', []))}")
db.close()
except Exception as e:
print(f"Error: {e}")
return True
return True
def handle_import(args: list) -> bool:
"""Handle persistence import command."""
config = parse_persistence_args(args)
input_file = config.get("input_file")
if not input_file:
print("Error: --file is required for import")
return True
if not config["conversation_url"]:
print("Error: No conversation URL configured")
return True
try:
from praisonai.db import PraisonDB
import json
db = PraisonDB(database_url=config["conversation_url"])
with open(input_file, 'r') as f:
data = json.loads(f.readline())
session_id = db.import_session(data)
print(f"Imported session: {session_id}")
print(f"Messages: {len(data.get('messages', []))}")
db.close()
except Exception as e:
print(f"Error: {e}")
return True
return True
def handle_migrate(args: list) -> bool:
"""Handle persistence migrate command."""
config = parse_persistence_args(args)
if not config["conversation_url"] and not config["state_url"]:
print("Error: No database URL configured")
print("Set --conversation-url or --state-url")
return True
try:
from praisonai.db import PraisonDB
from praisonai.persistence.migrations import MigrationManager
db = PraisonDB(
database_url=config["conversation_url"],
state_url=config["state_url"]
)
manager = MigrationManager(db)
# Check for --up or --down flags
direction = "up"
target = None
for i, arg in enumerate(args):
if arg == "--down":
direction = "down"
elif arg == "--target" and i + 1 < len(args):
target = args[i + 1]
if direction == "up":
print("Applying pending migrations...")
applied = manager.migrate_up(target)
if applied:
print(f"Applied migrations: {', '.join(applied)}")
else:
print("No pending migrations")
else:
if not target:
print("Error: --target required for --down migration")
return True
print(f"Rolling back to version {target}...")
rolled_back = manager.migrate_down(target)
if rolled_back:
print(f"Rolled back: {', '.join(rolled_back)}")
else:
print("No migrations to rollback")
db.close()
except Exception as e:
print(f"Error: {e}")
return True
return True
def handle_status(args: list) -> bool:
"""Handle persistence status command."""
config = parse_persistence_args(args)
if not config["conversation_url"] and not config["state_url"]:
print("Error: No database URL configured")
return True
try:
from praisonai.db import PraisonDB
from praisonai.persistence.migrations import MigrationManager
db = PraisonDB(
database_url=config["conversation_url"],
state_url=config["state_url"]
)
manager = MigrationManager(db)
status = manager.get_migration_status()
print("\n=== Schema Status ===")
print(f"Current version: {status['current_version'] or '(not set)'}")
print(f"Latest version: {status['latest_version']}")
print(f"Pending: {status['pending_count']} migration(s)")
if status['pending_versions']:
print(f"Pending versions: {', '.join(status['pending_versions'])}")
db.close()
except Exception as e:
print(f"Error: {e}")
return True
return True
def _mask_url(url: str) -> str:
"""Mask sensitive parts of URL."""
if not url:
return "(not set)"
# Mask password in URLs
import re
masked = re.sub(r'://([^:]+):([^@]+)@', r'://\1:****@', url)
return masked
def _test_conversation_store(url: str) -> tuple:
"""Test conversation store connectivity."""
try:
from praisonai.persistence.factory import create_conversation_store
# Detect backend
if "postgresql" in url or "postgres" in url:
backend = "postgres"
elif "mysql" in url:
backend = "mysql"
elif url.endswith(".db") or "sqlite" in url:
backend = "sqlite"
else:
backend = "sqlite"
store = create_conversation_store(backend, url=url)
# Try a simple operation
sessions = store.list_sessions(limit=1)
store.close()
return True, f"Connected ({backend})"
except Exception as e:
return False, str(e)
def _test_knowledge_store(url: str) -> tuple:
"""Test knowledge store connectivity."""
try:
from praisonai.persistence.factory import create_knowledge_store
# Detect backend
if "qdrant" in url or ":6333" in url:
backend = "qdrant"
elif "chroma" in url:
backend = "chroma"
else:
backend = "qdrant"
store = create_knowledge_store(backend, url=url)
# Try a simple operation
exists = store.collection_exists("test_doctor")
store.close()
return True, f"Connected ({backend})"
except Exception as e:
return False, str(e)
def _test_state_store(url: str) -> tuple:
"""Test state store connectivity."""
try:
from praisonai.persistence.factory import create_state_store
# Detect backend
if "redis" in url:
backend = "redis"
else:
backend = "memory"
store = create_state_store(backend, url=url)
# Try a simple operation
store.set("_doctor_test", "ok", ttl=5)
value = store.get("_doctor_test")
store.delete("_doctor_test")
store.close()
return True, f"Connected ({backend})"
except Exception as e:
return False, str(e)
+6
View File
@@ -266,6 +266,12 @@ class PraisonAI:
file_input = self.read_file_if_provided(getattr(args, 'file', None))
if args.command:
# Handle persistence command
if args.command == "persistence":
from praisonai.cli.features.persistence import handle_persistence_command
handle_persistence_command(unknown_args)
return None
# Handle schedule command
if args.command == "schedule":
from praisonai.cli.features.agent_scheduler import AgentSchedulerHandler
+1 -1
View File
@@ -57,7 +57,7 @@ class CloudDeployer:
file.write("FROM python:3.11-slim\n")
file.write("WORKDIR /app\n")
file.write("COPY . .\n")
file.write("RUN pip install flask praisonai==2.4.0 gunicorn markdown\n")
file.write("RUN pip install flask praisonai==2.4.2 gunicorn markdown\n")
file.write("EXPOSE 8080\n")
file.write('CMD ["gunicorn", "-b", "0.0.0.0:8080", "api:app"]\n')
@@ -0,0 +1,84 @@
"""
PraisonAI Persistence Layer
Provides database integrations for:
- Conversation history persistence (ConversationStore)
- Knowledge storage and retrieval (KnowledgeStore)
- Session/state management (StateStore)
All integrations use lazy loading to avoid importing unused dependencies.
Supported Backends (22 total):
- ConversationStore (6): postgres, mysql, sqlite, singlestore, supabase, surrealdb
- KnowledgeStore (10): qdrant, pinecone, chroma, weaviate, lancedb, milvus, pgvector, redis, cassandra, clickhouse
- StateStore (6): redis, dynamodb, firestore, mongodb, upstash, memory
"""
from typing import TYPE_CHECKING
__all__ = [
# Base interfaces
"ConversationStore",
"ConversationSession",
"ConversationMessage",
"KnowledgeStore",
"KnowledgeDocument",
"StateStore",
# Orchestrator
"PersistenceOrchestrator",
# Factory functions
"create_conversation_store",
"create_knowledge_store",
"create_state_store",
"create_stores_from_config",
# Config
"PersistenceConfig",
# Agent hooks
"wrap_agent_with_persistence",
"PersistentAgent",
"create_persistent_session",
]
# Lazy imports to avoid loading dependencies until needed
def __getattr__(name: str):
if name in ("ConversationStore", "ConversationSession", "ConversationMessage"):
from .conversation.base import ConversationStore, ConversationSession, ConversationMessage
return locals()[name]
if name in ("KnowledgeStore", "KnowledgeDocument"):
from .knowledge.base import KnowledgeStore, KnowledgeDocument
return locals()[name]
if name == "StateStore":
from .state.base import StateStore
return StateStore
if name == "PersistenceOrchestrator":
from .orchestrator import PersistenceOrchestrator
return PersistenceOrchestrator
if name == "PersistenceConfig":
from .config import PersistenceConfig
return PersistenceConfig
if name == "create_conversation_store":
from .factory import create_conversation_store
return create_conversation_store
if name == "create_knowledge_store":
from .factory import create_knowledge_store
return create_knowledge_store
if name == "create_state_store":
from .factory import create_state_store
return create_state_store
if name == "create_stores_from_config":
from .factory import create_stores_from_config
return create_stores_from_config
if name in ("wrap_agent_with_persistence", "PersistentAgent", "create_persistent_session"):
from .hooks.agent_hooks import wrap_agent_with_persistence, PersistentAgent, create_persistent_session
return locals()[name]
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,238 @@
"""
Configuration handling for PraisonAI persistence layer.
Supports configuration via:
- Environment variables
- Config file (YAML/JSON)
- CLI arguments
- Direct Python API
"""
import os
import logging
from dataclasses import dataclass, field
from typing import Any, Dict, Optional
logger = logging.getLogger(__name__)
# Environment variable mappings
ENV_VARS = {
# Conversation stores
"conversation_store": "PRAISONAI_CONVERSATION_STORE",
"conversation_url": "PRAISONAI_CONVERSATION_URL",
# Knowledge stores
"knowledge_store": "PRAISONAI_KNOWLEDGE_STORE",
"knowledge_url": "PRAISONAI_KNOWLEDGE_URL",
# State stores
"state_store": "PRAISONAI_STATE_STORE",
"state_url": "PRAISONAI_STATE_URL",
# Common
"session_id": "PRAISONAI_SESSION_ID",
"user_id": "PRAISONAI_USER_ID",
# PostgreSQL
"postgres_url": "POSTGRES_URL",
"postgres_host": "POSTGRES_HOST",
"postgres_port": "POSTGRES_PORT",
"postgres_database": "POSTGRES_DATABASE",
"postgres_user": "POSTGRES_USER",
"postgres_password": "POSTGRES_PASSWORD",
# Qdrant
"qdrant_url": "QDRANT_URL",
"qdrant_host": "QDRANT_HOST",
"qdrant_port": "QDRANT_PORT",
"qdrant_api_key": "QDRANT_API_KEY",
# Redis
"redis_url": "REDIS_URL",
"redis_host": "REDIS_HOST",
"redis_port": "REDIS_PORT",
"redis_password": "REDIS_PASSWORD",
# Pinecone
"pinecone_api_key": "PINECONE_API_KEY",
"pinecone_environment": "PINECONE_ENVIRONMENT",
"pinecone_index": "PINECONE_INDEX",
# ChromaDB
"chroma_path": "CHROMA_DB_PATH",
"chroma_host": "CHROMA_HOST",
"chroma_port": "CHROMA_PORT",
# Weaviate
"weaviate_url": "WEAVIATE_URL",
"weaviate_api_key": "WEAVIATE_API_KEY",
# Milvus
"milvus_host": "MILVUS_HOST",
"milvus_port": "MILVUS_PORT",
"milvus_token": "MILVUS_TOKEN",
# MongoDB
"mongodb_url": "MONGODB_URL",
"mongodb_database": "MONGODB_DATABASE",
# DynamoDB
"aws_region": "AWS_REGION",
"dynamodb_table": "DYNAMODB_TABLE",
# Firestore
"google_credentials": "GOOGLE_APPLICATION_CREDENTIALS",
"firestore_project": "FIRESTORE_PROJECT",
# Upstash
"upstash_redis_url": "UPSTASH_REDIS_URL",
"upstash_redis_token": "UPSTASH_REDIS_TOKEN",
"upstash_vector_url": "UPSTASH_VECTOR_URL",
"upstash_vector_token": "UPSTASH_VECTOR_TOKEN",
# Supabase
"supabase_url": "SUPABASE_URL",
"supabase_key": "SUPABASE_KEY",
# SurrealDB
"surrealdb_url": "SURREALDB_URL",
"surrealdb_namespace": "SURREALDB_NS",
"surrealdb_database": "SURREALDB_DB",
# MySQL
"mysql_url": "MYSQL_URL",
"mysql_host": "MYSQL_HOST",
"mysql_port": "MYSQL_PORT",
"mysql_database": "MYSQL_DATABASE",
"mysql_user": "MYSQL_USER",
"mysql_password": "MYSQL_PASSWORD",
# SingleStore
"singlestore_url": "SINGLESTORE_URL",
# ClickHouse
"clickhouse_host": "CLICKHOUSE_HOST",
"clickhouse_port": "CLICKHOUSE_PORT",
"clickhouse_user": "CLICKHOUSE_USER",
"clickhouse_password": "CLICKHOUSE_PASSWORD",
# Cassandra
"cassandra_hosts": "CASSANDRA_HOSTS",
"cassandra_keyspace": "CASSANDRA_KEYSPACE",
# LanceDB
"lancedb_path": "LANCEDB_PATH",
"lancedb_uri": "LANCEDB_URI",
}
# Supported backends
CONVERSATION_BACKENDS = ["postgres", "mysql", "sqlite", "singlestore", "supabase", "surrealdb"]
KNOWLEDGE_BACKENDS = ["qdrant", "pinecone", "chroma", "weaviate", "lancedb", "milvus", "pgvector", "redis", "cassandra", "clickhouse"]
STATE_BACKENDS = ["redis", "dynamodb", "firestore", "mongodb", "upstash", "memory"]
@dataclass
class PersistenceConfig:
"""
Configuration for PraisonAI persistence layer.
Example:
config = PersistenceConfig(
conversation_store="postgres",
conversation_url="postgresql://localhost:5432/praisonai",
knowledge_store="qdrant",
knowledge_url="http://localhost:6333",
state_store="redis",
state_url="redis://localhost:6379",
)
"""
# Store backends
conversation_store: Optional[str] = None
conversation_url: Optional[str] = None
conversation_options: Dict[str, Any] = field(default_factory=dict)
knowledge_store: Optional[str] = None
knowledge_url: Optional[str] = None
knowledge_options: Dict[str, Any] = field(default_factory=dict)
state_store: Optional[str] = None
state_url: Optional[str] = None
state_options: Dict[str, Any] = field(default_factory=dict)
# Session/user context
session_id: Optional[str] = None
user_id: Optional[str] = None
# General options
auto_create_tables: bool = True
lazy_init: bool = True
@classmethod
def from_env(cls) -> "PersistenceConfig":
"""Create config from environment variables."""
return cls(
conversation_store=os.getenv(ENV_VARS["conversation_store"]),
conversation_url=os.getenv(ENV_VARS["conversation_url"]) or os.getenv(ENV_VARS["postgres_url"]),
knowledge_store=os.getenv(ENV_VARS["knowledge_store"]),
knowledge_url=os.getenv(ENV_VARS["knowledge_url"]) or os.getenv(ENV_VARS["qdrant_url"]),
state_store=os.getenv(ENV_VARS["state_store"]),
state_url=os.getenv(ENV_VARS["state_url"]) or os.getenv(ENV_VARS["redis_url"]),
session_id=os.getenv(ENV_VARS["session_id"]),
user_id=os.getenv(ENV_VARS["user_id"]),
)
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "PersistenceConfig":
"""Create config from dictionary."""
return cls(**{k: v for k, v in data.items() if hasattr(cls, k)})
@classmethod
def from_yaml(cls, path: str) -> "PersistenceConfig":
"""Load config from YAML file."""
try:
import yaml
except ImportError:
raise ImportError("PyYAML required for YAML config. Install with: pip install pyyaml")
with open(path) as f:
data = yaml.safe_load(f)
# Handle nested persistence config
if "persistence" in data:
data = data["persistence"]
return cls.from_dict(data)
def to_dict(self) -> Dict[str, Any]:
"""Convert config to dictionary."""
return {
"conversation_store": self.conversation_store,
"conversation_url": self.conversation_url,
"conversation_options": self.conversation_options,
"knowledge_store": self.knowledge_store,
"knowledge_url": self.knowledge_url,
"knowledge_options": self.knowledge_options,
"state_store": self.state_store,
"state_url": self.state_url,
"state_options": self.state_options,
"session_id": self.session_id,
"user_id": self.user_id,
"auto_create_tables": self.auto_create_tables,
"lazy_init": self.lazy_init,
}
def validate(self) -> bool:
"""Validate configuration."""
valid = True
if self.conversation_store and self.conversation_store not in CONVERSATION_BACKENDS:
logger.error(f"Invalid conversation_store: {self.conversation_store}. "
f"Supported: {CONVERSATION_BACKENDS}")
valid = False
if self.knowledge_store and self.knowledge_store not in KNOWLEDGE_BACKENDS:
logger.error(f"Invalid knowledge_store: {self.knowledge_store}. "
f"Supported: {KNOWLEDGE_BACKENDS}")
valid = False
if self.state_store and self.state_store not in STATE_BACKENDS:
logger.error(f"Invalid state_store: {self.state_store}. "
f"Supported: {STATE_BACKENDS}")
valid = False
return valid
def get_env_var(key: str, default: Optional[str] = None) -> Optional[str]:
"""Get environment variable by config key name."""
env_name = ENV_VARS.get(key, key.upper())
return os.getenv(env_name, default)
def list_available_backends() -> Dict[str, list]:
"""List all available backends by store type."""
return {
"conversation": CONVERSATION_BACKENDS,
"knowledge": KNOWLEDGE_BACKENDS,
"state": STATE_BACKENDS,
}
@@ -0,0 +1,25 @@
"""
ConversationStore implementations for session and message persistence.
Supported backends:
- PostgreSQL (postgres)
- MySQL (mysql)
- SQLite (sqlite)
- SingleStore (singlestore)
- Supabase (supabase)
- SurrealDB (surrealdb)
"""
from typing import TYPE_CHECKING
__all__ = [
"ConversationStore",
"ConversationSession",
"ConversationMessage",
]
def __getattr__(name: str):
if name in ("ConversationStore", "ConversationSession", "ConversationMessage"):
from .base import ConversationStore, ConversationSession, ConversationMessage
return locals()[name]
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,151 @@
"""
Base interfaces for ConversationStore.
ConversationStore handles session and message persistence for conversation history.
"""
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
import time
import uuid
@dataclass
class ConversationMessage:
"""A single message in a conversation."""
id: str = field(default_factory=lambda: str(uuid.uuid4()))
session_id: str = ""
role: str = "" # user, assistant, system, tool
content: str = ""
tool_calls: Optional[List[Dict[str, Any]]] = None
tool_call_id: Optional[str] = None
metadata: Optional[Dict[str, Any]] = None
created_at: float = field(default_factory=time.time)
def to_dict(self) -> Dict[str, Any]:
return {
"id": self.id,
"session_id": self.session_id,
"role": self.role,
"content": self.content,
"tool_calls": self.tool_calls,
"tool_call_id": self.tool_call_id,
"metadata": self.metadata,
"created_at": self.created_at,
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "ConversationMessage":
return cls(**{k: v for k, v in data.items() if k in cls.__dataclass_fields__})
@dataclass
class ConversationSession:
"""A conversation session containing messages."""
session_id: str = field(default_factory=lambda: str(uuid.uuid4()))
user_id: Optional[str] = None
agent_id: Optional[str] = None
name: Optional[str] = None
state: Optional[Dict[str, Any]] = None
metadata: Optional[Dict[str, Any]] = None
created_at: float = field(default_factory=time.time)
updated_at: float = field(default_factory=time.time)
def to_dict(self) -> Dict[str, Any]:
return {
"session_id": self.session_id,
"user_id": self.user_id,
"agent_id": self.agent_id,
"name": self.name,
"state": self.state,
"metadata": self.metadata,
"created_at": self.created_at,
"updated_at": self.updated_at,
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "ConversationSession":
return cls(**{k: v for k, v in data.items() if k in cls.__dataclass_fields__})
class ConversationStore(ABC):
"""
Abstract base class for conversation persistence.
Implementations handle session and message storage for different backends:
- PostgreSQL, MySQL, SQLite (relational)
- SingleStore, Supabase, SurrealDB (hybrid)
"""
@abstractmethod
def create_session(self, session: ConversationSession) -> ConversationSession:
"""Create a new session."""
raise NotImplementedError
@abstractmethod
def get_session(self, session_id: str) -> Optional[ConversationSession]:
"""Get a session by ID."""
raise NotImplementedError
@abstractmethod
def update_session(self, session: ConversationSession) -> ConversationSession:
"""Update an existing session."""
raise NotImplementedError
@abstractmethod
def delete_session(self, session_id: str) -> bool:
"""Delete a session and all its messages."""
raise NotImplementedError
@abstractmethod
def list_sessions(
self,
user_id: Optional[str] = None,
agent_id: Optional[str] = None,
limit: int = 100,
offset: int = 0
) -> List[ConversationSession]:
"""List sessions, optionally filtered by user or agent."""
raise NotImplementedError
@abstractmethod
def add_message(self, session_id: str, message: ConversationMessage) -> ConversationMessage:
"""Add a message to a session."""
raise NotImplementedError
@abstractmethod
def get_messages(
self,
session_id: str,
limit: Optional[int] = None,
before: Optional[float] = None,
after: Optional[float] = None
) -> List[ConversationMessage]:
"""Get messages from a session."""
raise NotImplementedError
@abstractmethod
def delete_messages(self, session_id: str, message_ids: Optional[List[str]] = None) -> int:
"""Delete messages. If message_ids is None, delete all messages in session."""
raise NotImplementedError
def upsert_session(self, session: ConversationSession) -> ConversationSession:
"""Create or update a session."""
existing = self.get_session(session.session_id)
if existing:
session.updated_at = time.time()
return self.update_session(session)
return self.create_session(session)
@abstractmethod
def close(self) -> None:
"""Close the store and release resources."""
raise NotImplementedError
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.close()
return False
@@ -0,0 +1,387 @@
"""
MySQL implementation of ConversationStore.
Requires: mysql-connector-python or pymysql
Install: pip install mysql-connector-python
"""
import json
import logging
from typing import Any, List, Optional
from .base import ConversationStore, ConversationSession, ConversationMessage
logger = logging.getLogger(__name__)
class MySQLConversationStore(ConversationStore):
"""
MySQL-based conversation store.
Example:
store = MySQLConversationStore(
url="mysql://user:password@localhost:3306/praisonai"
)
"""
SCHEMA_VERSION = "1.0.0"
def __init__(
self,
url: Optional[str] = None,
host: str = "localhost",
port: int = 3306,
database: str = "praisonai",
user: str = "root",
password: str = "",
table_prefix: str = "praison_",
auto_create_tables: bool = True,
pool_size: int = 5,
):
"""
Initialize MySQL conversation store.
Args:
url: Full connection URL (overrides individual params)
host: Database host
port: Database port
database: Database name
user: Database user
password: Database password
table_prefix: Prefix for table names
auto_create_tables: Create tables if they don't exist
pool_size: Connection pool size
"""
try:
import mysql.connector
from mysql.connector import pooling
except ImportError:
raise ImportError(
"mysql-connector-python is required for MySQL support. "
"Install with: pip install mysql-connector-python"
)
self._mysql = mysql.connector
self.table_prefix = table_prefix
self.sessions_table = f"{table_prefix}sessions"
self.messages_table = f"{table_prefix}messages"
# Parse URL if provided
if url:
# Parse mysql://user:pass@host:port/database
from urllib.parse import urlparse
parsed = urlparse(url)
host = parsed.hostname or host
port = parsed.port or port
database = parsed.path.lstrip("/") or database
user = parsed.username or user
password = parsed.password or password
# Create connection pool
self._pool = pooling.MySQLConnectionPool(
pool_name="praison_mysql_pool",
pool_size=pool_size,
host=host,
port=port,
database=database,
user=user,
password=password,
)
if auto_create_tables:
self._create_tables()
def _get_conn(self):
"""Get a connection from the pool."""
return self._pool.get_connection()
def _create_tables(self) -> None:
"""Create tables if they don't exist."""
conn = self._get_conn()
try:
cur = conn.cursor()
# Sessions table
cur.execute(f"""
CREATE TABLE IF NOT EXISTS {self.sessions_table} (
session_id VARCHAR(255) PRIMARY KEY,
user_id VARCHAR(255),
agent_id VARCHAR(255),
name VARCHAR(255),
state JSON,
metadata JSON,
created_at DOUBLE,
updated_at DOUBLE,
INDEX idx_user (user_id),
INDEX idx_agent (agent_id),
INDEX idx_updated (updated_at DESC)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
""")
# Messages table
cur.execute(f"""
CREATE TABLE IF NOT EXISTS {self.messages_table} (
id VARCHAR(255) PRIMARY KEY,
session_id VARCHAR(255) NOT NULL,
role VARCHAR(50) NOT NULL,
content TEXT,
tool_calls JSON,
tool_call_id VARCHAR(255),
metadata JSON,
created_at DOUBLE,
INDEX idx_session (session_id),
INDEX idx_created (session_id, created_at),
FOREIGN KEY (session_id) REFERENCES {self.sessions_table}(session_id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
""")
conn.commit()
logger.info(f"MySQL tables created: {self.sessions_table}, {self.messages_table}")
finally:
cur.close()
conn.close()
def create_session(self, session: ConversationSession) -> ConversationSession:
"""Create a new session."""
conn = self._get_conn()
try:
cur = conn.cursor()
cur.execute(f"""
INSERT INTO {self.sessions_table}
(session_id, user_id, agent_id, name, state, metadata, created_at, updated_at)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
""", (
session.session_id,
session.user_id,
session.agent_id,
session.name,
json.dumps(session.state) if session.state else None,
json.dumps(session.metadata) if session.metadata else None,
session.created_at,
session.updated_at,
))
conn.commit()
return session
finally:
cur.close()
conn.close()
def get_session(self, session_id: str) -> Optional[ConversationSession]:
"""Get a session by ID."""
conn = self._get_conn()
try:
cur = conn.cursor(dictionary=True)
cur.execute(f"""
SELECT * FROM {self.sessions_table} WHERE session_id = %s
""", (session_id,))
row = cur.fetchone()
if not row:
return None
return ConversationSession(
session_id=row["session_id"],
user_id=row["user_id"],
agent_id=row["agent_id"],
name=row["name"],
state=row["state"] if isinstance(row["state"], dict) else (json.loads(row["state"]) if row["state"] else None),
metadata=row["metadata"] if isinstance(row["metadata"], dict) else (json.loads(row["metadata"]) if row["metadata"] else None),
created_at=row["created_at"],
updated_at=row["updated_at"],
)
finally:
cur.close()
conn.close()
def update_session(self, session: ConversationSession) -> ConversationSession:
"""Update an existing session."""
conn = self._get_conn()
try:
cur = conn.cursor()
cur.execute(f"""
UPDATE {self.sessions_table}
SET user_id = %s, agent_id = %s, name = %s,
state = %s, metadata = %s, updated_at = %s
WHERE session_id = %s
""", (
session.user_id,
session.agent_id,
session.name,
json.dumps(session.state) if session.state else None,
json.dumps(session.metadata) if session.metadata else None,
session.updated_at,
session.session_id,
))
conn.commit()
return session
finally:
cur.close()
conn.close()
def delete_session(self, session_id: str) -> bool:
"""Delete a session and all its messages."""
conn = self._get_conn()
try:
cur = conn.cursor()
cur.execute(f"""
DELETE FROM {self.sessions_table} WHERE session_id = %s
""", (session_id,))
deleted = cur.rowcount > 0
conn.commit()
return deleted
finally:
cur.close()
conn.close()
def list_sessions(
self,
user_id: Optional[str] = None,
agent_id: Optional[str] = None,
limit: int = 100,
offset: int = 0
) -> List[ConversationSession]:
"""List sessions."""
conn = self._get_conn()
try:
cur = conn.cursor(dictionary=True)
conditions = []
params: List[Any] = []
if user_id:
conditions.append("user_id = %s")
params.append(user_id)
if agent_id:
conditions.append("agent_id = %s")
params.append(agent_id)
where_clause = ""
if conditions:
where_clause = "WHERE " + " AND ".join(conditions)
params.extend([limit, offset])
cur.execute(f"""
SELECT * FROM {self.sessions_table}
{where_clause}
ORDER BY updated_at DESC
LIMIT %s OFFSET %s
""", params)
return [
ConversationSession(
session_id=row["session_id"],
user_id=row["user_id"],
agent_id=row["agent_id"],
name=row["name"],
state=row["state"] if isinstance(row["state"], dict) else (json.loads(row["state"]) if row["state"] else None),
metadata=row["metadata"] if isinstance(row["metadata"], dict) else (json.loads(row["metadata"]) if row["metadata"] else None),
created_at=row["created_at"],
updated_at=row["updated_at"],
)
for row in cur.fetchall()
]
finally:
cur.close()
conn.close()
def add_message(self, session_id: str, message: ConversationMessage) -> ConversationMessage:
"""Add a message to a session."""
message.session_id = session_id
conn = self._get_conn()
try:
cur = conn.cursor()
cur.execute(f"""
INSERT INTO {self.messages_table}
(id, session_id, role, content, tool_calls, tool_call_id, metadata, created_at)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
""", (
message.id,
session_id,
message.role,
message.content,
json.dumps(message.tool_calls) if message.tool_calls else None,
message.tool_call_id,
json.dumps(message.metadata) if message.metadata else None,
message.created_at,
))
conn.commit()
return message
finally:
cur.close()
conn.close()
def get_messages(
self,
session_id: str,
limit: Optional[int] = None,
before: Optional[float] = None,
after: Optional[float] = None
) -> List[ConversationMessage]:
"""Get messages from a session."""
conn = self._get_conn()
try:
cur = conn.cursor(dictionary=True)
conditions = ["session_id = %s"]
params: List[Any] = [session_id]
if before:
conditions.append("created_at < %s")
params.append(before)
if after:
conditions.append("created_at > %s")
params.append(after)
where_clause = "WHERE " + " AND ".join(conditions)
limit_clause = f"LIMIT {limit}" if limit else ""
cur.execute(f"""
SELECT * FROM {self.messages_table}
{where_clause}
ORDER BY created_at ASC
{limit_clause}
""", params)
return [
ConversationMessage(
id=row["id"],
session_id=row["session_id"],
role=row["role"],
content=row["content"],
tool_calls=row["tool_calls"] if isinstance(row["tool_calls"], list) else (json.loads(row["tool_calls"]) if row["tool_calls"] else None),
tool_call_id=row["tool_call_id"],
metadata=row["metadata"] if isinstance(row["metadata"], dict) else (json.loads(row["metadata"]) if row["metadata"] else None),
created_at=row["created_at"],
)
for row in cur.fetchall()
]
finally:
cur.close()
conn.close()
def delete_messages(self, session_id: str, message_ids: Optional[List[str]] = None) -> int:
"""Delete messages."""
conn = self._get_conn()
try:
cur = conn.cursor()
if message_ids:
placeholders = ",".join(["%s"] * len(message_ids))
cur.execute(f"""
DELETE FROM {self.messages_table}
WHERE session_id = %s AND id IN ({placeholders})
""", [session_id] + message_ids)
else:
cur.execute(f"""
DELETE FROM {self.messages_table} WHERE session_id = %s
""", (session_id,))
deleted = cur.rowcount
conn.commit()
return deleted
finally:
cur.close()
conn.close()
def close(self) -> None:
"""Close the store."""
pass # Pool handles cleanup
@@ -0,0 +1,401 @@
"""
PostgreSQL implementation of ConversationStore.
Requires: psycopg2-binary or psycopg2
Install: pip install psycopg2-binary
"""
import json
import logging
from typing import Any, Dict, List, Optional
from .base import ConversationStore, ConversationSession, ConversationMessage
logger = logging.getLogger(__name__)
class PostgresConversationStore(ConversationStore):
"""
PostgreSQL-based conversation store.
Connection URL format: postgresql://user:password@host:port/database
Example:
store = PostgresConversationStore(
url="postgresql://postgres:password@localhost:5432/praisonai"
)
"""
SCHEMA_VERSION = "1.0.0"
def __init__(
self,
url: Optional[str] = None,
host: str = "localhost",
port: int = 5432,
database: str = "praisonai",
user: str = "postgres",
password: str = "",
schema: str = "public",
table_prefix: str = "praison_",
auto_create_tables: bool = True,
pool_size: int = 5,
):
"""
Initialize PostgreSQL conversation store.
Args:
url: Full connection URL (overrides individual params)
host: Database host
port: Database port
database: Database name
user: Database user
password: Database password
schema: PostgreSQL schema
table_prefix: Prefix for table names
auto_create_tables: Create tables if they don't exist
pool_size: Connection pool size
"""
try:
import psycopg2
from psycopg2 import pool as pg_pool
from psycopg2.extras import RealDictCursor
except ImportError:
raise ImportError(
"psycopg2 is required for PostgreSQL support. "
"Install with: pip install psycopg2-binary"
)
self._psycopg2 = psycopg2
self._RealDictCursor = RealDictCursor
self.schema = schema
self.table_prefix = table_prefix
self.sessions_table = f"{schema}.{table_prefix}sessions"
self.messages_table = f"{schema}.{table_prefix}messages"
# Build connection params
if url:
self._pool = pg_pool.ThreadedConnectionPool(1, pool_size, url)
else:
self._pool = pg_pool.ThreadedConnectionPool(
1, pool_size,
host=host,
port=port,
database=database,
user=user,
password=password,
)
if auto_create_tables:
self._create_tables()
def _get_conn(self):
"""Get a connection from the pool."""
return self._pool.getconn()
def _put_conn(self, conn):
"""Return a connection to the pool."""
self._pool.putconn(conn)
def _create_tables(self) -> None:
"""Create tables if they don't exist."""
conn = self._get_conn()
try:
with conn.cursor() as cur:
# Create schema if not exists
cur.execute(f"CREATE SCHEMA IF NOT EXISTS {self.schema}")
# Sessions table
cur.execute(f"""
CREATE TABLE IF NOT EXISTS {self.sessions_table} (
session_id VARCHAR(255) PRIMARY KEY,
user_id VARCHAR(255),
agent_id VARCHAR(255),
name VARCHAR(255),
state JSONB,
metadata JSONB,
created_at DOUBLE PRECISION,
updated_at DOUBLE PRECISION
)
""")
# Sessions indexes
cur.execute(f"""
CREATE INDEX IF NOT EXISTS idx_{self.table_prefix}sessions_user
ON {self.sessions_table}(user_id)
""")
cur.execute(f"""
CREATE INDEX IF NOT EXISTS idx_{self.table_prefix}sessions_agent
ON {self.sessions_table}(agent_id)
""")
cur.execute(f"""
CREATE INDEX IF NOT EXISTS idx_{self.table_prefix}sessions_updated
ON {self.sessions_table}(updated_at DESC)
""")
# Messages table
cur.execute(f"""
CREATE TABLE IF NOT EXISTS {self.messages_table} (
id VARCHAR(255) PRIMARY KEY,
session_id VARCHAR(255) NOT NULL
REFERENCES {self.sessions_table}(session_id) ON DELETE CASCADE,
role VARCHAR(50) NOT NULL,
content TEXT,
tool_calls JSONB,
tool_call_id VARCHAR(255),
metadata JSONB,
created_at DOUBLE PRECISION DEFAULT EXTRACT(EPOCH FROM NOW())
)
""")
# Messages indexes
cur.execute(f"""
CREATE INDEX IF NOT EXISTS idx_{self.table_prefix}messages_session
ON {self.messages_table}(session_id)
""")
cur.execute(f"""
CREATE INDEX IF NOT EXISTS idx_{self.table_prefix}messages_created
ON {self.messages_table}(session_id, created_at)
""")
conn.commit()
logger.info(f"PostgreSQL tables created: {self.sessions_table}, {self.messages_table}")
finally:
self._put_conn(conn)
def create_session(self, session: ConversationSession) -> ConversationSession:
"""Create a new session."""
conn = self._get_conn()
try:
with conn.cursor() as cur:
cur.execute(f"""
INSERT INTO {self.sessions_table}
(session_id, user_id, agent_id, name, state, metadata, created_at, updated_at)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
RETURNING session_id
""", (
session.session_id,
session.user_id,
session.agent_id,
session.name,
json.dumps(session.state) if session.state else None,
json.dumps(session.metadata) if session.metadata else None,
session.created_at,
session.updated_at,
))
conn.commit()
return session
finally:
self._put_conn(conn)
def get_session(self, session_id: str) -> Optional[ConversationSession]:
"""Get a session by ID."""
conn = self._get_conn()
try:
with conn.cursor(cursor_factory=self._RealDictCursor) as cur:
cur.execute(f"""
SELECT * FROM {self.sessions_table} WHERE session_id = %s
""", (session_id,))
row = cur.fetchone()
if not row:
return None
return ConversationSession(
session_id=row["session_id"],
user_id=row["user_id"],
agent_id=row["agent_id"],
name=row["name"],
state=row["state"],
metadata=row["metadata"],
created_at=row["created_at"],
updated_at=row["updated_at"],
)
finally:
self._put_conn(conn)
def update_session(self, session: ConversationSession) -> ConversationSession:
"""Update an existing session."""
conn = self._get_conn()
try:
with conn.cursor() as cur:
cur.execute(f"""
UPDATE {self.sessions_table}
SET user_id = %s, agent_id = %s, name = %s,
state = %s, metadata = %s, updated_at = %s
WHERE session_id = %s
""", (
session.user_id,
session.agent_id,
session.name,
json.dumps(session.state) if session.state else None,
json.dumps(session.metadata) if session.metadata else None,
session.updated_at,
session.session_id,
))
conn.commit()
return session
finally:
self._put_conn(conn)
def delete_session(self, session_id: str) -> bool:
"""Delete a session and all its messages."""
conn = self._get_conn()
try:
with conn.cursor() as cur:
cur.execute(f"""
DELETE FROM {self.sessions_table} WHERE session_id = %s
""", (session_id,))
deleted = cur.rowcount > 0
conn.commit()
return deleted
finally:
self._put_conn(conn)
def list_sessions(
self,
user_id: Optional[str] = None,
agent_id: Optional[str] = None,
limit: int = 100,
offset: int = 0
) -> List[ConversationSession]:
"""List sessions, optionally filtered by user or agent."""
conn = self._get_conn()
try:
with conn.cursor(cursor_factory=self._RealDictCursor) as cur:
conditions = []
params = []
if user_id:
conditions.append("user_id = %s")
params.append(user_id)
if agent_id:
conditions.append("agent_id = %s")
params.append(agent_id)
where_clause = ""
if conditions:
where_clause = "WHERE " + " AND ".join(conditions)
params.extend([limit, offset])
cur.execute(f"""
SELECT * FROM {self.sessions_table}
{where_clause}
ORDER BY updated_at DESC
LIMIT %s OFFSET %s
""", params)
return [
ConversationSession(
session_id=row["session_id"],
user_id=row["user_id"],
agent_id=row["agent_id"],
name=row["name"],
state=row["state"],
metadata=row["metadata"],
created_at=row["created_at"],
updated_at=row["updated_at"],
)
for row in cur.fetchall()
]
finally:
self._put_conn(conn)
def add_message(self, session_id: str, message: ConversationMessage) -> ConversationMessage:
"""Add a message to a session."""
message.session_id = session_id
conn = self._get_conn()
try:
with conn.cursor() as cur:
cur.execute(f"""
INSERT INTO {self.messages_table}
(id, session_id, role, content, tool_calls, tool_call_id, metadata, created_at)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
""", (
message.id,
session_id,
message.role,
message.content,
json.dumps(message.tool_calls) if message.tool_calls else None,
message.tool_call_id,
json.dumps(message.metadata) if message.metadata else None,
message.created_at,
))
conn.commit()
return message
finally:
self._put_conn(conn)
def get_messages(
self,
session_id: str,
limit: Optional[int] = None,
before: Optional[float] = None,
after: Optional[float] = None
) -> List[ConversationMessage]:
"""Get messages from a session."""
conn = self._get_conn()
try:
with conn.cursor(cursor_factory=self._RealDictCursor) as cur:
conditions = ["session_id = %s"]
params: List[Any] = [session_id]
if before:
conditions.append("created_at < %s")
params.append(before)
if after:
conditions.append("created_at > %s")
params.append(after)
where_clause = "WHERE " + " AND ".join(conditions)
limit_clause = f"LIMIT {limit}" if limit else ""
cur.execute(f"""
SELECT * FROM {self.messages_table}
{where_clause}
ORDER BY created_at ASC
{limit_clause}
""", params)
return [
ConversationMessage(
id=row["id"],
session_id=row["session_id"],
role=row["role"],
content=row["content"],
tool_calls=row["tool_calls"],
tool_call_id=row["tool_call_id"],
metadata=row["metadata"],
created_at=row["created_at"],
)
for row in cur.fetchall()
]
finally:
self._put_conn(conn)
def delete_messages(self, session_id: str, message_ids: Optional[List[str]] = None) -> int:
"""Delete messages. If message_ids is None, delete all messages in session."""
conn = self._get_conn()
try:
with conn.cursor() as cur:
if message_ids:
placeholders = ",".join(["%s"] * len(message_ids))
cur.execute(f"""
DELETE FROM {self.messages_table}
WHERE session_id = %s AND id IN ({placeholders})
""", [session_id] + message_ids)
else:
cur.execute(f"""
DELETE FROM {self.messages_table} WHERE session_id = %s
""", (session_id,))
deleted = cur.rowcount
conn.commit()
return deleted
finally:
self._put_conn(conn)
def close(self) -> None:
"""Close the store and release resources."""
if self._pool:
self._pool.closeall()
self._pool = None
@@ -0,0 +1,240 @@
"""
SingleStore implementation of ConversationStore.
Requires: singlestoredb
Install: pip install singlestoredb
"""
import json
import logging
from typing import Any, List, Optional
from .base import ConversationStore, ConversationSession, ConversationMessage
logger = logging.getLogger(__name__)
class SingleStoreConversationStore(ConversationStore):
"""
SingleStore-based conversation store.
SingleStore is MySQL-compatible with additional vector capabilities.
Example:
store = SingleStoreConversationStore(
url="singlestoredb://user:password@host:3306/database"
)
"""
SCHEMA_VERSION = "1.0.0"
def __init__(
self,
url: Optional[str] = None,
host: str = "localhost",
port: int = 3306,
database: str = "praisonai",
user: str = "root",
password: str = "",
table_prefix: str = "praison_",
auto_create_tables: bool = True,
):
try:
import singlestoredb as s2
except ImportError:
raise ImportError(
"singlestoredb is required for SingleStore support. "
"Install with: pip install singlestoredb"
)
self._s2 = s2
self.table_prefix = table_prefix
self.sessions_table = f"{table_prefix}sessions"
self.messages_table = f"{table_prefix}messages"
if url:
self._conn = s2.connect(url)
else:
self._conn = s2.connect(
host=host,
port=port,
database=database,
user=user,
password=password,
)
if auto_create_tables:
self._create_tables()
def _create_tables(self) -> None:
"""Create tables if they don't exist."""
cur = self._conn.cursor()
cur.execute(f"""
CREATE TABLE IF NOT EXISTS {self.sessions_table} (
session_id VARCHAR(255) PRIMARY KEY,
user_id VARCHAR(255),
agent_id VARCHAR(255),
name VARCHAR(255),
state JSON,
metadata JSON,
created_at DOUBLE,
updated_at DOUBLE,
KEY idx_user (user_id),
KEY idx_agent (agent_id)
)
""")
cur.execute(f"""
CREATE TABLE IF NOT EXISTS {self.messages_table} (
id VARCHAR(255) PRIMARY KEY,
session_id VARCHAR(255) NOT NULL,
role VARCHAR(50) NOT NULL,
content TEXT,
tool_calls JSON,
tool_call_id VARCHAR(255),
metadata JSON,
created_at DOUBLE,
KEY idx_session (session_id),
KEY idx_created (session_id, created_at)
)
""")
self._conn.commit()
logger.info(f"SingleStore tables created")
def create_session(self, session: ConversationSession) -> ConversationSession:
cur = self._conn.cursor()
cur.execute(f"""
INSERT INTO {self.sessions_table}
(session_id, user_id, agent_id, name, state, metadata, created_at, updated_at)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
""", (
session.session_id, session.user_id, session.agent_id, session.name,
json.dumps(session.state) if session.state else None,
json.dumps(session.metadata) if session.metadata else None,
session.created_at, session.updated_at,
))
self._conn.commit()
return session
def get_session(self, session_id: str) -> Optional[ConversationSession]:
cur = self._conn.cursor()
cur.execute(f"SELECT * FROM {self.sessions_table} WHERE session_id = %s", (session_id,))
row = cur.fetchone()
if not row:
return None
cols = [d[0] for d in cur.description]
data = dict(zip(cols, row))
return ConversationSession(
session_id=data["session_id"], user_id=data["user_id"],
agent_id=data["agent_id"], name=data["name"],
state=json.loads(data["state"]) if data["state"] else None,
metadata=json.loads(data["metadata"]) if data["metadata"] else None,
created_at=data["created_at"], updated_at=data["updated_at"],
)
def update_session(self, session: ConversationSession) -> ConversationSession:
cur = self._conn.cursor()
cur.execute(f"""
UPDATE {self.sessions_table}
SET user_id=%s, agent_id=%s, name=%s, state=%s, metadata=%s, updated_at=%s
WHERE session_id=%s
""", (
session.user_id, session.agent_id, session.name,
json.dumps(session.state) if session.state else None,
json.dumps(session.metadata) if session.metadata else None,
session.updated_at, session.session_id,
))
self._conn.commit()
return session
def delete_session(self, session_id: str) -> bool:
cur = self._conn.cursor()
cur.execute(f"DELETE FROM {self.messages_table} WHERE session_id = %s", (session_id,))
cur.execute(f"DELETE FROM {self.sessions_table} WHERE session_id = %s", (session_id,))
self._conn.commit()
return cur.rowcount > 0
def list_sessions(self, user_id: Optional[str] = None, agent_id: Optional[str] = None,
limit: int = 100, offset: int = 0) -> List[ConversationSession]:
cur = self._conn.cursor()
conditions, params = [], []
if user_id:
conditions.append("user_id = %s")
params.append(user_id)
if agent_id:
conditions.append("agent_id = %s")
params.append(agent_id)
where = "WHERE " + " AND ".join(conditions) if conditions else ""
params.extend([limit, offset])
cur.execute(f"SELECT * FROM {self.sessions_table} {where} ORDER BY updated_at DESC LIMIT %s OFFSET %s", params)
cols = [d[0] for d in cur.description]
return [ConversationSession(
session_id=dict(zip(cols, r))["session_id"],
user_id=dict(zip(cols, r))["user_id"],
agent_id=dict(zip(cols, r))["agent_id"],
name=dict(zip(cols, r))["name"],
state=json.loads(dict(zip(cols, r))["state"]) if dict(zip(cols, r))["state"] else None,
metadata=json.loads(dict(zip(cols, r))["metadata"]) if dict(zip(cols, r))["metadata"] else None,
created_at=dict(zip(cols, r))["created_at"],
updated_at=dict(zip(cols, r))["updated_at"],
) for r in cur.fetchall()]
def add_message(self, session_id: str, message: ConversationMessage) -> ConversationMessage:
message.session_id = session_id
cur = self._conn.cursor()
cur.execute(f"""
INSERT INTO {self.messages_table}
(id, session_id, role, content, tool_calls, tool_call_id, metadata, created_at)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
""", (
message.id, session_id, message.role, message.content,
json.dumps(message.tool_calls) if message.tool_calls else None,
message.tool_call_id,
json.dumps(message.metadata) if message.metadata else None,
message.created_at,
))
self._conn.commit()
return message
def get_messages(self, session_id: str, limit: Optional[int] = None,
before: Optional[float] = None, after: Optional[float] = None) -> List[ConversationMessage]:
cur = self._conn.cursor()
conditions, params = ["session_id = %s"], [session_id]
if before:
conditions.append("created_at < %s")
params.append(before)
if after:
conditions.append("created_at > %s")
params.append(after)
where = "WHERE " + " AND ".join(conditions)
limit_clause = f"LIMIT {limit}" if limit else ""
cur.execute(f"SELECT * FROM {self.messages_table} {where} ORDER BY created_at ASC {limit_clause}", params)
cols = [d[0] for d in cur.description]
return [ConversationMessage(
id=dict(zip(cols, r))["id"],
session_id=dict(zip(cols, r))["session_id"],
role=dict(zip(cols, r))["role"],
content=dict(zip(cols, r))["content"],
tool_calls=json.loads(dict(zip(cols, r))["tool_calls"]) if dict(zip(cols, r))["tool_calls"] else None,
tool_call_id=dict(zip(cols, r))["tool_call_id"],
metadata=json.loads(dict(zip(cols, r))["metadata"]) if dict(zip(cols, r))["metadata"] else None,
created_at=dict(zip(cols, r))["created_at"],
) for r in cur.fetchall()]
def delete_messages(self, session_id: str, message_ids: Optional[List[str]] = None) -> int:
cur = self._conn.cursor()
if message_ids:
placeholders = ",".join(["%s"] * len(message_ids))
cur.execute(f"DELETE FROM {self.messages_table} WHERE session_id = %s AND id IN ({placeholders})",
[session_id] + message_ids)
else:
cur.execute(f"DELETE FROM {self.messages_table} WHERE session_id = %s", (session_id,))
self._conn.commit()
return cur.rowcount
def close(self) -> None:
if self._conn:
self._conn.close()
self._conn = None
@@ -0,0 +1,341 @@
"""
SQLite implementation of ConversationStore.
Zero external dependencies - uses built-in sqlite3 module.
"""
import json
import logging
import sqlite3
import threading
from typing import Any, List, Optional
from .base import ConversationStore, ConversationSession, ConversationMessage
logger = logging.getLogger(__name__)
class SQLiteConversationStore(ConversationStore):
"""
SQLite-based conversation store.
Zero external dependencies - uses Python's built-in sqlite3.
Example:
store = SQLiteConversationStore(
path="./praisonai.db"
)
"""
SCHEMA_VERSION = "1.0.0"
def __init__(
self,
path: str = "praisonai_conversations.db",
table_prefix: str = "praison_",
auto_create_tables: bool = True,
check_same_thread: bool = False,
):
"""
Initialize SQLite conversation store.
Args:
path: Path to SQLite database file
table_prefix: Prefix for table names
auto_create_tables: Create tables if they don't exist
check_same_thread: SQLite check_same_thread parameter
"""
self.path = path
self.table_prefix = table_prefix
self.sessions_table = f"{table_prefix}sessions"
self.messages_table = f"{table_prefix}messages"
self._check_same_thread = check_same_thread
self._local = threading.local()
if auto_create_tables:
self._create_tables()
def _get_conn(self) -> sqlite3.Connection:
"""Get thread-local connection."""
if not hasattr(self._local, "conn") or self._local.conn is None:
self._local.conn = sqlite3.connect(
self.path,
check_same_thread=self._check_same_thread
)
self._local.conn.row_factory = sqlite3.Row
return self._local.conn
def _create_tables(self) -> None:
"""Create tables if they don't exist."""
conn = self._get_conn()
cur = conn.cursor()
# Sessions table
cur.execute(f"""
CREATE TABLE IF NOT EXISTS {self.sessions_table} (
session_id TEXT PRIMARY KEY,
user_id TEXT,
agent_id TEXT,
name TEXT,
state TEXT,
metadata TEXT,
created_at REAL,
updated_at REAL
)
""")
# Sessions indexes
cur.execute(f"""
CREATE INDEX IF NOT EXISTS idx_{self.table_prefix}sessions_user
ON {self.sessions_table}(user_id)
""")
cur.execute(f"""
CREATE INDEX IF NOT EXISTS idx_{self.table_prefix}sessions_agent
ON {self.sessions_table}(agent_id)
""")
# Messages table
cur.execute(f"""
CREATE TABLE IF NOT EXISTS {self.messages_table} (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
role TEXT NOT NULL,
content TEXT,
tool_calls TEXT,
tool_call_id TEXT,
metadata TEXT,
created_at REAL,
FOREIGN KEY (session_id) REFERENCES {self.sessions_table}(session_id) ON DELETE CASCADE
)
""")
# Messages indexes
cur.execute(f"""
CREATE INDEX IF NOT EXISTS idx_{self.table_prefix}messages_session
ON {self.messages_table}(session_id)
""")
cur.execute(f"""
CREATE INDEX IF NOT EXISTS idx_{self.table_prefix}messages_created
ON {self.messages_table}(session_id, created_at)
""")
# Enable foreign keys
cur.execute("PRAGMA foreign_keys = ON")
conn.commit()
logger.info(f"SQLite tables created: {self.sessions_table}, {self.messages_table}")
def create_session(self, session: ConversationSession) -> ConversationSession:
"""Create a new session."""
conn = self._get_conn()
cur = conn.cursor()
cur.execute(f"""
INSERT INTO {self.sessions_table}
(session_id, user_id, agent_id, name, state, metadata, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""", (
session.session_id,
session.user_id,
session.agent_id,
session.name,
json.dumps(session.state) if session.state else None,
json.dumps(session.metadata) if session.metadata else None,
session.created_at,
session.updated_at,
))
conn.commit()
return session
def get_session(self, session_id: str) -> Optional[ConversationSession]:
"""Get a session by ID."""
conn = self._get_conn()
cur = conn.cursor()
cur.execute(f"""
SELECT * FROM {self.sessions_table} WHERE session_id = ?
""", (session_id,))
row = cur.fetchone()
if not row:
return None
return ConversationSession(
session_id=row["session_id"],
user_id=row["user_id"],
agent_id=row["agent_id"],
name=row["name"],
state=json.loads(row["state"]) if row["state"] else None,
metadata=json.loads(row["metadata"]) if row["metadata"] else None,
created_at=row["created_at"],
updated_at=row["updated_at"],
)
def update_session(self, session: ConversationSession) -> ConversationSession:
"""Update an existing session."""
conn = self._get_conn()
cur = conn.cursor()
cur.execute(f"""
UPDATE {self.sessions_table}
SET user_id = ?, agent_id = ?, name = ?,
state = ?, metadata = ?, updated_at = ?
WHERE session_id = ?
""", (
session.user_id,
session.agent_id,
session.name,
json.dumps(session.state) if session.state else None,
json.dumps(session.metadata) if session.metadata else None,
session.updated_at,
session.session_id,
))
conn.commit()
return session
def delete_session(self, session_id: str) -> bool:
"""Delete a session and all its messages."""
conn = self._get_conn()
cur = conn.cursor()
# Delete messages first (foreign key)
cur.execute(f"DELETE FROM {self.messages_table} WHERE session_id = ?", (session_id,))
cur.execute(f"DELETE FROM {self.sessions_table} WHERE session_id = ?", (session_id,))
deleted = cur.rowcount > 0
conn.commit()
return deleted
def list_sessions(
self,
user_id: Optional[str] = None,
agent_id: Optional[str] = None,
limit: int = 100,
offset: int = 0
) -> List[ConversationSession]:
"""List sessions, optionally filtered by user or agent."""
conn = self._get_conn()
cur = conn.cursor()
conditions = []
params: List[Any] = []
if user_id:
conditions.append("user_id = ?")
params.append(user_id)
if agent_id:
conditions.append("agent_id = ?")
params.append(agent_id)
where_clause = ""
if conditions:
where_clause = "WHERE " + " AND ".join(conditions)
params.extend([limit, offset])
cur.execute(f"""
SELECT * FROM {self.sessions_table}
{where_clause}
ORDER BY updated_at DESC
LIMIT ? OFFSET ?
""", params)
return [
ConversationSession(
session_id=row["session_id"],
user_id=row["user_id"],
agent_id=row["agent_id"],
name=row["name"],
state=json.loads(row["state"]) if row["state"] else None,
metadata=json.loads(row["metadata"]) if row["metadata"] else None,
created_at=row["created_at"],
updated_at=row["updated_at"],
)
for row in cur.fetchall()
]
def add_message(self, session_id: str, message: ConversationMessage) -> ConversationMessage:
"""Add a message to a session."""
message.session_id = session_id
conn = self._get_conn()
cur = conn.cursor()
cur.execute(f"""
INSERT INTO {self.messages_table}
(id, session_id, role, content, tool_calls, tool_call_id, metadata, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""", (
message.id,
session_id,
message.role,
message.content,
json.dumps(message.tool_calls) if message.tool_calls else None,
message.tool_call_id,
json.dumps(message.metadata) if message.metadata else None,
message.created_at,
))
conn.commit()
return message
def get_messages(
self,
session_id: str,
limit: Optional[int] = None,
before: Optional[float] = None,
after: Optional[float] = None
) -> List[ConversationMessage]:
"""Get messages from a session."""
conn = self._get_conn()
cur = conn.cursor()
conditions = ["session_id = ?"]
params: List[Any] = [session_id]
if before:
conditions.append("created_at < ?")
params.append(before)
if after:
conditions.append("created_at > ?")
params.append(after)
where_clause = "WHERE " + " AND ".join(conditions)
limit_clause = f"LIMIT {limit}" if limit else ""
cur.execute(f"""
SELECT * FROM {self.messages_table}
{where_clause}
ORDER BY created_at ASC
{limit_clause}
""", params)
return [
ConversationMessage(
id=row["id"],
session_id=row["session_id"],
role=row["role"],
content=row["content"],
tool_calls=json.loads(row["tool_calls"]) if row["tool_calls"] else None,
tool_call_id=row["tool_call_id"],
metadata=json.loads(row["metadata"]) if row["metadata"] else None,
created_at=row["created_at"],
)
for row in cur.fetchall()
]
def delete_messages(self, session_id: str, message_ids: Optional[List[str]] = None) -> int:
"""Delete messages."""
conn = self._get_conn()
cur = conn.cursor()
if message_ids:
placeholders = ",".join(["?"] * len(message_ids))
cur.execute(f"""
DELETE FROM {self.messages_table}
WHERE session_id = ? AND id IN ({placeholders})
""", [session_id] + message_ids)
else:
cur.execute(f"""
DELETE FROM {self.messages_table} WHERE session_id = ?
""", (session_id,))
deleted = cur.rowcount
conn.commit()
return deleted
def close(self) -> None:
"""Close the store."""
if hasattr(self._local, "conn") and self._local.conn:
self._local.conn.close()
self._local.conn = None
@@ -0,0 +1,203 @@
"""
Supabase implementation of ConversationStore.
Requires: supabase
Install: pip install supabase
"""
import json
import logging
import time
from typing import Any, Dict, List, Optional
from .base import ConversationStore, ConversationSession, ConversationMessage
logger = logging.getLogger(__name__)
class SupabaseConversationStore(ConversationStore):
"""
Supabase-based conversation store.
Uses Supabase's PostgreSQL backend via REST API.
Example:
store = SupabaseConversationStore(
url="https://xxx.supabase.co",
key="your-anon-key"
)
"""
SCHEMA_VERSION = "1.0.0"
def __init__(
self,
url: Optional[str] = None,
key: Optional[str] = None,
table_prefix: str = "praison_",
auto_create_tables: bool = False, # Tables should be created via Supabase dashboard
):
"""
Initialize Supabase conversation store.
Args:
url: Supabase project URL
key: Supabase anon/service key
table_prefix: Prefix for table names
auto_create_tables: Not supported - use Supabase dashboard
"""
try:
from supabase import create_client, Client
except ImportError:
raise ImportError(
"supabase is required for Supabase support. "
"Install with: pip install supabase"
)
import os
url = url or os.getenv("SUPABASE_URL")
key = key or os.getenv("SUPABASE_KEY")
if not url or not key:
raise ValueError("Supabase URL and key are required")
self._client: Client = create_client(url, key)
self.table_prefix = table_prefix
self.sessions_table = f"{table_prefix}sessions"
self.messages_table = f"{table_prefix}messages"
if auto_create_tables:
logger.warning("auto_create_tables not supported for Supabase. Create tables via dashboard.")
def create_session(self, session: ConversationSession) -> ConversationSession:
data = {
"session_id": session.session_id,
"user_id": session.user_id,
"agent_id": session.agent_id,
"name": session.name,
"state": session.state,
"metadata": session.metadata,
"created_at": session.created_at,
"updated_at": session.updated_at,
}
self._client.table(self.sessions_table).insert(data).execute()
return session
def get_session(self, session_id: str) -> Optional[ConversationSession]:
result = self._client.table(self.sessions_table).select("*").eq("session_id", session_id).execute()
if not result.data:
return None
row = result.data[0]
return ConversationSession(
session_id=row["session_id"],
user_id=row.get("user_id"),
agent_id=row.get("agent_id"),
name=row.get("name"),
state=row.get("state"),
metadata=row.get("metadata"),
created_at=row.get("created_at"),
updated_at=row.get("updated_at"),
)
def update_session(self, session: ConversationSession) -> ConversationSession:
data = {
"user_id": session.user_id,
"agent_id": session.agent_id,
"name": session.name,
"state": session.state,
"metadata": session.metadata,
"updated_at": session.updated_at,
}
self._client.table(self.sessions_table).update(data).eq("session_id", session.session_id).execute()
return session
def delete_session(self, session_id: str) -> bool:
# Delete messages first
self._client.table(self.messages_table).delete().eq("session_id", session_id).execute()
result = self._client.table(self.sessions_table).delete().eq("session_id", session_id).execute()
return len(result.data) > 0 if result.data else False
def list_sessions(
self,
user_id: Optional[str] = None,
agent_id: Optional[str] = None,
limit: int = 100,
offset: int = 0
) -> List[ConversationSession]:
query = self._client.table(self.sessions_table).select("*")
if user_id:
query = query.eq("user_id", user_id)
if agent_id:
query = query.eq("agent_id", agent_id)
query = query.order("updated_at", desc=True).range(offset, offset + limit - 1)
result = query.execute()
return [
ConversationSession(
session_id=row["session_id"],
user_id=row.get("user_id"),
agent_id=row.get("agent_id"),
name=row.get("name"),
state=row.get("state"),
metadata=row.get("metadata"),
created_at=row.get("created_at"),
updated_at=row.get("updated_at"),
)
for row in (result.data or [])
]
def add_message(self, session_id: str, message: ConversationMessage) -> ConversationMessage:
message.session_id = session_id
data = {
"id": message.id,
"session_id": session_id,
"role": message.role,
"content": message.content,
"tool_calls": message.tool_calls,
"tool_call_id": message.tool_call_id,
"metadata": message.metadata,
"created_at": message.created_at,
}
self._client.table(self.messages_table).insert(data).execute()
return message
def get_messages(
self,
session_id: str,
limit: Optional[int] = None,
before: Optional[float] = None,
after: Optional[float] = None
) -> List[ConversationMessage]:
query = self._client.table(self.messages_table).select("*").eq("session_id", session_id)
if before:
query = query.lt("created_at", before)
if after:
query = query.gt("created_at", after)
query = query.order("created_at", desc=False)
if limit:
query = query.limit(limit)
result = query.execute()
return [
ConversationMessage(
id=row["id"],
session_id=row["session_id"],
role=row["role"],
content=row.get("content", ""),
tool_calls=row.get("tool_calls"),
tool_call_id=row.get("tool_call_id"),
metadata=row.get("metadata"),
created_at=row.get("created_at"),
)
for row in (result.data or [])
]
def delete_messages(self, session_id: str, message_ids: Optional[List[str]] = None) -> int:
if message_ids:
result = self._client.table(self.messages_table).delete().eq("session_id", session_id).in_("id", message_ids).execute()
else:
result = self._client.table(self.messages_table).delete().eq("session_id", session_id).execute()
return len(result.data) if result.data else 0
def close(self) -> None:
pass # Supabase client doesn't need explicit close
@@ -0,0 +1,287 @@
"""
SurrealDB implementation of ConversationStore.
Requires: surrealdb
Install: pip install surrealdb
"""
import json
import logging
from typing import List, Optional
from .base import ConversationStore, ConversationSession, ConversationMessage
logger = logging.getLogger(__name__)
class SurrealDBConversationStore(ConversationStore):
"""
SurrealDB-based conversation store.
SurrealDB is a multi-model database supporting SQL-like queries.
Example:
store = SurrealDBConversationStore(
url="ws://localhost:8000/rpc",
namespace="praisonai",
database="conversations"
)
"""
SCHEMA_VERSION = "1.0.0"
def __init__(
self,
url: str = "ws://localhost:8000/rpc",
namespace: str = "praisonai",
database: str = "conversations",
username: Optional[str] = None,
password: Optional[str] = None,
table_prefix: str = "praison_",
auto_create_tables: bool = True,
):
try:
from surrealdb import Surreal
except ImportError:
raise ImportError(
"surrealdb is required for SurrealDB support. "
"Install with: pip install surrealdb"
)
self._Surreal = Surreal
self.url = url
self.namespace = namespace
self.database = database
self.username = username
self.password = password
self.table_prefix = table_prefix
self.sessions_table = f"{table_prefix}sessions"
self.messages_table = f"{table_prefix}messages"
self._client = None
# Note: SurrealDB Python client is async, we'll use sync wrapper
self._init_sync()
def _init_sync(self):
"""Initialize synchronously using asyncio."""
import asyncio
async def _init():
self._client = self._Surreal(self.url)
await self._client.connect()
if self.username and self.password:
await self._client.signin({"user": self.username, "pass": self.password})
await self._client.use(self.namespace, self.database)
try:
loop = asyncio.get_event_loop()
except RuntimeError:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
loop.run_until_complete(_init())
def _run_sync(self, coro):
"""Run async coroutine synchronously."""
import asyncio
try:
loop = asyncio.get_event_loop()
except RuntimeError:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
return loop.run_until_complete(coro)
def create_session(self, session: ConversationSession) -> ConversationSession:
data = {
"session_id": session.session_id,
"user_id": session.user_id,
"agent_id": session.agent_id,
"name": session.name,
"state": session.state,
"metadata": session.metadata,
"created_at": session.created_at,
"updated_at": session.updated_at,
}
self._run_sync(self._client.create(self.sessions_table, data))
return session
def get_session(self, session_id: str) -> Optional[ConversationSession]:
result = self._run_sync(
self._client.query(
f"SELECT * FROM {self.sessions_table} WHERE session_id = $sid",
{"sid": session_id}
)
)
if not result or not result[0].get("result"):
return None
row = result[0]["result"][0]
return ConversationSession(
session_id=row["session_id"],
user_id=row.get("user_id"),
agent_id=row.get("agent_id"),
name=row.get("name"),
state=row.get("state"),
metadata=row.get("metadata"),
created_at=row.get("created_at"),
updated_at=row.get("updated_at"),
)
def update_session(self, session: ConversationSession) -> ConversationSession:
self._run_sync(
self._client.query(
f"""UPDATE {self.sessions_table} SET
user_id = $user_id, agent_id = $agent_id, name = $name,
state = $state, metadata = $metadata, updated_at = $updated_at
WHERE session_id = $session_id""",
{
"session_id": session.session_id,
"user_id": session.user_id,
"agent_id": session.agent_id,
"name": session.name,
"state": session.state,
"metadata": session.metadata,
"updated_at": session.updated_at,
}
)
)
return session
def delete_session(self, session_id: str) -> bool:
self._run_sync(
self._client.query(
f"DELETE FROM {self.messages_table} WHERE session_id = $sid",
{"sid": session_id}
)
)
result = self._run_sync(
self._client.query(
f"DELETE FROM {self.sessions_table} WHERE session_id = $sid",
{"sid": session_id}
)
)
return True
def list_sessions(
self,
user_id: Optional[str] = None,
agent_id: Optional[str] = None,
limit: int = 100,
offset: int = 0
) -> List[ConversationSession]:
conditions = []
params = {"limit": limit, "offset": offset}
if user_id:
conditions.append("user_id = $user_id")
params["user_id"] = user_id
if agent_id:
conditions.append("agent_id = $agent_id")
params["agent_id"] = agent_id
where = "WHERE " + " AND ".join(conditions) if conditions else ""
result = self._run_sync(
self._client.query(
f"SELECT * FROM {self.sessions_table} {where} ORDER BY updated_at DESC LIMIT $limit START $offset",
params
)
)
if not result or not result[0].get("result"):
return []
return [
ConversationSession(
session_id=row["session_id"],
user_id=row.get("user_id"),
agent_id=row.get("agent_id"),
name=row.get("name"),
state=row.get("state"),
metadata=row.get("metadata"),
created_at=row.get("created_at"),
updated_at=row.get("updated_at"),
)
for row in result[0]["result"]
]
def add_message(self, session_id: str, message: ConversationMessage) -> ConversationMessage:
message.session_id = session_id
data = {
"id": message.id,
"session_id": session_id,
"role": message.role,
"content": message.content,
"tool_calls": message.tool_calls,
"tool_call_id": message.tool_call_id,
"metadata": message.metadata,
"created_at": message.created_at,
}
self._run_sync(self._client.create(self.messages_table, data))
return message
def get_messages(
self,
session_id: str,
limit: Optional[int] = None,
before: Optional[float] = None,
after: Optional[float] = None
) -> List[ConversationMessage]:
conditions = ["session_id = $session_id"]
params = {"session_id": session_id}
if before:
conditions.append("created_at < $before")
params["before"] = before
if after:
conditions.append("created_at > $after")
params["after"] = after
where = "WHERE " + " AND ".join(conditions)
limit_clause = f"LIMIT {limit}" if limit else ""
result = self._run_sync(
self._client.query(
f"SELECT * FROM {self.messages_table} {where} ORDER BY created_at ASC {limit_clause}",
params
)
)
if not result or not result[0].get("result"):
return []
return [
ConversationMessage(
id=row["id"],
session_id=row["session_id"],
role=row["role"],
content=row.get("content", ""),
tool_calls=row.get("tool_calls"),
tool_call_id=row.get("tool_call_id"),
metadata=row.get("metadata"),
created_at=row.get("created_at"),
)
for row in result[0]["result"]
]
def delete_messages(self, session_id: str, message_ids: Optional[List[str]] = None) -> int:
if message_ids:
self._run_sync(
self._client.query(
f"DELETE FROM {self.messages_table} WHERE session_id = $sid AND id IN $ids",
{"sid": session_id, "ids": message_ids}
)
)
return len(message_ids)
else:
self._run_sync(
self._client.query(
f"DELETE FROM {self.messages_table} WHERE session_id = $sid",
{"sid": session_id}
)
)
return -1
def close(self) -> None:
if self._client:
self._run_sync(self._client.close())
self._client = None
@@ -0,0 +1,234 @@
"""
Factory functions for creating store instances.
Provides lazy loading of backend implementations to avoid importing
unused dependencies.
"""
import logging
from typing import Any, Dict, Optional
from .config import PersistenceConfig
logger = logging.getLogger(__name__)
def create_conversation_store(
backend: str,
url: Optional[str] = None,
**options: Any
):
"""
Create a ConversationStore instance.
Args:
backend: Backend type (postgres, mysql, sqlite, singlestore, supabase, surrealdb)
url: Connection URL
**options: Backend-specific options
Returns:
ConversationStore instance
Example:
store = create_conversation_store(
"postgres",
url="postgresql://localhost:5432/praisonai"
)
"""
backend = backend.lower()
if backend == "postgres":
from .conversation.postgres import PostgresConversationStore
return PostgresConversationStore(url=url, **options)
elif backend == "mysql":
from .conversation.mysql import MySQLConversationStore
return MySQLConversationStore(url=url, **options)
elif backend == "sqlite":
from .conversation.sqlite import SQLiteConversationStore
path = url or options.pop("path", None)
return SQLiteConversationStore(path=path, **options)
elif backend == "singlestore":
from .conversation.singlestore import SingleStoreConversationStore
return SingleStoreConversationStore(url=url, **options)
elif backend == "supabase":
from .conversation.supabase import SupabaseConversationStore
return SupabaseConversationStore(url=url, **options)
elif backend == "surrealdb":
from .conversation.surrealdb import SurrealDBConversationStore
return SurrealDBConversationStore(url=url, **options)
else:
raise ValueError(
f"Unknown conversation store backend: {backend}. "
f"Supported: postgres, mysql, sqlite, singlestore, supabase, surrealdb"
)
def create_knowledge_store(
backend: str,
url: Optional[str] = None,
**options: Any
):
"""
Create a KnowledgeStore instance.
Args:
backend: Backend type (qdrant, pinecone, chroma, weaviate, lancedb, milvus, pgvector, redis, cassandra, clickhouse)
url: Connection URL
**options: Backend-specific options
Returns:
KnowledgeStore instance
Example:
store = create_knowledge_store(
"qdrant",
url="http://localhost:6333"
)
"""
backend = backend.lower()
if backend == "qdrant":
from .knowledge.qdrant import QdrantKnowledgeStore
return QdrantKnowledgeStore(url=url, **options)
elif backend == "pinecone":
from .knowledge.pinecone import PineconeKnowledgeStore
return PineconeKnowledgeStore(**options)
elif backend == "chroma":
from .knowledge.chroma import ChromaKnowledgeStore
path = url or options.pop("path", None)
return ChromaKnowledgeStore(path=path, **options)
elif backend == "weaviate":
from .knowledge.weaviate import WeaviateKnowledgeStore
return WeaviateKnowledgeStore(url=url, **options)
elif backend == "lancedb":
from .knowledge.lancedb import LanceDBKnowledgeStore
path = url or options.pop("path", None)
return LanceDBKnowledgeStore(path=path, **options)
elif backend == "milvus":
from .knowledge.milvus import MilvusKnowledgeStore
return MilvusKnowledgeStore(url=url, **options)
elif backend == "pgvector":
from .knowledge.pgvector import PGVectorKnowledgeStore
return PGVectorKnowledgeStore(url=url, **options)
elif backend == "redis":
from .knowledge.redis_vector import RedisVectorKnowledgeStore
return RedisVectorKnowledgeStore(url=url, **options)
elif backend == "cassandra":
from .knowledge.cassandra import CassandraKnowledgeStore
return CassandraKnowledgeStore(**options)
elif backend == "clickhouse":
from .knowledge.clickhouse import ClickHouseKnowledgeStore
return ClickHouseKnowledgeStore(**options)
else:
raise ValueError(
f"Unknown knowledge store backend: {backend}. "
f"Supported: qdrant, pinecone, chroma, weaviate, lancedb, milvus, pgvector, redis, cassandra, clickhouse"
)
def create_state_store(
backend: str,
url: Optional[str] = None,
**options: Any
):
"""
Create a StateStore instance.
Args:
backend: Backend type (redis, dynamodb, firestore, mongodb, upstash, memory)
url: Connection URL
**options: Backend-specific options
Returns:
StateStore instance
Example:
store = create_state_store(
"redis",
url="redis://localhost:6379"
)
"""
backend = backend.lower()
if backend == "redis":
from .state.redis import RedisStateStore
return RedisStateStore(url=url, **options)
elif backend == "dynamodb":
from .state.dynamodb import DynamoDBStateStore
return DynamoDBStateStore(**options)
elif backend == "firestore":
from .state.firestore import FirestoreStateStore
return FirestoreStateStore(**options)
elif backend == "mongodb":
from .state.mongodb import MongoDBStateStore
return MongoDBStateStore(url=url, **options)
elif backend == "upstash":
from .state.upstash import UpstashStateStore
return UpstashStateStore(url=url, **options)
elif backend == "memory":
from .state.memory import MemoryStateStore
return MemoryStateStore(**options)
else:
raise ValueError(
f"Unknown state store backend: {backend}. "
f"Supported: redis, dynamodb, firestore, mongodb, upstash, memory"
)
def create_stores_from_config(config: PersistenceConfig) -> Dict[str, Any]:
"""
Create all configured stores from a PersistenceConfig.
Returns:
Dict with keys: conversation, knowledge, state (values may be None)
"""
stores = {
"conversation": None,
"knowledge": None,
"state": None,
}
if config.conversation_store:
stores["conversation"] = create_conversation_store(
config.conversation_store,
url=config.conversation_url,
**config.conversation_options
)
if config.knowledge_store:
stores["knowledge"] = create_knowledge_store(
config.knowledge_store,
url=config.knowledge_url,
**config.knowledge_options
)
if config.state_store:
stores["state"] = create_state_store(
config.state_store,
url=config.state_url,
**config.state_options
)
return stores
@@ -0,0 +1,18 @@
"""
Agent hooks for automatic persistence integration.
Provides wrapper functions to add persistence capabilities to PraisonAI agents
without modifying the core SDK.
"""
from .agent_hooks import (
wrap_agent_with_persistence,
PersistentAgent,
create_persistent_session,
)
__all__ = [
"wrap_agent_with_persistence",
"PersistentAgent",
"create_persistent_session",
]
@@ -0,0 +1,297 @@
"""
Agent hooks for automatic persistence integration.
Provides wrapper functions to add persistence capabilities to PraisonAI agents
without modifying the core SDK.
"""
import time
import uuid
import logging
from typing import Any, Dict, List, Optional, Callable
from functools import wraps
from ..orchestrator import PersistenceOrchestrator
from ..conversation.base import ConversationMessage, ConversationSession
logger = logging.getLogger(__name__)
def wrap_agent_with_persistence(
agent,
orchestrator: PersistenceOrchestrator,
session_id: Optional[str] = None,
user_id: Optional[str] = None,
auto_resume: bool = True,
):
"""
Wrap an agent to add automatic persistence hooks.
This function wraps the agent's chat method to automatically:
1. Load/create session on first message
2. Persist each user message
3. Persist each assistant response
4. Update session metadata after each interaction
Args:
agent: PraisonAI Agent instance
orchestrator: PersistenceOrchestrator instance
session_id: Session ID (auto-generated if None)
user_id: User ID for the session
auto_resume: Whether to auto-resume existing session
Returns:
The wrapped agent with persistence capabilities
Example:
from praisonai.persistence import PersistenceOrchestrator
from praisonai.persistence.hooks import wrap_agent_with_persistence
from praisonaiagents import Agent
orchestrator = PersistenceOrchestrator(...)
agent = Agent(name="Assistant", role="Helper")
# Wrap agent with persistence
agent = wrap_agent_with_persistence(agent, orchestrator, session_id="my-session")
# Now all chats are automatically persisted
response = agent.chat("Hello!")
"""
session_id = session_id or f"session-{uuid.uuid4().hex[:8]}"
user_id = user_id or getattr(agent, 'user_id', 'default')
# Store original chat method
original_chat = agent.chat
_session_initialized = [False] # Use list to allow mutation in closure
@wraps(original_chat)
def chat_with_persistence(prompt, *args, **kwargs):
nonlocal _session_initialized
# Initialize session on first call
if not _session_initialized[0]:
history = orchestrator.on_agent_start(
agent,
session_id=session_id,
user_id=user_id,
resume=auto_resume
)
# Inject history into agent's chat_history if resuming
if auto_resume and history:
agent.chat_history = [
{"role": msg.role, "content": msg.content}
for msg in history
]
logger.info(f"Resumed session {session_id} with {len(history)} messages")
_session_initialized[0] = True
# Persist user message
orchestrator.on_message(session_id, "user", prompt if isinstance(prompt, str) else str(prompt))
# Call original chat
response = original_chat(prompt, *args, **kwargs)
# Persist assistant response
if response:
orchestrator.on_message(session_id, "assistant", response)
return response
# Replace chat method
agent.chat = chat_with_persistence
# Add persistence-related methods to agent
agent._persistence_orchestrator = orchestrator
agent._persistence_session_id = session_id
def get_session():
"""Get the current session."""
return orchestrator.get_session(session_id)
def get_messages(limit: Optional[int] = None):
"""Get messages from the current session."""
return orchestrator.get_messages(session_id, limit=limit)
def end_session():
"""End the current session."""
orchestrator.on_agent_end(agent, session_id)
agent.get_session = get_session
agent.get_messages = get_messages
agent.end_session = end_session
return agent
class PersistentAgent:
"""
A wrapper class that provides persistence capabilities for any agent.
This is an alternative to wrap_agent_with_persistence that uses composition
instead of monkey-patching.
Example:
from praisonai.persistence import PersistenceOrchestrator
from praisonai.persistence.hooks import PersistentAgent
from praisonaiagents import Agent
orchestrator = PersistenceOrchestrator(...)
base_agent = Agent(name="Assistant", role="Helper")
agent = PersistentAgent(base_agent, orchestrator, session_id="my-session")
response = agent.chat("Hello!")
"""
def __init__(
self,
agent,
orchestrator: PersistenceOrchestrator,
session_id: Optional[str] = None,
user_id: Optional[str] = None,
auto_resume: bool = True,
):
self._agent = agent
self._orchestrator = orchestrator
self._session_id = session_id or f"session-{uuid.uuid4().hex[:8]}"
self._user_id = user_id or getattr(agent, 'user_id', 'default')
self._auto_resume = auto_resume
self._session_initialized = False
def __getattr__(self, name):
"""Delegate attribute access to wrapped agent."""
return getattr(self._agent, name)
def _ensure_session(self):
"""Ensure session is initialized."""
if not self._session_initialized:
history = self._orchestrator.on_agent_start(
self._agent,
session_id=self._session_id,
user_id=self._user_id,
resume=self._auto_resume
)
if self._auto_resume and history:
self._agent.chat_history = [
{"role": msg.role, "content": msg.content}
for msg in history
]
logger.info(f"Resumed session {self._session_id} with {len(history)} messages")
self._session_initialized = True
def chat(self, prompt, *args, **kwargs):
"""Chat with automatic persistence."""
self._ensure_session()
# Persist user message
self._orchestrator.on_message(
self._session_id, "user",
prompt if isinstance(prompt, str) else str(prompt)
)
# Call agent chat
response = self._agent.chat(prompt, *args, **kwargs)
# Persist assistant response
if response:
self._orchestrator.on_message(self._session_id, "assistant", response)
return response
def get_session(self) -> Optional[ConversationSession]:
"""Get the current session."""
return self._orchestrator.get_session(self._session_id)
def get_messages(self, limit: Optional[int] = None) -> List[ConversationMessage]:
"""Get messages from the current session."""
return self._orchestrator.get_messages(self._session_id, limit=limit)
def end_session(self):
"""End the current session."""
self._orchestrator.on_agent_end(self._agent, self._session_id)
@property
def session_id(self) -> str:
"""Get the session ID."""
return self._session_id
def create_persistent_session(
orchestrator: PersistenceOrchestrator,
session_id: Optional[str] = None,
user_id: Optional[str] = None,
):
"""
Create a context manager for persistent sessions.
Example:
from praisonai.persistence import PersistenceOrchestrator
from praisonai.persistence.hooks import create_persistent_session
from praisonaiagents import Agent
orchestrator = PersistenceOrchestrator(...)
with create_persistent_session(orchestrator, session_id="my-session") as session:
agent = Agent(name="Assistant", role="Helper")
# Messages are persisted within the session context
session.persist_message("user", "Hello!")
response = agent.chat("Hello!")
session.persist_message("assistant", response)
"""
class PersistentSession:
def __init__(self, orchestrator, session_id, user_id):
self.orchestrator = orchestrator
self.session_id = session_id or f"session-{uuid.uuid4().hex[:8]}"
self.user_id = user_id or "default"
self._started = False
def __enter__(self):
self.start()
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.end()
return False
def start(self, agent=None):
"""Start the session."""
if not self._started:
class MockAgent:
name = "session-agent"
self.orchestrator.on_agent_start(
agent or MockAgent(),
session_id=self.session_id,
user_id=self.user_id
)
self._started = True
def end(self, agent=None):
"""End the session."""
if self._started:
class MockAgent:
name = "session-agent"
self.orchestrator.on_agent_end(
agent or MockAgent(),
self.session_id
)
def persist_message(self, role: str, content: str):
"""Persist a message to the session."""
self.orchestrator.on_message(self.session_id, role, content)
def get_messages(self, limit: Optional[int] = None):
"""Get messages from the session."""
return self.orchestrator.get_messages(self.session_id, limit=limit)
def get_session(self):
"""Get the session object."""
return self.orchestrator.get_session(self.session_id)
return PersistentSession(orchestrator, session_id, user_id)
@@ -0,0 +1,26 @@
"""
KnowledgeStore implementations for vector embeddings and semantic search.
Supported backends:
- Qdrant (qdrant)
- Pinecone (pinecone)
- ChromaDB (chroma)
- Weaviate (weaviate)
- LanceDB (lancedb)
- Milvus (milvus)
- PGVector (pgvector)
- Redis Vector (redis)
- Cassandra Vector (cassandra)
- ClickHouse Vector (clickhouse)
"""
__all__ = [
"KnowledgeStore",
"KnowledgeDocument",
]
def __getattr__(name: str):
if name in ("KnowledgeStore", "KnowledgeDocument"):
from .base import KnowledgeStore, KnowledgeDocument
return locals()[name]
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,144 @@
"""
Base interfaces for KnowledgeStore.
KnowledgeStore handles vector embeddings and semantic search for RAG.
"""
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
import time
import uuid
import hashlib
@dataclass
class KnowledgeDocument:
"""A document with vector embedding for semantic search."""
id: str = field(default_factory=lambda: str(uuid.uuid4()))
content: str = ""
embedding: Optional[List[float]] = None
metadata: Optional[Dict[str, Any]] = None
content_hash: Optional[str] = None
created_at: float = field(default_factory=time.time)
def __post_init__(self):
if self.content and not self.content_hash:
self.content_hash = hashlib.sha256(self.content.encode()).hexdigest()[:16]
def to_dict(self) -> Dict[str, Any]:
return {
"id": self.id,
"content": self.content,
"embedding": self.embedding,
"metadata": self.metadata,
"content_hash": self.content_hash,
"created_at": self.created_at,
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "KnowledgeDocument":
return cls(**{k: v for k, v in data.items() if k in cls.__dataclass_fields__})
class KnowledgeStore(ABC):
"""
Abstract base class for vector/knowledge persistence.
Implementations handle document storage and semantic search for different backends:
- Qdrant, Pinecone, ChromaDB, Weaviate (dedicated vector DBs)
- LanceDB, Milvus (embedded/distributed vector DBs)
- PGVector, Redis, Cassandra, ClickHouse (vector extensions)
"""
@abstractmethod
def create_collection(
self,
name: str,
dimension: int,
distance: str = "cosine",
metadata: Optional[Dict[str, Any]] = None
) -> None:
"""Create a new collection/index for vectors."""
raise NotImplementedError
@abstractmethod
def delete_collection(self, name: str) -> bool:
"""Delete a collection and all its documents."""
raise NotImplementedError
@abstractmethod
def collection_exists(self, name: str) -> bool:
"""Check if a collection exists."""
raise NotImplementedError
@abstractmethod
def list_collections(self) -> List[str]:
"""List all collections."""
raise NotImplementedError
@abstractmethod
def insert(
self,
collection: str,
documents: List[KnowledgeDocument]
) -> List[str]:
"""Insert documents into a collection. Returns list of IDs."""
raise NotImplementedError
@abstractmethod
def upsert(
self,
collection: str,
documents: List[KnowledgeDocument]
) -> List[str]:
"""Insert or update documents. Returns list of IDs."""
raise NotImplementedError
@abstractmethod
def search(
self,
collection: str,
query_embedding: List[float],
limit: int = 5,
filters: Optional[Dict[str, Any]] = None,
score_threshold: Optional[float] = None
) -> List[KnowledgeDocument]:
"""Search for similar documents by embedding."""
raise NotImplementedError
@abstractmethod
def get(
self,
collection: str,
ids: List[str]
) -> List[KnowledgeDocument]:
"""Get documents by IDs."""
raise NotImplementedError
@abstractmethod
def delete(
self,
collection: str,
ids: Optional[List[str]] = None,
filters: Optional[Dict[str, Any]] = None
) -> int:
"""Delete documents by IDs or filters. Returns count deleted."""
raise NotImplementedError
@abstractmethod
def count(self, collection: str) -> int:
"""Count documents in a collection."""
raise NotImplementedError
@abstractmethod
def close(self) -> None:
"""Close the store and release resources."""
raise NotImplementedError
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.close()
return False
@@ -0,0 +1,232 @@
"""
Cassandra Vector implementation of KnowledgeStore.
Requires: cassandra-driver
Install: pip install cassandra-driver
"""
import logging
from typing import Any, Dict, List, Optional
from .base import KnowledgeStore, KnowledgeDocument
logger = logging.getLogger(__name__)
class CassandraKnowledgeStore(KnowledgeStore):
"""
Cassandra-based knowledge store using vector search (SAI).
Requires Cassandra 5.0+ or DataStax Astra with vector search.
Example:
store = CassandraKnowledgeStore(
hosts=["localhost"],
keyspace="praisonai"
)
"""
def __init__(
self,
hosts: Optional[List[str]] = None,
keyspace: str = "praisonai",
username: Optional[str] = None,
password: Optional[str] = None,
port: int = 9042,
secure_connect_bundle: Optional[str] = None,
):
try:
from cassandra.cluster import Cluster
from cassandra.auth import PlainTextAuthProvider
except ImportError:
raise ImportError(
"cassandra-driver is required for Cassandra support. "
"Install with: pip install cassandra-driver"
)
self._Cluster = Cluster
self.keyspace = keyspace
auth = None
if username and password:
auth = PlainTextAuthProvider(username=username, password=password)
if secure_connect_bundle:
# Astra DB connection
from cassandra.cluster import Cluster
cloud_config = {"secure_connect_bundle": secure_connect_bundle}
self._cluster = Cluster(cloud=cloud_config, auth_provider=auth)
else:
hosts = hosts or ["localhost"]
self._cluster = Cluster(hosts, port=port, auth_provider=auth)
self._session = self._cluster.connect()
# Create keyspace if not exists
self._session.execute(f"""
CREATE KEYSPACE IF NOT EXISTS {keyspace}
WITH replication = {{'class': 'SimpleStrategy', 'replication_factor': 1}}
""")
self._session.set_keyspace(keyspace)
logger.info(f"Connected to Cassandra keyspace: {keyspace}")
def create_collection(
self,
name: str,
dimension: int,
distance: str = "cosine",
metadata: Optional[Dict[str, Any]] = None
) -> None:
"""Create a new table with vector column."""
similarity_map = {"cosine": "COSINE", "euclidean": "EUCLIDEAN", "dot": "DOT_PRODUCT"}
self._session.execute(f"""
CREATE TABLE IF NOT EXISTS {name} (
id text PRIMARY KEY,
content text,
content_hash text,
created_at double,
embedding vector<float, {dimension}>
)
""")
# Create SAI index for vector search
self._session.execute(f"""
CREATE CUSTOM INDEX IF NOT EXISTS {name}_embedding_idx ON {name} (embedding)
USING 'StorageAttachedIndex'
WITH OPTIONS = {{'similarity_function': '{similarity_map.get(distance, "COSINE")}'}}
""")
logger.info(f"Created Cassandra table: {name}")
def delete_collection(self, name: str) -> bool:
"""Delete a table."""
try:
self._session.execute(f"DROP TABLE IF EXISTS {name}")
return True
except Exception as e:
logger.warning(f"Failed to delete table {name}: {e}")
return False
def collection_exists(self, name: str) -> bool:
"""Check if a table exists."""
result = self._session.execute("""
SELECT table_name FROM system_schema.tables
WHERE keyspace_name = %s AND table_name = %s
""", (self.keyspace, name))
return len(list(result)) > 0
def list_collections(self) -> List[str]:
"""List all tables."""
result = self._session.execute("""
SELECT table_name FROM system_schema.tables WHERE keyspace_name = %s
""", (self.keyspace,))
return [row.table_name for row in result]
def insert(
self,
collection: str,
documents: List[KnowledgeDocument]
) -> List[str]:
"""Insert documents."""
ids = []
for doc in documents:
if doc.embedding is None:
raise ValueError(f"Document {doc.id} has no embedding")
self._session.execute(f"""
INSERT INTO {collection} (id, content, content_hash, created_at, embedding)
VALUES (%s, %s, %s, %s, %s)
""", (doc.id, doc.content, doc.content_hash or "", doc.created_at, doc.embedding))
ids.append(doc.id)
return ids
def upsert(
self,
collection: str,
documents: List[KnowledgeDocument]
) -> List[str]:
"""Insert or update documents."""
return self.insert(collection, documents) # Cassandra INSERT is upsert
def search(
self,
collection: str,
query_embedding: List[float],
limit: int = 5,
filters: Optional[Dict[str, Any]] = None,
score_threshold: Optional[float] = None
) -> List[KnowledgeDocument]:
"""Search for similar documents using ANN."""
result = self._session.execute(f"""
SELECT id, content, content_hash, created_at, similarity_cosine(embedding, %s) as score
FROM {collection}
ORDER BY embedding ANN OF %s
LIMIT %s
""", (query_embedding, query_embedding, limit))
documents = []
for row in result:
if score_threshold and row.score < score_threshold:
continue
doc = KnowledgeDocument(
id=row.id,
content=row.content,
embedding=None,
metadata={},
content_hash=row.content_hash,
created_at=row.created_at,
)
documents.append(doc)
return documents
def get(
self,
collection: str,
ids: List[str]
) -> List[KnowledgeDocument]:
"""Get documents by IDs."""
documents = []
for doc_id in ids:
result = self._session.execute(f"""
SELECT id, content, content_hash, created_at, embedding
FROM {collection} WHERE id = %s
""", (doc_id,))
for row in result:
doc = KnowledgeDocument(
id=row.id,
content=row.content,
embedding=list(row.embedding) if row.embedding else None,
metadata={},
content_hash=row.content_hash,
created_at=row.created_at,
)
documents.append(doc)
return documents
def delete(
self,
collection: str,
ids: Optional[List[str]] = None,
filters: Optional[Dict[str, Any]] = None
) -> int:
"""Delete documents."""
if ids:
for doc_id in ids:
self._session.execute(f"DELETE FROM {collection} WHERE id = %s", (doc_id,))
return len(ids)
return 0
def count(self, collection: str) -> int:
"""Count documents."""
result = self._session.execute(f"SELECT COUNT(*) FROM {collection}")
return result.one()[0]
def close(self) -> None:
"""Close the store."""
if self._cluster:
self._cluster.shutdown()
@@ -0,0 +1,295 @@
"""
ChromaDB implementation of KnowledgeStore.
Requires: chromadb
Install: pip install chromadb
"""
import logging
from typing import Any, Dict, List, Optional
from .base import KnowledgeStore, KnowledgeDocument
logger = logging.getLogger(__name__)
class ChromaKnowledgeStore(KnowledgeStore):
"""
ChromaDB-based knowledge store for vector search.
Zero-config embedded vector database.
Example:
store = ChromaKnowledgeStore(
path="./chroma_db"
)
"""
def __init__(
self,
path: Optional[str] = None,
host: Optional[str] = None,
port: int = 8000,
persistent: bool = True,
):
"""
Initialize ChromaDB knowledge store.
Args:
path: Path for persistent storage (embedded mode)
host: Chroma server host (client mode)
port: Chroma server port
persistent: Use persistent storage
"""
try:
import chromadb
from chromadb.config import Settings
except ImportError:
raise ImportError(
"chromadb is required for ChromaDB support. "
"Install with: pip install chromadb"
)
self._chromadb = chromadb
if host:
# Client mode
self._client = chromadb.HttpClient(host=host, port=port)
elif path and persistent:
# Persistent embedded mode
self._client = chromadb.PersistentClient(
path=path,
settings=Settings(anonymized_telemetry=False)
)
else:
# In-memory mode
self._client = chromadb.Client(
settings=Settings(anonymized_telemetry=False)
)
self._collections: Dict[str, Any] = {}
logger.info(f"ChromaDB initialized (path={path}, host={host})")
def _get_collection(self, name: str):
"""Get or cache a collection."""
if name not in self._collections:
self._collections[name] = self._client.get_collection(name)
return self._collections[name]
def create_collection(
self,
name: str,
dimension: int,
distance: str = "cosine",
metadata: Optional[Dict[str, Any]] = None
) -> None:
"""Create a new collection."""
distance_map = {
"cosine": "cosine",
"euclidean": "l2",
"dot": "ip",
}
self._client.create_collection(
name=name,
metadata={"hnsw:space": distance_map.get(distance, "cosine"), **(metadata or {})}
)
logger.info(f"Created ChromaDB collection: {name}")
def delete_collection(self, name: str) -> bool:
"""Delete a collection."""
try:
self._client.delete_collection(name)
if name in self._collections:
del self._collections[name]
return True
except Exception as e:
logger.warning(f"Failed to delete collection {name}: {e}")
return False
def collection_exists(self, name: str) -> bool:
"""Check if a collection exists."""
try:
self._client.get_collection(name)
return True
except Exception:
return False
def list_collections(self) -> List[str]:
"""List all collections."""
return [c.name for c in self._client.list_collections()]
def insert(
self,
collection: str,
documents: List[KnowledgeDocument]
) -> List[str]:
"""Insert documents into a collection."""
col = self._get_collection(collection)
ids = []
embeddings = []
contents = []
metadatas = []
for doc in documents:
if doc.embedding is None:
raise ValueError(f"Document {doc.id} has no embedding")
ids.append(doc.id)
embeddings.append(doc.embedding)
contents.append(doc.content)
metadatas.append({
"content_hash": doc.content_hash or "",
"created_at": doc.created_at,
**(doc.metadata or {})
})
col.add(
ids=ids,
embeddings=embeddings,
documents=contents,
metadatas=metadatas,
)
return ids
def upsert(
self,
collection: str,
documents: List[KnowledgeDocument]
) -> List[str]:
"""Insert or update documents."""
col = self._get_collection(collection)
ids = []
embeddings = []
contents = []
metadatas = []
for doc in documents:
if doc.embedding is None:
raise ValueError(f"Document {doc.id} has no embedding")
ids.append(doc.id)
embeddings.append(doc.embedding)
contents.append(doc.content)
metadatas.append({
"content_hash": doc.content_hash or "",
"created_at": doc.created_at,
**(doc.metadata or {})
})
col.upsert(
ids=ids,
embeddings=embeddings,
documents=contents,
metadatas=metadatas,
)
return ids
def search(
self,
collection: str,
query_embedding: List[float],
limit: int = 5,
filters: Optional[Dict[str, Any]] = None,
score_threshold: Optional[float] = None
) -> List[KnowledgeDocument]:
"""Search for similar documents."""
col = self._get_collection(collection)
where = None
if filters:
where = {k: v for k, v in filters.items()}
results = col.query(
query_embeddings=[query_embedding],
n_results=limit,
where=where,
include=["documents", "metadatas", "distances"],
)
documents = []
if results["ids"] and results["ids"][0]:
for i, doc_id in enumerate(results["ids"][0]):
distance = results["distances"][0][i] if results.get("distances") else None
# Convert distance to similarity score (for cosine: 1 - distance)
if score_threshold is not None and distance is not None:
score = 1 - distance
if score < score_threshold:
continue
metadata = results["metadatas"][0][i] if results.get("metadatas") else {}
content_hash = metadata.pop("content_hash", None)
created_at = metadata.pop("created_at", 0)
doc = KnowledgeDocument(
id=doc_id,
content=results["documents"][0][i] if results.get("documents") else "",
embedding=None,
metadata=metadata,
content_hash=content_hash,
created_at=created_at,
)
documents.append(doc)
return documents
def get(
self,
collection: str,
ids: List[str]
) -> List[KnowledgeDocument]:
"""Get documents by IDs."""
col = self._get_collection(collection)
results = col.get(
ids=ids,
include=["documents", "metadatas", "embeddings"],
)
documents = []
if results["ids"]:
for i, doc_id in enumerate(results["ids"]):
metadata = results["metadatas"][i] if results.get("metadatas") else {}
content_hash = metadata.pop("content_hash", None)
created_at = metadata.pop("created_at", 0)
doc = KnowledgeDocument(
id=doc_id,
content=results["documents"][i] if results.get("documents") else "",
embedding=results["embeddings"][i] if results.get("embeddings") else None,
metadata=metadata,
content_hash=content_hash,
created_at=created_at,
)
documents.append(doc)
return documents
def delete(
self,
collection: str,
ids: Optional[List[str]] = None,
filters: Optional[Dict[str, Any]] = None
) -> int:
"""Delete documents."""
col = self._get_collection(collection)
if ids:
col.delete(ids=ids)
return len(ids)
elif filters:
col.delete(where=filters)
return -1 # Unknown count
return 0
def count(self, collection: str) -> int:
"""Count documents in a collection."""
col = self._get_collection(collection)
return col.count()
def close(self) -> None:
"""Close the store."""
self._collections.clear()
@@ -0,0 +1,242 @@
"""
ClickHouse Vector implementation of KnowledgeStore.
Requires: clickhouse-connect
Install: pip install clickhouse-connect
"""
import logging
from typing import Any, Dict, List, Optional
from .base import KnowledgeStore, KnowledgeDocument
logger = logging.getLogger(__name__)
class ClickHouseKnowledgeStore(KnowledgeStore):
"""
ClickHouse-based knowledge store using vector search.
Example:
store = ClickHouseKnowledgeStore(
host="localhost",
port=8123
)
"""
def __init__(
self,
host: str = "localhost",
port: int = 8123,
username: str = "default",
password: str = "",
database: str = "praisonai",
secure: bool = False,
):
try:
import clickhouse_connect
except ImportError:
raise ImportError(
"clickhouse-connect is required for ClickHouse support. "
"Install with: pip install clickhouse-connect"
)
self._client = clickhouse_connect.get_client(
host=host,
port=port,
username=username,
password=password,
database=database,
secure=secure,
)
self.database = database
# Create database if not exists
self._client.command(f"CREATE DATABASE IF NOT EXISTS {database}")
logger.info(f"Connected to ClickHouse database: {database}")
def _table_name(self, collection: str) -> str:
return f"{self.database}.praison_vec_{collection}"
def create_collection(
self,
name: str,
dimension: int,
distance: str = "cosine",
metadata: Optional[Dict[str, Any]] = None
) -> None:
"""Create a new table with vector column."""
table = self._table_name(name)
self._client.command(f"""
CREATE TABLE IF NOT EXISTS {table} (
id String,
content String,
content_hash String,
created_at Float64,
embedding Array(Float32)
) ENGINE = MergeTree()
ORDER BY id
""")
logger.info(f"Created ClickHouse table: {table}")
def delete_collection(self, name: str) -> bool:
"""Delete a table."""
table = self._table_name(name)
try:
self._client.command(f"DROP TABLE IF EXISTS {table}")
return True
except Exception as e:
logger.warning(f"Failed to delete table {table}: {e}")
return False
def collection_exists(self, name: str) -> bool:
"""Check if a table exists."""
table = self._table_name(name)
result = self._client.query(f"EXISTS TABLE {table}")
return result.result_rows[0][0] == 1
def list_collections(self) -> List[str]:
"""List all collections."""
result = self._client.query(f"""
SELECT name FROM system.tables
WHERE database = '{self.database}' AND name LIKE 'praison_vec_%'
""")
return [row[0].replace("praison_vec_", "") for row in result.result_rows]
def insert(
self,
collection: str,
documents: List[KnowledgeDocument]
) -> List[str]:
"""Insert documents."""
table = self._table_name(collection)
data = []
ids = []
for doc in documents:
if doc.embedding is None:
raise ValueError(f"Document {doc.id} has no embedding")
data.append([
doc.id,
doc.content,
doc.content_hash or "",
doc.created_at,
doc.embedding,
])
ids.append(doc.id)
self._client.insert(
table,
data,
column_names=["id", "content", "content_hash", "created_at", "embedding"]
)
return ids
def upsert(
self,
collection: str,
documents: List[KnowledgeDocument]
) -> List[str]:
"""Insert or update documents."""
# ClickHouse doesn't have native upsert for MergeTree
# Delete then insert
ids = [doc.id for doc in documents]
self.delete(collection, ids)
return self.insert(collection, documents)
def search(
self,
collection: str,
query_embedding: List[float],
limit: int = 5,
filters: Optional[Dict[str, Any]] = None,
score_threshold: Optional[float] = None
) -> List[KnowledgeDocument]:
"""Search for similar documents using cosine distance."""
table = self._table_name(collection)
# Convert embedding to string for query
embedding_str = "[" + ",".join(map(str, query_embedding)) + "]"
query = f"""
SELECT
id, content, content_hash, created_at,
1 - cosineDistance(embedding, {embedding_str}) as score
FROM {table}
"""
if score_threshold:
query += f" WHERE 1 - cosineDistance(embedding, {embedding_str}) >= {score_threshold}"
query += f" ORDER BY score DESC LIMIT {limit}"
result = self._client.query(query)
documents = []
for row in result.result_rows:
doc = KnowledgeDocument(
id=row[0],
content=row[1],
embedding=None,
metadata={},
content_hash=row[2],
created_at=row[3],
)
documents.append(doc)
return documents
def get(
self,
collection: str,
ids: List[str]
) -> List[KnowledgeDocument]:
"""Get documents by IDs."""
table = self._table_name(collection)
id_list = ", ".join([f"'{i}'" for i in ids])
result = self._client.query(f"""
SELECT id, content, content_hash, created_at, embedding
FROM {table} WHERE id IN ({id_list})
""")
documents = []
for row in result.result_rows:
doc = KnowledgeDocument(
id=row[0],
content=row[1],
embedding=list(row[4]) if row[4] else None,
metadata={},
content_hash=row[2],
created_at=row[3],
)
documents.append(doc)
return documents
def delete(
self,
collection: str,
ids: Optional[List[str]] = None,
filters: Optional[Dict[str, Any]] = None
) -> int:
"""Delete documents."""
table = self._table_name(collection)
if ids:
id_list = ", ".join([f"'{i}'" for i in ids])
self._client.command(f"ALTER TABLE {table} DELETE WHERE id IN ({id_list})")
return len(ids)
return 0
def count(self, collection: str) -> int:
"""Count documents."""
table = self._table_name(collection)
result = self._client.query(f"SELECT COUNT(*) FROM {table}")
return result.result_rows[0][0]
def close(self) -> None:
"""Close the store."""
if self._client:
self._client.close()
@@ -0,0 +1,216 @@
"""
LanceDB implementation of KnowledgeStore.
Requires: lancedb
Install: pip install lancedb
"""
import logging
from typing import Any, Dict, List, Optional
from .base import KnowledgeStore, KnowledgeDocument
logger = logging.getLogger(__name__)
class LanceDBKnowledgeStore(KnowledgeStore):
"""
LanceDB-based knowledge store for vector search.
Embedded serverless vector database.
Example:
store = LanceDBKnowledgeStore(
path="./lancedb"
)
"""
def __init__(
self,
path: str = "./lancedb",
uri: Optional[str] = None,
):
try:
import lancedb
except ImportError:
raise ImportError(
"lancedb is required for LanceDB support. "
"Install with: pip install lancedb"
)
self._lancedb = lancedb
self._db = lancedb.connect(uri or path)
self._tables: Dict[str, Any] = {}
logger.info(f"Connected to LanceDB at {uri or path}")
def create_collection(
self,
name: str,
dimension: int,
distance: str = "cosine",
metadata: Optional[Dict[str, Any]] = None
) -> None:
"""Create a new table."""
import pyarrow as pa
schema = pa.schema([
pa.field("id", pa.string()),
pa.field("content", pa.string()),
pa.field("content_hash", pa.string()),
pa.field("created_at", pa.float64()),
pa.field("vector", pa.list_(pa.float32(), dimension)),
])
self._db.create_table(name, schema=schema)
logger.info(f"Created LanceDB table: {name}")
def delete_collection(self, name: str) -> bool:
"""Delete a table."""
try:
self._db.drop_table(name)
if name in self._tables:
del self._tables[name]
return True
except Exception as e:
logger.warning(f"Failed to delete table {name}: {e}")
return False
def collection_exists(self, name: str) -> bool:
"""Check if a table exists."""
return name in self._db.table_names()
def list_collections(self) -> List[str]:
"""List all tables."""
return self._db.table_names()
def _get_table(self, name: str):
"""Get or cache a table."""
if name not in self._tables:
self._tables[name] = self._db.open_table(name)
return self._tables[name]
def insert(
self,
collection: str,
documents: List[KnowledgeDocument]
) -> List[str]:
"""Insert documents."""
table = self._get_table(collection)
data = []
ids = []
for doc in documents:
if doc.embedding is None:
raise ValueError(f"Document {doc.id} has no embedding")
data.append({
"id": doc.id,
"content": doc.content,
"content_hash": doc.content_hash or "",
"created_at": doc.created_at,
"vector": doc.embedding,
})
ids.append(doc.id)
table.add(data)
return ids
def upsert(
self,
collection: str,
documents: List[KnowledgeDocument]
) -> List[str]:
"""Insert or update documents."""
# LanceDB doesn't have native upsert, delete then insert
ids = [doc.id for doc in documents]
self.delete(collection, ids)
return self.insert(collection, documents)
def search(
self,
collection: str,
query_embedding: List[float],
limit: int = 5,
filters: Optional[Dict[str, Any]] = None,
score_threshold: Optional[float] = None
) -> List[KnowledgeDocument]:
"""Search for similar documents."""
table = self._get_table(collection)
query = table.search(query_embedding).limit(limit)
if filters:
where_clauses = [f"{k} = '{v}'" for k, v in filters.items()]
query = query.where(" AND ".join(where_clauses))
results = query.to_pandas()
documents = []
for _, row in results.iterrows():
if score_threshold and row.get("_distance", 1) > (1 - score_threshold):
continue
doc = KnowledgeDocument(
id=row["id"],
content=row["content"],
embedding=None,
metadata={},
content_hash=row.get("content_hash"),
created_at=row.get("created_at", 0),
)
documents.append(doc)
return documents
def get(
self,
collection: str,
ids: List[str]
) -> List[KnowledgeDocument]:
"""Get documents by IDs."""
table = self._get_table(collection)
id_list = ", ".join([f"'{i}'" for i in ids])
results = table.search().where(f"id IN ({id_list})").to_pandas()
documents = []
for _, row in results.iterrows():
doc = KnowledgeDocument(
id=row["id"],
content=row["content"],
embedding=list(row["vector"]) if "vector" in row else None,
metadata={},
content_hash=row.get("content_hash"),
created_at=row.get("created_at", 0),
)
documents.append(doc)
return documents
def delete(
self,
collection: str,
ids: Optional[List[str]] = None,
filters: Optional[Dict[str, Any]] = None
) -> int:
"""Delete documents."""
table = self._get_table(collection)
if ids:
id_list = ", ".join([f"'{i}'" for i in ids])
table.delete(f"id IN ({id_list})")
return len(ids)
elif filters:
where_clauses = [f"{k} = '{v}'" for k, v in filters.items()]
table.delete(" AND ".join(where_clauses))
return -1
return 0
def count(self, collection: str) -> int:
"""Count documents."""
table = self._get_table(collection)
return table.count_rows()
def close(self) -> None:
"""Close the store."""
self._tables.clear()
@@ -0,0 +1,277 @@
"""
Milvus implementation of KnowledgeStore.
Requires: pymilvus
Install: pip install pymilvus
"""
import logging
from typing import Any, Dict, List, Optional
from .base import KnowledgeStore, KnowledgeDocument
logger = logging.getLogger(__name__)
class MilvusKnowledgeStore(KnowledgeStore):
"""
Milvus-based knowledge store for vector search.
Example:
store = MilvusKnowledgeStore(
host="localhost",
port=19530
)
"""
def __init__(
self,
url: Optional[str] = None,
host: str = "localhost",
port: int = 19530,
token: Optional[str] = None,
db_name: str = "default",
):
try:
from pymilvus import connections, Collection, FieldSchema, CollectionSchema, DataType, utility
except ImportError:
raise ImportError(
"pymilvus is required for Milvus support. "
"Install with: pip install pymilvus"
)
self._pymilvus = {
"connections": connections,
"Collection": Collection,
"FieldSchema": FieldSchema,
"CollectionSchema": CollectionSchema,
"DataType": DataType,
"utility": utility,
}
if url:
# Parse milvus://host:port
from urllib.parse import urlparse
parsed = urlparse(url)
host = parsed.hostname or host
port = parsed.port or port
connections.connect(
alias="default",
host=host,
port=port,
token=token,
db_name=db_name,
)
logger.info(f"Connected to Milvus at {host}:{port}")
def create_collection(
self,
name: str,
dimension: int,
distance: str = "cosine",
metadata: Optional[Dict[str, Any]] = None
) -> None:
"""Create a new collection."""
FieldSchema = self._pymilvus["FieldSchema"]
CollectionSchema = self._pymilvus["CollectionSchema"]
DataType = self._pymilvus["DataType"]
Collection = self._pymilvus["Collection"]
fields = [
FieldSchema(name="id", dtype=DataType.VARCHAR, is_primary=True, max_length=256),
FieldSchema(name="content", dtype=DataType.VARCHAR, max_length=65535),
FieldSchema(name="content_hash", dtype=DataType.VARCHAR, max_length=64),
FieldSchema(name="created_at", dtype=DataType.DOUBLE),
FieldSchema(name="vector", dtype=DataType.FLOAT_VECTOR, dim=dimension),
]
schema = CollectionSchema(fields=fields, description="PraisonAI knowledge store")
collection = Collection(name=name, schema=schema)
# Create index
metric_map = {"cosine": "COSINE", "euclidean": "L2", "dot": "IP"}
index_params = {
"metric_type": metric_map.get(distance, "COSINE"),
"index_type": "HNSW",
"params": {"M": 16, "efConstruction": 256}
}
collection.create_index(field_name="vector", index_params=index_params)
logger.info(f"Created Milvus collection: {name}")
def delete_collection(self, name: str) -> bool:
"""Delete a collection."""
utility = self._pymilvus["utility"]
try:
utility.drop_collection(name)
return True
except Exception as e:
logger.warning(f"Failed to delete collection {name}: {e}")
return False
def collection_exists(self, name: str) -> bool:
"""Check if a collection exists."""
utility = self._pymilvus["utility"]
return utility.has_collection(name)
def list_collections(self) -> List[str]:
"""List all collections."""
utility = self._pymilvus["utility"]
return utility.list_collections()
def insert(
self,
collection: str,
documents: List[KnowledgeDocument]
) -> List[str]:
"""Insert documents."""
Collection = self._pymilvus["Collection"]
col = Collection(collection)
col.load()
data = [
[doc.id for doc in documents],
[doc.content for doc in documents],
[doc.content_hash or "" for doc in documents],
[doc.created_at for doc in documents],
[doc.embedding for doc in documents],
]
col.insert(data)
col.flush()
return [doc.id for doc in documents]
def upsert(
self,
collection: str,
documents: List[KnowledgeDocument]
) -> List[str]:
"""Insert or update documents."""
Collection = self._pymilvus["Collection"]
col = Collection(collection)
col.load()
data = [
[doc.id for doc in documents],
[doc.content for doc in documents],
[doc.content_hash or "" for doc in documents],
[doc.created_at for doc in documents],
[doc.embedding for doc in documents],
]
col.upsert(data)
col.flush()
return [doc.id for doc in documents]
def search(
self,
collection: str,
query_embedding: List[float],
limit: int = 5,
filters: Optional[Dict[str, Any]] = None,
score_threshold: Optional[float] = None
) -> List[KnowledgeDocument]:
"""Search for similar documents."""
Collection = self._pymilvus["Collection"]
col = Collection(collection)
col.load()
search_params = {"metric_type": "COSINE", "params": {"ef": 64}}
expr = None
if filters:
conditions = [f'{k} == "{v}"' for k, v in filters.items()]
expr = " and ".join(conditions)
results = col.search(
data=[query_embedding],
anns_field="vector",
param=search_params,
limit=limit,
expr=expr,
output_fields=["id", "content", "content_hash", "created_at"],
)
documents = []
for hits in results:
for hit in hits:
if score_threshold and hit.score < score_threshold:
continue
doc = KnowledgeDocument(
id=hit.entity.get("id"),
content=hit.entity.get("content", ""),
embedding=None,
metadata={},
content_hash=hit.entity.get("content_hash"),
created_at=hit.entity.get("created_at", 0),
)
documents.append(doc)
return documents
def get(
self,
collection: str,
ids: List[str]
) -> List[KnowledgeDocument]:
"""Get documents by IDs."""
Collection = self._pymilvus["Collection"]
col = Collection(collection)
col.load()
id_list = ", ".join([f'"{i}"' for i in ids])
expr = f"id in [{id_list}]"
results = col.query(
expr=expr,
output_fields=["id", "content", "content_hash", "created_at", "vector"],
)
documents = []
for row in results:
doc = KnowledgeDocument(
id=row["id"],
content=row.get("content", ""),
embedding=row.get("vector"),
metadata={},
content_hash=row.get("content_hash"),
created_at=row.get("created_at", 0),
)
documents.append(doc)
return documents
def delete(
self,
collection: str,
ids: Optional[List[str]] = None,
filters: Optional[Dict[str, Any]] = None
) -> int:
"""Delete documents."""
Collection = self._pymilvus["Collection"]
col = Collection(collection)
col.load()
if ids:
id_list = ", ".join([f'"{i}"' for i in ids])
expr = f"id in [{id_list}]"
col.delete(expr)
return len(ids)
elif filters:
conditions = [f'{k} == "{v}"' for k, v in filters.items()]
expr = " and ".join(conditions)
col.delete(expr)
return -1
return 0
def count(self, collection: str) -> int:
"""Count documents."""
Collection = self._pymilvus["Collection"]
col = Collection(collection)
return col.num_entities
def close(self) -> None:
"""Close the store."""
connections = self._pymilvus["connections"]
connections.disconnect("default")
@@ -0,0 +1,335 @@
"""
PGVector implementation of KnowledgeStore.
Requires: pgvector, psycopg2-binary
Install: pip install pgvector psycopg2-binary
"""
import json
import logging
from typing import Any, Dict, List, Optional
from .base import KnowledgeStore, KnowledgeDocument
logger = logging.getLogger(__name__)
class PGVectorKnowledgeStore(KnowledgeStore):
"""
PGVector-based knowledge store for vector search.
Uses PostgreSQL with pgvector extension.
Example:
store = PGVectorKnowledgeStore(
url="postgresql://localhost:5432/praisonai"
)
"""
def __init__(
self,
url: Optional[str] = None,
host: str = "localhost",
port: int = 5432,
database: str = "praisonai",
user: str = "postgres",
password: str = "",
schema: str = "public",
auto_create_extension: bool = True,
):
try:
import psycopg2
from psycopg2 import pool as pg_pool
from psycopg2.extras import RealDictCursor
except ImportError:
raise ImportError(
"psycopg2 is required for PGVector support. "
"Install with: pip install psycopg2-binary"
)
self._psycopg2 = psycopg2
self._RealDictCursor = RealDictCursor
self.schema = schema
if url:
self._pool = pg_pool.ThreadedConnectionPool(1, 5, url)
else:
self._pool = pg_pool.ThreadedConnectionPool(
1, 5,
host=host, port=port, database=database,
user=user, password=password,
)
if auto_create_extension:
self._create_extension()
def _get_conn(self):
return self._pool.getconn()
def _put_conn(self, conn):
self._pool.putconn(conn)
def _create_extension(self):
"""Create pgvector extension if not exists."""
conn = self._get_conn()
try:
with conn.cursor() as cur:
cur.execute("CREATE EXTENSION IF NOT EXISTS vector")
conn.commit()
finally:
self._put_conn(conn)
def _table_name(self, collection: str) -> str:
return f"{self.schema}.praison_vec_{collection}"
def create_collection(
self,
name: str,
dimension: int,
distance: str = "cosine",
metadata: Optional[Dict[str, Any]] = None
) -> None:
"""Create a new collection table."""
table = self._table_name(name)
conn = self._get_conn()
try:
with conn.cursor() as cur:
cur.execute(f"""
CREATE TABLE IF NOT EXISTS {table} (
id VARCHAR(255) PRIMARY KEY,
content TEXT,
content_hash VARCHAR(64),
created_at DOUBLE PRECISION,
metadata JSONB,
embedding vector({dimension})
)
""")
# Create index based on distance metric
op_map = {"cosine": "vector_cosine_ops", "euclidean": "vector_l2_ops", "dot": "vector_ip_ops"}
ops = op_map.get(distance, "vector_cosine_ops")
cur.execute(f"""
CREATE INDEX IF NOT EXISTS idx_{name}_embedding
ON {table} USING hnsw (embedding {ops})
""")
conn.commit()
logger.info(f"Created PGVector table: {table}")
finally:
self._put_conn(conn)
def delete_collection(self, name: str) -> bool:
"""Delete a collection table."""
table = self._table_name(name)
conn = self._get_conn()
try:
with conn.cursor() as cur:
cur.execute(f"DROP TABLE IF EXISTS {table}")
conn.commit()
return True
except Exception as e:
logger.warning(f"Failed to delete table {table}: {e}")
return False
finally:
self._put_conn(conn)
def collection_exists(self, name: str) -> bool:
"""Check if a collection exists."""
table = self._table_name(name)
conn = self._get_conn()
try:
with conn.cursor() as cur:
cur.execute("""
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_schema = %s AND table_name = %s
)
""", (self.schema, f"praison_vec_{name}"))
return cur.fetchone()[0]
finally:
self._put_conn(conn)
def list_collections(self) -> List[str]:
"""List all collections."""
conn = self._get_conn()
try:
with conn.cursor() as cur:
cur.execute("""
SELECT table_name FROM information_schema.tables
WHERE table_schema = %s AND table_name LIKE 'praison_vec_%%'
""", (self.schema,))
return [row[0].replace("praison_vec_", "") for row in cur.fetchall()]
finally:
self._put_conn(conn)
def insert(
self,
collection: str,
documents: List[KnowledgeDocument]
) -> List[str]:
"""Insert documents."""
table = self._table_name(collection)
conn = self._get_conn()
try:
with conn.cursor() as cur:
for doc in documents:
if doc.embedding is None:
raise ValueError(f"Document {doc.id} has no embedding")
cur.execute(f"""
INSERT INTO {table} (id, content, content_hash, created_at, metadata, embedding)
VALUES (%s, %s, %s, %s, %s, %s)
""", (
doc.id, doc.content, doc.content_hash, doc.created_at,
json.dumps(doc.metadata) if doc.metadata else None,
doc.embedding,
))
conn.commit()
return [doc.id for doc in documents]
finally:
self._put_conn(conn)
def upsert(
self,
collection: str,
documents: List[KnowledgeDocument]
) -> List[str]:
"""Insert or update documents."""
table = self._table_name(collection)
conn = self._get_conn()
try:
with conn.cursor() as cur:
for doc in documents:
if doc.embedding is None:
raise ValueError(f"Document {doc.id} has no embedding")
cur.execute(f"""
INSERT INTO {table} (id, content, content_hash, created_at, metadata, embedding)
VALUES (%s, %s, %s, %s, %s, %s)
ON CONFLICT (id) DO UPDATE SET
content = EXCLUDED.content,
content_hash = EXCLUDED.content_hash,
created_at = EXCLUDED.created_at,
metadata = EXCLUDED.metadata,
embedding = EXCLUDED.embedding
""", (
doc.id, doc.content, doc.content_hash, doc.created_at,
json.dumps(doc.metadata) if doc.metadata else None,
doc.embedding,
))
conn.commit()
return [doc.id for doc in documents]
finally:
self._put_conn(conn)
def search(
self,
collection: str,
query_embedding: List[float],
limit: int = 5,
filters: Optional[Dict[str, Any]] = None,
score_threshold: Optional[float] = None
) -> List[KnowledgeDocument]:
"""Search for similar documents."""
table = self._table_name(collection)
conn = self._get_conn()
try:
with conn.cursor(cursor_factory=self._RealDictCursor) as cur:
# Use cosine distance operator <=>
query = f"""
SELECT id, content, content_hash, created_at, metadata,
1 - (embedding <=> %s::vector) as score
FROM {table}
"""
params = [query_embedding]
if score_threshold:
query += " WHERE 1 - (embedding <=> %s::vector) >= %s"
params.extend([query_embedding, score_threshold])
query += " ORDER BY embedding <=> %s::vector LIMIT %s"
params.extend([query_embedding, limit])
cur.execute(query, params)
documents = []
for row in cur.fetchall():
doc = KnowledgeDocument(
id=row["id"],
content=row["content"],
embedding=None,
metadata=row["metadata"] or {},
content_hash=row["content_hash"],
created_at=row["created_at"],
)
documents.append(doc)
return documents
finally:
self._put_conn(conn)
def get(
self,
collection: str,
ids: List[str]
) -> List[KnowledgeDocument]:
"""Get documents by IDs."""
table = self._table_name(collection)
conn = self._get_conn()
try:
with conn.cursor(cursor_factory=self._RealDictCursor) as cur:
placeholders = ",".join(["%s"] * len(ids))
cur.execute(f"""
SELECT id, content, content_hash, created_at, metadata, embedding::float[]
FROM {table} WHERE id IN ({placeholders})
""", ids)
documents = []
for row in cur.fetchall():
doc = KnowledgeDocument(
id=row["id"],
content=row["content"],
embedding=row["embedding"],
metadata=row["metadata"] or {},
content_hash=row["content_hash"],
created_at=row["created_at"],
)
documents.append(doc)
return documents
finally:
self._put_conn(conn)
def delete(
self,
collection: str,
ids: Optional[List[str]] = None,
filters: Optional[Dict[str, Any]] = None
) -> int:
"""Delete documents."""
table = self._table_name(collection)
conn = self._get_conn()
try:
with conn.cursor() as cur:
if ids:
placeholders = ",".join(["%s"] * len(ids))
cur.execute(f"DELETE FROM {table} WHERE id IN ({placeholders})", ids)
deleted = cur.rowcount
else:
deleted = 0
conn.commit()
return deleted
finally:
self._put_conn(conn)
def count(self, collection: str) -> int:
"""Count documents."""
table = self._table_name(collection)
conn = self._get_conn()
try:
with conn.cursor() as cur:
cur.execute(f"SELECT COUNT(*) FROM {table}")
return cur.fetchone()[0]
finally:
self._put_conn(conn)
def close(self) -> None:
"""Close the store."""
if self._pool:
self._pool.closeall()
@@ -0,0 +1,253 @@
"""
Pinecone implementation of KnowledgeStore.
Requires: pinecone-client
Install: pip install pinecone-client
"""
import logging
import os
from typing import Any, Dict, List, Optional
from .base import KnowledgeStore, KnowledgeDocument
logger = logging.getLogger(__name__)
class PineconeKnowledgeStore(KnowledgeStore):
"""
Pinecone-based knowledge store for vector search.
Cloud-only managed vector database.
Example:
store = PineconeKnowledgeStore(
api_key="your-api-key",
index_name="praisonai"
)
"""
def __init__(
self,
api_key: Optional[str] = None,
index_name: Optional[str] = None,
environment: Optional[str] = None,
namespace: str = "",
):
try:
from pinecone import Pinecone
except ImportError:
raise ImportError(
"pinecone-client is required for Pinecone support. "
"Install with: pip install pinecone-client"
)
api_key = api_key or os.getenv("PINECONE_API_KEY")
if not api_key:
raise ValueError("Pinecone API key is required")
self._pc = Pinecone(api_key=api_key)
self._index_name = index_name or os.getenv("PINECONE_INDEX", "praisonai")
self._namespace = namespace
self._index = None
# Try to connect to existing index
try:
self._index = self._pc.Index(self._index_name)
logger.info(f"Connected to Pinecone index: {self._index_name}")
except Exception as e:
logger.warning(f"Index {self._index_name} not found: {e}")
def create_collection(
self,
name: str,
dimension: int,
distance: str = "cosine",
metadata: Optional[Dict[str, Any]] = None
) -> None:
"""Create a new index (Pinecone calls them indexes, not collections)."""
from pinecone import ServerlessSpec
metric_map = {"cosine": "cosine", "euclidean": "euclidean", "dot": "dotproduct"}
self._pc.create_index(
name=name,
dimension=dimension,
metric=metric_map.get(distance, "cosine"),
spec=ServerlessSpec(cloud="aws", region="us-east-1")
)
self._index_name = name
self._index = self._pc.Index(name)
logger.info(f"Created Pinecone index: {name}")
def delete_collection(self, name: str) -> bool:
"""Delete an index."""
try:
self._pc.delete_index(name)
if name == self._index_name:
self._index = None
return True
except Exception as e:
logger.warning(f"Failed to delete index {name}: {e}")
return False
def collection_exists(self, name: str) -> bool:
"""Check if an index exists."""
try:
indexes = self._pc.list_indexes()
return any(idx.name == name for idx in indexes)
except Exception:
return False
def list_collections(self) -> List[str]:
"""List all indexes."""
indexes = self._pc.list_indexes()
return [idx.name for idx in indexes]
def insert(
self,
collection: str,
documents: List[KnowledgeDocument]
) -> List[str]:
"""Insert documents."""
if collection != self._index_name:
self._index = self._pc.Index(collection)
self._index_name = collection
vectors = []
ids = []
for doc in documents:
if doc.embedding is None:
raise ValueError(f"Document {doc.id} has no embedding")
vectors.append({
"id": doc.id,
"values": doc.embedding,
"metadata": {
"content": doc.content,
"content_hash": doc.content_hash or "",
"created_at": doc.created_at,
**(doc.metadata or {})
}
})
ids.append(doc.id)
self._index.upsert(vectors=vectors, namespace=self._namespace)
return ids
def upsert(
self,
collection: str,
documents: List[KnowledgeDocument]
) -> List[str]:
"""Insert or update documents."""
return self.insert(collection, documents)
def search(
self,
collection: str,
query_embedding: List[float],
limit: int = 5,
filters: Optional[Dict[str, Any]] = None,
score_threshold: Optional[float] = None
) -> List[KnowledgeDocument]:
"""Search for similar documents."""
if collection != self._index_name:
self._index = self._pc.Index(collection)
self._index_name = collection
results = self._index.query(
vector=query_embedding,
top_k=limit,
filter=filters,
include_metadata=True,
namespace=self._namespace,
)
documents = []
for match in results.matches:
if score_threshold and match.score < score_threshold:
continue
metadata = match.metadata or {}
content = metadata.pop("content", "")
content_hash = metadata.pop("content_hash", None)
created_at = metadata.pop("created_at", 0)
doc = KnowledgeDocument(
id=match.id,
content=content,
embedding=None,
metadata=metadata,
content_hash=content_hash,
created_at=created_at,
)
documents.append(doc)
return documents
def get(
self,
collection: str,
ids: List[str]
) -> List[KnowledgeDocument]:
"""Get documents by IDs."""
if collection != self._index_name:
self._index = self._pc.Index(collection)
self._index_name = collection
results = self._index.fetch(ids=ids, namespace=self._namespace)
documents = []
for doc_id, vector in results.vectors.items():
metadata = vector.metadata or {}
content = metadata.pop("content", "")
content_hash = metadata.pop("content_hash", None)
created_at = metadata.pop("created_at", 0)
doc = KnowledgeDocument(
id=doc_id,
content=content,
embedding=list(vector.values),
metadata=metadata,
content_hash=content_hash,
created_at=created_at,
)
documents.append(doc)
return documents
def delete(
self,
collection: str,
ids: Optional[List[str]] = None,
filters: Optional[Dict[str, Any]] = None
) -> int:
"""Delete documents."""
if collection != self._index_name:
self._index = self._pc.Index(collection)
self._index_name = collection
if ids:
self._index.delete(ids=ids, namespace=self._namespace)
return len(ids)
elif filters:
self._index.delete(filter=filters, namespace=self._namespace)
return -1
return 0
def count(self, collection: str) -> int:
"""Count documents."""
if collection != self._index_name:
self._index = self._pc.Index(collection)
self._index_name = collection
stats = self._index.describe_index_stats()
if self._namespace:
return stats.namespaces.get(self._namespace, {}).get("vector_count", 0)
return stats.total_vector_count
def close(self) -> None:
"""Close the store."""
self._index = None
@@ -0,0 +1,301 @@
"""
Qdrant implementation of KnowledgeStore.
Requires: qdrant-client
Install: pip install qdrant-client
"""
import logging
from typing import Any, Dict, List, Optional
from .base import KnowledgeStore, KnowledgeDocument
logger = logging.getLogger(__name__)
class QdrantKnowledgeStore(KnowledgeStore):
"""
Qdrant-based knowledge store for vector search.
Example:
store = QdrantKnowledgeStore(
url="http://localhost:6333"
)
"""
def __init__(
self,
url: Optional[str] = None,
host: str = "localhost",
port: int = 6333,
grpc_port: int = 6334,
api_key: Optional[str] = None,
prefer_grpc: bool = False,
timeout: int = 30,
):
"""
Initialize Qdrant knowledge store.
Args:
url: Full URL (overrides host/port)
host: Qdrant host
port: Qdrant HTTP port
grpc_port: Qdrant gRPC port
api_key: API key for Qdrant Cloud
prefer_grpc: Use gRPC instead of HTTP
timeout: Request timeout in seconds
"""
try:
from qdrant_client import QdrantClient
from qdrant_client.models import (
Distance, VectorParams, PointStruct,
Filter, FieldCondition, MatchValue
)
except ImportError:
raise ImportError(
"qdrant-client is required for Qdrant support. "
"Install with: pip install qdrant-client"
)
self._QdrantClient = QdrantClient
self._Distance = Distance
self._VectorParams = VectorParams
self._PointStruct = PointStruct
self._Filter = Filter
self._FieldCondition = FieldCondition
self._MatchValue = MatchValue
if url:
self._client = QdrantClient(url=url, api_key=api_key, timeout=timeout)
else:
self._client = QdrantClient(
host=host,
port=port,
grpc_port=grpc_port,
api_key=api_key,
prefer_grpc=prefer_grpc,
timeout=timeout,
)
logger.info(f"Connected to Qdrant at {url or f'{host}:{port}'}")
def _get_distance(self, distance: str) -> Any:
"""Convert distance string to Qdrant Distance enum."""
distance_map = {
"cosine": self._Distance.COSINE,
"euclidean": self._Distance.EUCLID,
"dot": self._Distance.DOT,
"manhattan": self._Distance.MANHATTAN,
}
return distance_map.get(distance.lower(), self._Distance.COSINE)
def create_collection(
self,
name: str,
dimension: int,
distance: str = "cosine",
metadata: Optional[Dict[str, Any]] = None
) -> None:
"""Create a new collection."""
self._client.create_collection(
collection_name=name,
vectors_config=self._VectorParams(
size=dimension,
distance=self._get_distance(distance)
)
)
logger.info(f"Created Qdrant collection: {name} (dim={dimension}, distance={distance})")
def delete_collection(self, name: str) -> bool:
"""Delete a collection."""
try:
self._client.delete_collection(collection_name=name)
logger.info(f"Deleted Qdrant collection: {name}")
return True
except Exception as e:
logger.warning(f"Failed to delete collection {name}: {e}")
return False
def collection_exists(self, name: str) -> bool:
"""Check if a collection exists."""
try:
collections = self._client.get_collections()
return any(c.name == name for c in collections.collections)
except Exception:
return False
def list_collections(self) -> List[str]:
"""List all collections."""
collections = self._client.get_collections()
return [c.name for c in collections.collections]
def insert(
self,
collection: str,
documents: List[KnowledgeDocument]
) -> List[str]:
"""Insert documents into a collection."""
points = []
ids = []
for i, doc in enumerate(documents):
if doc.embedding is None:
raise ValueError(f"Document {doc.id} has no embedding")
point = self._PointStruct(
id=i, # Qdrant prefers numeric IDs for performance
vector=doc.embedding,
payload={
"doc_id": doc.id,
"content": doc.content,
"content_hash": doc.content_hash,
"created_at": doc.created_at,
**(doc.metadata or {})
}
)
points.append(point)
ids.append(doc.id)
self._client.upsert(collection_name=collection, points=points)
logger.debug(f"Inserted {len(documents)} documents into {collection}")
return ids
def upsert(
self,
collection: str,
documents: List[KnowledgeDocument]
) -> List[str]:
"""Insert or update documents."""
return self.insert(collection, documents)
def search(
self,
collection: str,
query_embedding: List[float],
limit: int = 5,
filters: Optional[Dict[str, Any]] = None,
score_threshold: Optional[float] = None
) -> List[KnowledgeDocument]:
"""Search for similar documents."""
search_filter = None
if filters:
conditions = [
self._FieldCondition(
key=k,
match=self._MatchValue(value=v)
)
for k, v in filters.items()
]
search_filter = self._Filter(must=conditions)
results = self._client.query_points(
collection_name=collection,
query=query_embedding,
limit=limit,
query_filter=search_filter,
score_threshold=score_threshold,
)
documents = []
for point in results.points:
payload = point.payload or {}
doc = KnowledgeDocument(
id=payload.get("doc_id", str(point.id)),
content=payload.get("content", ""),
embedding=None, # Don't return embeddings by default
metadata={
k: v for k, v in payload.items()
if k not in ("doc_id", "content", "content_hash", "created_at")
},
content_hash=payload.get("content_hash"),
created_at=payload.get("created_at", 0),
)
documents.append(doc)
return documents
def get(
self,
collection: str,
ids: List[str]
) -> List[KnowledgeDocument]:
"""Get documents by IDs."""
# Qdrant uses numeric IDs internally, so we search by doc_id in payload
documents = []
for doc_id in ids:
results = self._client.scroll(
collection_name=collection,
scroll_filter=self._Filter(
must=[
self._FieldCondition(
key="doc_id",
match=self._MatchValue(value=doc_id)
)
]
),
limit=1,
)
for point in results[0]:
payload = point.payload or {}
doc = KnowledgeDocument(
id=payload.get("doc_id", str(point.id)),
content=payload.get("content", ""),
embedding=list(point.vector) if point.vector else None,
metadata={
k: v for k, v in payload.items()
if k not in ("doc_id", "content", "content_hash", "created_at")
},
content_hash=payload.get("content_hash"),
created_at=payload.get("created_at", 0),
)
documents.append(doc)
return documents
def delete(
self,
collection: str,
ids: Optional[List[str]] = None,
filters: Optional[Dict[str, Any]] = None
) -> int:
"""Delete documents by IDs or filters."""
if ids:
# Delete by doc_id in payload
for doc_id in ids:
self._client.delete(
collection_name=collection,
points_selector=self._Filter(
must=[
self._FieldCondition(
key="doc_id",
match=self._MatchValue(value=doc_id)
)
]
)
)
return len(ids)
elif filters:
conditions = [
self._FieldCondition(
key=k,
match=self._MatchValue(value=v)
)
for k, v in filters.items()
]
self._client.delete(
collection_name=collection,
points_selector=self._Filter(must=conditions)
)
return -1 # Unknown count
return 0
def count(self, collection: str) -> int:
"""Count documents in a collection."""
info = self._client.get_collection(collection_name=collection)
return info.points_count or 0
def close(self) -> None:
"""Close the store."""
if self._client:
self._client.close()
self._client = None
@@ -0,0 +1,291 @@
"""
Redis Vector implementation of KnowledgeStore.
Requires: redis
Install: pip install redis
"""
import json
import logging
from typing import Any, Dict, List, Optional
from .base import KnowledgeStore, KnowledgeDocument
logger = logging.getLogger(__name__)
class RedisVectorKnowledgeStore(KnowledgeStore):
"""
Redis-based knowledge store using RediSearch for vector search.
Requires Redis Stack or Redis with RediSearch module.
Example:
store = RedisVectorKnowledgeStore(
url="redis://localhost:6379"
)
"""
def __init__(
self,
url: Optional[str] = None,
host: str = "localhost",
port: int = 6379,
password: Optional[str] = None,
prefix: str = "praison:vec:",
):
try:
import redis
from redis.commands.search.field import VectorField, TextField, NumericField
from redis.commands.search.indexDefinition import IndexDefinition, IndexType
from redis.commands.search.query import Query
except ImportError:
raise ImportError(
"redis is required for Redis Vector support. "
"Install with: pip install redis"
)
self._redis = redis
self._VectorField = VectorField
self._TextField = TextField
self._NumericField = NumericField
self._IndexDefinition = IndexDefinition
self._IndexType = IndexType
self._Query = Query
self.prefix = prefix
if url:
self._client = redis.from_url(url, decode_responses=False)
else:
self._client = redis.Redis(
host=host, port=port, password=password, decode_responses=False
)
self._client.ping()
logger.info(f"Connected to Redis at {url or f'{host}:{port}'}")
def _index_name(self, collection: str) -> str:
return f"{self.prefix}{collection}:idx"
def _doc_key(self, collection: str, doc_id: str) -> str:
return f"{self.prefix}{collection}:{doc_id}"
def create_collection(
self,
name: str,
dimension: int,
distance: str = "cosine",
metadata: Optional[Dict[str, Any]] = None
) -> None:
"""Create a new index."""
index_name = self._index_name(name)
distance_map = {"cosine": "COSINE", "euclidean": "L2", "dot": "IP"}
schema = (
self._TextField("content"),
self._TextField("content_hash"),
self._NumericField("created_at"),
self._VectorField(
"embedding",
"HNSW",
{
"TYPE": "FLOAT32",
"DIM": dimension,
"DISTANCE_METRIC": distance_map.get(distance, "COSINE"),
}
),
)
definition = self._IndexDefinition(
prefix=[f"{self.prefix}{name}:"],
index_type=self._IndexType.HASH,
)
try:
self._client.ft(index_name).create_index(schema, definition=definition)
logger.info(f"Created Redis index: {index_name}")
except Exception as e:
if "Index already exists" not in str(e):
raise
def delete_collection(self, name: str) -> bool:
"""Delete an index and all documents."""
index_name = self._index_name(name)
try:
self._client.ft(index_name).dropindex(delete_documents=True)
return True
except Exception as e:
logger.warning(f"Failed to delete index {index_name}: {e}")
return False
def collection_exists(self, name: str) -> bool:
"""Check if an index exists."""
index_name = self._index_name(name)
try:
self._client.ft(index_name).info()
return True
except Exception:
return False
def list_collections(self) -> List[str]:
"""List all collections."""
try:
indexes = self._client.execute_command("FT._LIST")
prefix = f"{self.prefix}"
return [
idx.decode().replace(prefix, "").replace(":idx", "")
for idx in indexes
if idx.decode().startswith(prefix)
]
except Exception:
return []
def insert(
self,
collection: str,
documents: List[KnowledgeDocument]
) -> List[str]:
"""Insert documents."""
import numpy as np
ids = []
pipe = self._client.pipeline()
for doc in documents:
if doc.embedding is None:
raise ValueError(f"Document {doc.id} has no embedding")
key = self._doc_key(collection, doc.id)
embedding_bytes = np.array(doc.embedding, dtype=np.float32).tobytes()
pipe.hset(key, mapping={
"content": doc.content,
"content_hash": doc.content_hash or "",
"created_at": doc.created_at,
"embedding": embedding_bytes,
})
ids.append(doc.id)
pipe.execute()
return ids
def upsert(
self,
collection: str,
documents: List[KnowledgeDocument]
) -> List[str]:
"""Insert or update documents."""
return self.insert(collection, documents)
def search(
self,
collection: str,
query_embedding: List[float],
limit: int = 5,
filters: Optional[Dict[str, Any]] = None,
score_threshold: Optional[float] = None
) -> List[KnowledgeDocument]:
"""Search for similar documents."""
import numpy as np
index_name = self._index_name(collection)
query_bytes = np.array(query_embedding, dtype=np.float32).tobytes()
query_str = f"*=>[KNN {limit} @embedding $vec AS score]"
query = (
self._Query(query_str)
.return_fields("content", "content_hash", "created_at", "score")
.sort_by("score")
.dialect(2)
)
results = self._client.ft(index_name).search(
query, query_params={"vec": query_bytes}
)
documents = []
for doc in results.docs:
score = float(doc.score) if hasattr(doc, "score") else 0
# Redis returns distance, convert to similarity for cosine
similarity = 1 - score
if score_threshold and similarity < score_threshold:
continue
doc_id = doc.id.decode() if isinstance(doc.id, bytes) else doc.id
doc_id = doc_id.split(":")[-1] # Remove prefix
knowledge_doc = KnowledgeDocument(
id=doc_id,
content=doc.content.decode() if isinstance(doc.content, bytes) else doc.content,
embedding=None,
metadata={},
content_hash=doc.content_hash.decode() if isinstance(doc.content_hash, bytes) else doc.content_hash,
created_at=float(doc.created_at) if doc.created_at else 0,
)
documents.append(knowledge_doc)
return documents
def get(
self,
collection: str,
ids: List[str]
) -> List[KnowledgeDocument]:
"""Get documents by IDs."""
import numpy as np
documents = []
for doc_id in ids:
key = self._doc_key(collection, doc_id)
data = self._client.hgetall(key)
if data:
embedding = None
if b"embedding" in data:
embedding = np.frombuffer(data[b"embedding"], dtype=np.float32).tolist()
doc = KnowledgeDocument(
id=doc_id,
content=data.get(b"content", b"").decode(),
embedding=embedding,
metadata={},
content_hash=data.get(b"content_hash", b"").decode(),
created_at=float(data.get(b"created_at", 0)),
)
documents.append(doc)
return documents
def delete(
self,
collection: str,
ids: Optional[List[str]] = None,
filters: Optional[Dict[str, Any]] = None
) -> int:
"""Delete documents."""
if ids:
pipe = self._client.pipeline()
for doc_id in ids:
key = self._doc_key(collection, doc_id)
pipe.delete(key)
pipe.execute()
return len(ids)
return 0
def count(self, collection: str) -> int:
"""Count documents."""
index_name = self._index_name(collection)
try:
info = self._client.ft(index_name).info()
return int(info.get("num_docs", 0))
except Exception:
return 0
def close(self) -> None:
"""Close the store."""
if self._client:
self._client.close()
@@ -0,0 +1,223 @@
"""
Weaviate implementation of KnowledgeStore.
Requires: weaviate-client
Install: pip install weaviate-client
"""
import logging
import os
from typing import Any, Dict, List, Optional
from .base import KnowledgeStore, KnowledgeDocument
logger = logging.getLogger(__name__)
class WeaviateKnowledgeStore(KnowledgeStore):
"""
Weaviate-based knowledge store for vector search.
Example:
store = WeaviateKnowledgeStore(
url="http://localhost:8080"
)
"""
def __init__(
self,
url: Optional[str] = None,
api_key: Optional[str] = None,
additional_headers: Optional[Dict[str, str]] = None,
):
try:
import weaviate
except ImportError:
raise ImportError(
"weaviate-client is required for Weaviate support. "
"Install with: pip install weaviate-client"
)
url = url or os.getenv("WEAVIATE_URL", "http://localhost:8080")
api_key = api_key or os.getenv("WEAVIATE_API_KEY")
auth = weaviate.auth.AuthApiKey(api_key) if api_key else None
self._client = weaviate.connect_to_custom(
http_host=url.replace("http://", "").replace("https://", "").split(":")[0],
http_port=int(url.split(":")[-1]) if ":" in url.split("/")[-1] else 8080,
http_secure=url.startswith("https"),
auth_credentials=auth,
additional_headers=additional_headers,
)
logger.info(f"Connected to Weaviate at {url}")
def create_collection(
self,
name: str,
dimension: int,
distance: str = "cosine",
metadata: Optional[Dict[str, Any]] = None
) -> None:
"""Create a new collection (class in Weaviate)."""
from weaviate.classes.config import Configure, Property, DataType
distance_map = {"cosine": "cosine", "euclidean": "l2-squared", "dot": "dot"}
self._client.collections.create(
name=name,
vectorizer_config=Configure.Vectorizer.none(),
vector_index_config=Configure.VectorIndex.hnsw(
distance_metric=distance_map.get(distance, "cosine")
),
properties=[
Property(name="content", data_type=DataType.TEXT),
Property(name="content_hash", data_type=DataType.TEXT),
Property(name="created_at", data_type=DataType.NUMBER),
]
)
logger.info(f"Created Weaviate collection: {name}")
def delete_collection(self, name: str) -> bool:
"""Delete a collection."""
try:
self._client.collections.delete(name)
return True
except Exception as e:
logger.warning(f"Failed to delete collection {name}: {e}")
return False
def collection_exists(self, name: str) -> bool:
"""Check if a collection exists."""
return self._client.collections.exists(name)
def list_collections(self) -> List[str]:
"""List all collections."""
return [c.name for c in self._client.collections.list_all().values()]
def insert(
self,
collection: str,
documents: List[KnowledgeDocument]
) -> List[str]:
"""Insert documents."""
col = self._client.collections.get(collection)
ids = []
with col.batch.dynamic() as batch:
for doc in documents:
if doc.embedding is None:
raise ValueError(f"Document {doc.id} has no embedding")
properties = {
"content": doc.content,
"content_hash": doc.content_hash or "",
"created_at": doc.created_at,
**(doc.metadata or {})
}
batch.add_object(
properties=properties,
vector=doc.embedding,
uuid=doc.id,
)
ids.append(doc.id)
return ids
def upsert(
self,
collection: str,
documents: List[KnowledgeDocument]
) -> List[str]:
"""Insert or update documents."""
return self.insert(collection, documents)
def search(
self,
collection: str,
query_embedding: List[float],
limit: int = 5,
filters: Optional[Dict[str, Any]] = None,
score_threshold: Optional[float] = None
) -> List[KnowledgeDocument]:
"""Search for similar documents."""
col = self._client.collections.get(collection)
query = col.query.near_vector(
near_vector=query_embedding,
limit=limit,
return_metadata=["distance"],
)
documents = []
for obj in query.objects:
if score_threshold:
score = 1 - (obj.metadata.distance or 0)
if score < score_threshold:
continue
props = obj.properties
doc = KnowledgeDocument(
id=str(obj.uuid),
content=props.get("content", ""),
embedding=None,
metadata={k: v for k, v in props.items() if k not in ("content", "content_hash", "created_at")},
content_hash=props.get("content_hash"),
created_at=props.get("created_at", 0),
)
documents.append(doc)
return documents
def get(
self,
collection: str,
ids: List[str]
) -> List[KnowledgeDocument]:
"""Get documents by IDs."""
col = self._client.collections.get(collection)
documents = []
for doc_id in ids:
try:
obj = col.query.fetch_object_by_id(doc_id, include_vector=True)
if obj:
props = obj.properties
doc = KnowledgeDocument(
id=str(obj.uuid),
content=props.get("content", ""),
embedding=list(obj.vector) if obj.vector else None,
metadata={k: v for k, v in props.items() if k not in ("content", "content_hash", "created_at")},
content_hash=props.get("content_hash"),
created_at=props.get("created_at", 0),
)
documents.append(doc)
except Exception:
pass
return documents
def delete(
self,
collection: str,
ids: Optional[List[str]] = None,
filters: Optional[Dict[str, Any]] = None
) -> int:
"""Delete documents."""
col = self._client.collections.get(collection)
if ids:
for doc_id in ids:
col.data.delete_by_id(doc_id)
return len(ids)
return 0
def count(self, collection: str) -> int:
"""Count documents."""
col = self._client.collections.get(collection)
return col.aggregate.over_all(total_count=True).total_count or 0
def close(self) -> None:
"""Close the store."""
if self._client:
self._client.close()
@@ -0,0 +1,10 @@
"""
Schema versioning and migrations for PraisonAI persistence.
This module provides tools for managing database schema versions
and applying migrations when upgrading.
"""
from .manager import MigrationManager, SchemaVersion
__all__ = ["MigrationManager", "SchemaVersion"]
@@ -0,0 +1,251 @@
"""
Migration manager for PraisonAI persistence schema versioning.
"""
import logging
from dataclasses import dataclass, field
from typing import Any, Callable, Dict, List, Optional, Tuple
from packaging import version as pkg_version
logger = logging.getLogger(__name__)
@dataclass
class SchemaVersion:
"""Represents a schema version."""
version: str
description: str = ""
applied_at: Optional[float] = None
def __lt__(self, other: "SchemaVersion") -> bool:
return pkg_version.parse(self.version) < pkg_version.parse(other.version)
def __eq__(self, other: object) -> bool:
if not isinstance(other, SchemaVersion):
return False
return self.version == other.version
@dataclass
class Migration:
"""Represents a single migration."""
version: str
description: str
up: Callable[[Any], None]
down: Optional[Callable[[Any], None]] = None
class MigrationManager:
"""
Manages database schema versions and migrations.
Example:
manager = MigrationManager(db_adapter)
manager.register_migration(
version="1.0.0",
description="Initial schema",
up=lambda db: db.execute("CREATE TABLE ..."),
down=lambda db: db.execute("DROP TABLE ...")
)
manager.migrate_up() # Apply all pending migrations
"""
# Current schema version
CURRENT_VERSION = "1.0.0"
def __init__(self, db_adapter: Any = None):
"""
Initialize migration manager.
Args:
db_adapter: Database adapter (PraisonDB or similar)
"""
self._db = db_adapter
self._migrations: List[Migration] = []
self._versions_table = "praison_schema_versions"
# Register built-in migrations
self._register_builtin_migrations()
def _register_builtin_migrations(self):
"""Register built-in migrations."""
self.register_migration(
version="1.0.0",
description="Initial schema with sessions, messages, runs",
up=self._migrate_1_0_0_up,
down=self._migrate_1_0_0_down
)
def register_migration(
self,
version: str,
description: str,
up: Callable[[Any], None],
down: Optional[Callable[[Any], None]] = None
):
"""
Register a new migration.
Args:
version: Version string (e.g., "1.0.0")
description: Human-readable description
up: Function to apply migration
down: Optional function to rollback migration
"""
migration = Migration(
version=version,
description=description,
up=up,
down=down
)
self._migrations.append(migration)
self._migrations.sort(key=lambda m: pkg_version.parse(m.version))
def get_current_version(self) -> Optional[str]:
"""Get the current schema version from database."""
if not self._db:
return None
try:
# Try to get version from state store
if hasattr(self._db, '_state_store') and self._db._state_store:
self._db._init_stores()
version_data = self._db._state_store.get(f"{self._versions_table}:current")
if version_data:
return version_data.get("version")
except Exception as e:
logger.warning(f"Failed to get schema version: {e}")
return None
def set_current_version(self, version: str):
"""Set the current schema version in database."""
if not self._db:
return
try:
if hasattr(self._db, '_state_store') and self._db._state_store:
self._db._init_stores()
import time
self._db._state_store.set(f"{self._versions_table}:current", {
"version": version,
"applied_at": time.time()
})
except Exception as e:
logger.warning(f"Failed to set schema version: {e}")
def get_pending_migrations(self) -> List[Migration]:
"""Get list of migrations that haven't been applied."""
current = self.get_current_version()
if not current:
return self._migrations.copy()
current_ver = pkg_version.parse(current)
return [
m for m in self._migrations
if pkg_version.parse(m.version) > current_ver
]
def migrate_up(self, target_version: Optional[str] = None) -> List[str]:
"""
Apply pending migrations up to target version.
Args:
target_version: Optional target version (default: latest)
Returns:
List of applied migration versions
"""
pending = self.get_pending_migrations()
if not pending:
logger.info("No pending migrations")
return []
target = pkg_version.parse(target_version) if target_version else None
applied = []
for migration in pending:
if target and pkg_version.parse(migration.version) > target:
break
logger.info(f"Applying migration {migration.version}: {migration.description}")
try:
migration.up(self._db)
self.set_current_version(migration.version)
applied.append(migration.version)
logger.info(f"Successfully applied migration {migration.version}")
except Exception as e:
logger.error(f"Failed to apply migration {migration.version}: {e}")
raise
return applied
def migrate_down(self, target_version: str) -> List[str]:
"""
Rollback migrations down to target version.
Args:
target_version: Target version to rollback to
Returns:
List of rolled back migration versions
"""
current = self.get_current_version()
if not current:
logger.info("No migrations to rollback")
return []
current_ver = pkg_version.parse(current)
target_ver = pkg_version.parse(target_version)
if target_ver >= current_ver:
logger.info("Target version is not lower than current")
return []
# Get migrations to rollback (in reverse order)
to_rollback = [
m for m in reversed(self._migrations)
if pkg_version.parse(m.version) > target_ver and pkg_version.parse(m.version) <= current_ver
]
rolled_back = []
for migration in to_rollback:
if not migration.down:
logger.warning(f"Migration {migration.version} has no down function, skipping")
continue
logger.info(f"Rolling back migration {migration.version}")
try:
migration.down(self._db)
rolled_back.append(migration.version)
logger.info(f"Successfully rolled back migration {migration.version}")
except Exception as e:
logger.error(f"Failed to rollback migration {migration.version}: {e}")
raise
self.set_current_version(target_version)
return rolled_back
def get_migration_status(self) -> Dict[str, Any]:
"""Get current migration status."""
current = self.get_current_version()
pending = self.get_pending_migrations()
return {
"current_version": current,
"latest_version": self._migrations[-1].version if self._migrations else None,
"pending_count": len(pending),
"pending_versions": [m.version for m in pending],
"all_versions": [m.version for m in self._migrations]
}
# --- Built-in Migrations ---
def _migrate_1_0_0_up(self, db: Any):
"""Initial schema migration."""
# This is a no-op since tables are created on first use
logger.info("Initial schema (1.0.0) - tables created on first use")
def _migrate_1_0_0_down(self, db: Any):
"""Rollback initial schema."""
logger.info("Rolling back initial schema (1.0.0)")
@@ -0,0 +1,406 @@
"""
PersistenceOrchestrator - Central coordinator for all persistence operations.
Hooks into agent lifecycle to provide automatic conversation persistence,
knowledge retrieval, and state management.
"""
import logging
import time
import uuid
from typing import Any, Dict, List, Optional, TYPE_CHECKING
from .conversation.base import ConversationStore, ConversationSession, ConversationMessage
from .knowledge.base import KnowledgeStore, KnowledgeDocument
from .state.base import StateStore
from .config import PersistenceConfig
from .factory import create_stores_from_config
if TYPE_CHECKING:
pass
logger = logging.getLogger(__name__)
class PersistenceOrchestrator:
"""
Central coordinator for all persistence operations.
Lives in wrapper layer, hooks into agent lifecycle to provide:
- Automatic session loading/creation at agent start
- Message persistence during conversation
- Session metadata updates at agent end
- Knowledge retrieval for RAG
- State caching
Example:
orchestrator = PersistenceOrchestrator(
conversation_store=PostgresConversationStore(...),
knowledge_store=QdrantKnowledgeStore(...),
state_store=RedisStateStore(...),
)
# Hook into agent lifecycle
history = orchestrator.on_agent_start(agent, session_id="sess-123")
# ... agent runs ...
orchestrator.on_message(session_id, user_message)
orchestrator.on_message(session_id, assistant_message)
orchestrator.on_agent_end(agent, session_id)
"""
def __init__(
self,
conversation_store: Optional[ConversationStore] = None,
knowledge_store: Optional[KnowledgeStore] = None,
state_store: Optional[StateStore] = None,
config: Optional[PersistenceConfig] = None,
):
"""
Initialize the orchestrator.
Args:
conversation_store: Store for session/message persistence
knowledge_store: Store for vector embeddings
state_store: Store for key-value state
config: Configuration (alternative to passing stores directly)
"""
if config:
stores = create_stores_from_config(config)
self.conversation = stores["conversation"] or conversation_store
self.knowledge = stores["knowledge"] or knowledge_store
self.state = stores["state"] or state_store
self._config = config
else:
self.conversation = conversation_store
self.knowledge = knowledge_store
self.state = state_store
self._config = None
self._current_session: Optional[ConversationSession] = None
self._session_cache: Dict[str, ConversationSession] = {}
@classmethod
def from_config(cls, config: PersistenceConfig) -> "PersistenceOrchestrator":
"""Create orchestrator from configuration."""
return cls(config=config)
@classmethod
def from_env(cls) -> "PersistenceOrchestrator":
"""Create orchestrator from environment variables."""
config = PersistenceConfig.from_env()
return cls(config=config)
# =========================================================================
# Agent Lifecycle Hooks
# =========================================================================
def on_agent_start(
self,
agent: Any,
session_id: Optional[str] = None,
user_id: Optional[str] = None,
resume: bool = True,
) -> List[ConversationMessage]:
"""
Called before agent run. Loads or creates session.
Args:
agent: The agent instance
session_id: Session ID (generated if not provided)
user_id: User ID for session
resume: Whether to load existing session history
Returns:
List of previous messages if resuming, empty list otherwise
"""
if not session_id:
session_id = str(uuid.uuid4())
if not self.conversation:
logger.debug("No conversation store configured, skipping session load")
return []
# Try to load existing session
session = None
if resume:
session = self.conversation.get_session(session_id)
if session:
logger.info(f"Resuming session: {session_id}")
self._current_session = session
self._session_cache[session_id] = session
# Load previous messages
messages = self.conversation.get_messages(session_id)
return messages
else:
# Create new session
agent_id = getattr(agent, "name", None) or getattr(agent, "agent_id", None)
session = ConversationSession(
session_id=session_id,
user_id=user_id,
agent_id=agent_id,
name=f"Session {session_id[:8]}",
metadata={"agent_type": type(agent).__name__},
)
self.conversation.create_session(session)
logger.info(f"Created new session: {session_id}")
self._current_session = session
self._session_cache[session_id] = session
return []
def on_message(
self,
session_id: str,
role: str,
content: str,
tool_calls: Optional[List[Dict]] = None,
tool_call_id: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> Optional[ConversationMessage]:
"""
Called after each message (user or assistant).
Args:
session_id: Session ID
role: Message role (user, assistant, system, tool)
content: Message content
tool_calls: Tool calls (for assistant messages)
tool_call_id: Tool call ID (for tool response messages)
metadata: Additional metadata
Returns:
The persisted message, or None if no store configured
"""
if not self.conversation:
return None
message = ConversationMessage(
id=str(uuid.uuid4()),
session_id=session_id,
role=role,
content=content,
tool_calls=tool_calls,
tool_call_id=tool_call_id,
metadata=metadata,
)
self.conversation.add_message(session_id, message)
logger.debug(f"Persisted {role} message to session {session_id}")
return message
def on_agent_end(
self,
agent: Any,
session_id: str,
metadata: Optional[Dict[str, Any]] = None,
) -> None:
"""
Called after agent run. Updates session metadata.
Args:
agent: The agent instance
session_id: Session ID
metadata: Additional metadata to store
"""
if not self.conversation:
return
session = self._session_cache.get(session_id) or self.conversation.get_session(session_id)
if session:
session.updated_at = time.time()
if metadata:
session.metadata = {**(session.metadata or {}), **metadata}
self.conversation.update_session(session)
logger.debug(f"Updated session metadata: {session_id}")
# =========================================================================
# Knowledge Retrieval
# =========================================================================
def retrieve_knowledge(
self,
query_embedding: List[float],
collection: str = "default",
limit: int = 5,
filters: Optional[Dict[str, Any]] = None,
) -> List[KnowledgeDocument]:
"""
Retrieve relevant documents for RAG.
Args:
query_embedding: Query vector embedding
collection: Collection name
limit: Max documents to return
filters: Metadata filters
Returns:
List of relevant documents
"""
if not self.knowledge:
logger.debug("No knowledge store configured")
return []
return self.knowledge.search(
collection=collection,
query_embedding=query_embedding,
limit=limit,
filters=filters,
)
def add_knowledge(
self,
documents: List[KnowledgeDocument],
collection: str = "default",
) -> List[str]:
"""
Add documents to knowledge store.
Args:
documents: Documents with embeddings
collection: Collection name
Returns:
List of document IDs
"""
if not self.knowledge:
raise ValueError("No knowledge store configured")
return self.knowledge.upsert(collection, documents)
# =========================================================================
# State Management
# =========================================================================
def get_state(self, key: str) -> Optional[Any]:
"""Get state value."""
if not self.state:
return None
return self.state.get(key)
def set_state(self, key: str, value: Any, ttl: Optional[int] = None) -> None:
"""Set state value with optional TTL."""
if not self.state:
return
self.state.set(key, value, ttl)
def delete_state(self, key: str) -> bool:
"""Delete state value."""
if not self.state:
return False
return self.state.delete(key)
# =========================================================================
# Session Management
# =========================================================================
def get_session(self, session_id: str) -> Optional[ConversationSession]:
"""Get a session by ID."""
if not self.conversation:
return None
return self.conversation.get_session(session_id)
def list_sessions(
self,
user_id: Optional[str] = None,
limit: int = 100,
) -> List[ConversationSession]:
"""List sessions for a user."""
if not self.conversation:
return []
return self.conversation.list_sessions(user_id=user_id, limit=limit)
def delete_session(self, session_id: str) -> bool:
"""Delete a session and all its messages."""
if not self.conversation:
return False
if session_id in self._session_cache:
del self._session_cache[session_id]
return self.conversation.delete_session(session_id)
def get_messages(
self,
session_id: str,
limit: Optional[int] = None,
) -> List[ConversationMessage]:
"""Get messages from a session."""
if not self.conversation:
return []
return self.conversation.get_messages(session_id, limit=limit)
# =========================================================================
# Context Building
# =========================================================================
def build_context(
self,
session_id: str,
query_embedding: Optional[List[float]] = None,
history_limit: int = 20,
knowledge_limit: int = 5,
knowledge_collection: str = "default",
) -> Dict[str, Any]:
"""
Build context for agent including history and knowledge.
Args:
session_id: Session ID
query_embedding: Query embedding for knowledge retrieval
history_limit: Max history messages
knowledge_limit: Max knowledge documents
knowledge_collection: Knowledge collection name
Returns:
Dict with 'history' and 'knowledge' keys
"""
context = {
"history": [],
"knowledge": [],
}
# Get conversation history
if self.conversation:
messages = self.conversation.get_messages(session_id, limit=history_limit)
context["history"] = [
{"role": m.role, "content": m.content}
for m in messages
]
# Get relevant knowledge
if self.knowledge and query_embedding:
docs = self.knowledge.search(
collection=knowledge_collection,
query_embedding=query_embedding,
limit=knowledge_limit,
)
context["knowledge"] = [
{"content": d.content, "metadata": d.metadata}
for d in docs
]
return context
# =========================================================================
# Cleanup
# =========================================================================
def close(self) -> None:
"""Close all stores and release resources."""
if self.conversation:
self.conversation.close()
if self.knowledge:
self.knowledge.close()
if self.state:
self.state.close()
self._session_cache.clear()
logger.info("Persistence orchestrator closed")
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.close()
return False
@@ -0,0 +1,21 @@
"""
StateStore implementations for fast key-value state and caching.
Supported backends:
- Redis (redis)
- DynamoDB (dynamodb)
- Firestore (firestore)
- MongoDB (mongodb)
- Upstash (upstash)
- In-memory / JSON file (memory)
"""
__all__ = [
"StateStore",
]
def __getattr__(name: str):
if name == "StateStore":
from .base import StateStore
return StateStore
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,107 @@
"""
Base interfaces for StateStore.
StateStore handles fast key-value state and caching.
"""
from abc import ABC, abstractmethod
from typing import Any, Dict, List, Optional
import json
class StateStore(ABC):
"""
Abstract base class for key-value state persistence.
Implementations handle fast state/cache storage for different backends:
- Redis (dedicated KV store)
- DynamoDB, Firestore (serverless document stores)
- MongoDB (document store)
- Upstash (serverless Redis)
- In-memory / JSON file (zero-dependency fallback)
"""
@abstractmethod
def get(self, key: str) -> Optional[Any]:
"""Get a value by key. Returns None if not found."""
raise NotImplementedError
@abstractmethod
def set(
self,
key: str,
value: Any,
ttl: Optional[int] = None
) -> None:
"""Set a value. TTL is in seconds."""
raise NotImplementedError
@abstractmethod
def delete(self, key: str) -> bool:
"""Delete a key. Returns True if key existed."""
raise NotImplementedError
@abstractmethod
def exists(self, key: str) -> bool:
"""Check if a key exists."""
raise NotImplementedError
@abstractmethod
def keys(self, pattern: str = "*") -> List[str]:
"""List keys matching pattern."""
raise NotImplementedError
@abstractmethod
def ttl(self, key: str) -> Optional[int]:
"""Get remaining TTL in seconds. Returns None if no TTL or key doesn't exist."""
raise NotImplementedError
@abstractmethod
def expire(self, key: str, ttl: int) -> bool:
"""Set TTL on existing key. Returns True if key exists."""
raise NotImplementedError
def get_json(self, key: str) -> Optional[Any]:
"""Get and deserialize JSON value."""
value = self.get(key)
if value is None:
return None
if isinstance(value, str):
return json.loads(value)
return value
def set_json(self, key: str, value: Any, ttl: Optional[int] = None) -> None:
"""Serialize and set JSON value."""
self.set(key, json.dumps(value), ttl)
@abstractmethod
def hget(self, key: str, field: str) -> Optional[Any]:
"""Get a field from a hash."""
raise NotImplementedError
@abstractmethod
def hset(self, key: str, field: str, value: Any) -> None:
"""Set a field in a hash."""
raise NotImplementedError
@abstractmethod
def hgetall(self, key: str) -> Dict[str, Any]:
"""Get all fields from a hash."""
raise NotImplementedError
@abstractmethod
def hdel(self, key: str, *fields: str) -> int:
"""Delete fields from a hash. Returns count deleted."""
raise NotImplementedError
@abstractmethod
def close(self) -> None:
"""Close the store and release resources."""
raise NotImplementedError
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.close()
return False
@@ -0,0 +1,226 @@
"""
DynamoDB implementation of StateStore.
Requires: boto3
Install: pip install boto3
"""
import json
import logging
import os
import time
from typing import Any, Dict, List, Optional
from .base import StateStore
logger = logging.getLogger(__name__)
class DynamoDBStateStore(StateStore):
"""
DynamoDB-based state store.
Example:
store = DynamoDBStateStore(
table_name="praisonai_state",
region="us-east-1"
)
"""
def __init__(
self,
table_name: str = "praisonai_state",
region: Optional[str] = None,
endpoint_url: Optional[str] = None,
auto_create_table: bool = True,
):
try:
import boto3
except ImportError:
raise ImportError(
"boto3 is required for DynamoDB support. "
"Install with: pip install boto3"
)
region = region or os.getenv("AWS_REGION", "us-east-1")
self._dynamodb = boto3.resource(
"dynamodb",
region_name=region,
endpoint_url=endpoint_url,
)
self._client = boto3.client(
"dynamodb",
region_name=region,
endpoint_url=endpoint_url,
)
self.table_name = table_name
if auto_create_table:
self._create_table()
self._table = self._dynamodb.Table(table_name)
logger.info(f"Connected to DynamoDB table: {table_name}")
def _create_table(self) -> None:
"""Create table if not exists."""
try:
self._client.describe_table(TableName=self.table_name)
except self._client.exceptions.ResourceNotFoundException:
self._client.create_table(
TableName=self.table_name,
KeySchema=[{"AttributeName": "pk", "KeyType": "HASH"}],
AttributeDefinitions=[{"AttributeName": "pk", "AttributeType": "S"}],
BillingMode="PAY_PER_REQUEST",
)
waiter = self._client.get_waiter("table_exists")
waiter.wait(TableName=self.table_name)
# Enable TTL
self._client.update_time_to_live(
TableName=self.table_name,
TimeToLiveSpecification={"Enabled": True, "AttributeName": "ttl"}
)
logger.info(f"Created DynamoDB table: {self.table_name}")
def get(self, key: str) -> Optional[Any]:
"""Get a value by key."""
response = self._table.get_item(Key={"pk": key})
item = response.get("Item")
if not item:
return None
# Check TTL
if item.get("ttl") and item["ttl"] <= int(time.time()):
return None
value = item.get("value")
if isinstance(value, str):
try:
return json.loads(value)
except json.JSONDecodeError:
return value
return value
def set(
self,
key: str,
value: Any,
ttl: Optional[int] = None
) -> None:
"""Set a value with optional TTL."""
item = {
"pk": key,
"value": json.dumps(value) if not isinstance(value, str) else value,
"updated_at": int(time.time()),
}
if ttl:
item["ttl"] = int(time.time()) + ttl
self._table.put_item(Item=item)
def delete(self, key: str) -> bool:
"""Delete a key."""
response = self._table.delete_item(
Key={"pk": key},
ReturnValues="ALL_OLD"
)
return "Attributes" in response
def exists(self, key: str) -> bool:
"""Check if a key exists."""
response = self._table.get_item(
Key={"pk": key},
ProjectionExpression="pk,#t",
ExpressionAttributeNames={"#t": "ttl"}
)
item = response.get("Item")
if not item:
return False
if item.get("ttl") and item["ttl"] <= int(time.time()):
return False
return True
def keys(self, pattern: str = "*") -> List[str]:
"""List keys matching pattern."""
# DynamoDB scan is expensive, use with caution
response = self._table.scan(ProjectionExpression="pk")
keys = [item["pk"] for item in response.get("Items", [])]
if pattern != "*":
import fnmatch
keys = [k for k in keys if fnmatch.fnmatch(k, pattern)]
return keys
def ttl(self, key: str) -> Optional[int]:
"""Get remaining TTL in seconds."""
response = self._table.get_item(
Key={"pk": key},
ProjectionExpression="#t",
ExpressionAttributeNames={"#t": "ttl"}
)
item = response.get("Item")
if not item or "ttl" not in item:
return None
remaining = item["ttl"] - int(time.time())
if remaining <= 0:
return None
return remaining
def expire(self, key: str, ttl: int) -> bool:
"""Set TTL on existing key."""
try:
self._table.update_item(
Key={"pk": key},
UpdateExpression="SET #t = :ttl",
ExpressionAttributeNames={"#t": "ttl"},
ExpressionAttributeValues={":ttl": int(time.time()) + ttl},
ConditionExpression="attribute_exists(pk)"
)
return True
except self._client.exceptions.ConditionalCheckFailedException:
return False
def hget(self, key: str, field: str) -> Optional[Any]:
"""Get a field from a hash."""
value = self.get(key)
if not isinstance(value, dict):
return None
return value.get(field)
def hset(self, key: str, field: str, value: Any) -> None:
"""Set a field in a hash."""
current = self.get(key)
if not isinstance(current, dict):
current = {}
current[field] = value
self.set(key, current)
def hgetall(self, key: str) -> Dict[str, Any]:
"""Get all fields from a hash."""
value = self.get(key)
if not isinstance(value, dict):
return {}
return value
def hdel(self, key: str, *fields: str) -> int:
"""Delete fields from a hash."""
current = self.get(key)
if not isinstance(current, dict):
return 0
count = 0
for field in fields:
if field in current:
del current[field]
count += 1
if count > 0:
self.set(key, current)
return count
def close(self) -> None:
"""Close the store."""
pass # boto3 handles cleanup
@@ -0,0 +1,175 @@
"""
Firestore implementation of StateStore.
Requires: google-cloud-firestore
Install: pip install google-cloud-firestore
"""
import json
import logging
import os
import time
from typing import Any, Dict, List, Optional
from .base import StateStore
logger = logging.getLogger(__name__)
class FirestoreStateStore(StateStore):
"""
Firestore-based state store.
Example:
store = FirestoreStateStore(
project="my-project",
collection="praisonai_state"
)
"""
def __init__(
self,
project: Optional[str] = None,
collection: str = "praisonai_state",
credentials_path: Optional[str] = None,
):
try:
from google.cloud import firestore
except ImportError:
raise ImportError(
"google-cloud-firestore is required for Firestore support. "
"Install with: pip install google-cloud-firestore"
)
if credentials_path:
os.environ["GOOGLE_APPLICATION_CREDENTIALS"] = credentials_path
project = project or os.getenv("FIRESTORE_PROJECT")
self._client = firestore.Client(project=project)
self._collection = self._client.collection(collection)
logger.info(f"Connected to Firestore collection: {collection}")
def get(self, key: str) -> Optional[Any]:
"""Get a value by key."""
doc = self._collection.document(key).get()
if not doc.exists:
return None
data = doc.to_dict()
# Check TTL
if data.get("expires_at") and data["expires_at"] <= time.time():
self._collection.document(key).delete()
return None
return data.get("value")
def set(
self,
key: str,
value: Any,
ttl: Optional[int] = None
) -> None:
"""Set a value with optional TTL."""
data = {"value": value, "updated_at": time.time()}
if ttl:
data["expires_at"] = time.time() + ttl
self._collection.document(key).set(data)
def delete(self, key: str) -> bool:
"""Delete a key."""
doc = self._collection.document(key)
if doc.get().exists:
doc.delete()
return True
return False
def exists(self, key: str) -> bool:
"""Check if a key exists."""
doc = self._collection.document(key).get()
if not doc.exists:
return False
data = doc.to_dict()
if data.get("expires_at") and data["expires_at"] <= time.time():
return False
return True
def keys(self, pattern: str = "*") -> List[str]:
"""List keys matching pattern."""
docs = self._collection.stream()
keys = [doc.id for doc in docs]
if pattern != "*":
import fnmatch
keys = [k for k in keys if fnmatch.fnmatch(k, pattern)]
return keys
def ttl(self, key: str) -> Optional[int]:
"""Get remaining TTL in seconds."""
doc = self._collection.document(key).get()
if not doc.exists:
return None
data = doc.to_dict()
if "expires_at" not in data:
return None
remaining = data["expires_at"] - time.time()
if remaining <= 0:
return None
return int(remaining)
def expire(self, key: str, ttl: int) -> bool:
"""Set TTL on existing key."""
doc = self._collection.document(key)
if not doc.get().exists:
return False
doc.update({"expires_at": time.time() + ttl})
return True
def hget(self, key: str, field: str) -> Optional[Any]:
"""Get a field from a hash."""
value = self.get(key)
if not isinstance(value, dict):
return None
return value.get(field)
def hset(self, key: str, field: str, value: Any) -> None:
"""Set a field in a hash."""
current = self.get(key)
if not isinstance(current, dict):
current = {}
current[field] = value
self.set(key, current)
def hgetall(self, key: str) -> Dict[str, Any]:
"""Get all fields from a hash."""
value = self.get(key)
if not isinstance(value, dict):
return {}
return value
def hdel(self, key: str, *fields: str) -> int:
"""Delete fields from a hash."""
current = self.get(key)
if not isinstance(current, dict):
return 0
count = 0
for field in fields:
if field in current:
del current[field]
count += 1
if count > 0:
self.set(key, current)
return count
def close(self) -> None:
"""Close the store."""
pass # Firestore client handles cleanup
@@ -0,0 +1,245 @@
"""
In-memory / JSON file implementation of StateStore.
Zero external dependencies - uses Python built-ins.
"""
import json
import logging
import os
import threading
import time
from typing import Any, Dict, List, Optional
from .base import StateStore
logger = logging.getLogger(__name__)
class MemoryStateStore(StateStore):
"""
In-memory state store with optional JSON file persistence.
Zero external dependencies - ideal for development and testing.
Example:
# In-memory only
store = MemoryStateStore()
# With file persistence
store = MemoryStateStore(path="./state.json")
"""
def __init__(
self,
path: Optional[str] = None,
auto_save: bool = True,
save_interval: int = 60,
):
"""
Initialize memory state store.
Args:
path: Path to JSON file for persistence (None = in-memory only)
auto_save: Auto-save to file periodically
save_interval: Save interval in seconds
"""
self.path = path
self.auto_save = auto_save
self.save_interval = save_interval
self._data: Dict[str, Any] = {}
self._ttls: Dict[str, float] = {} # key -> expiry timestamp
self._lock = threading.RLock()
self._last_save = time.time()
# Load from file if exists
if path and os.path.exists(path):
self._load()
logger.info(f"MemoryStateStore initialized (path={path})")
def _load(self) -> None:
"""Load state from JSON file."""
if not self.path:
return
try:
with open(self.path, "r") as f:
saved = json.load(f)
self._data = saved.get("data", {})
self._ttls = saved.get("ttls", {})
# Clean expired keys
now = time.time()
expired = [k for k, exp in self._ttls.items() if exp <= now]
for k in expired:
self._data.pop(k, None)
self._ttls.pop(k, None)
logger.debug(f"Loaded {len(self._data)} keys from {self.path}")
except Exception as e:
logger.warning(f"Failed to load state from {self.path}: {e}")
def _save(self) -> None:
"""Save state to JSON file."""
if not self.path:
return
try:
os.makedirs(os.path.dirname(self.path) or ".", exist_ok=True)
with open(self.path, "w") as f:
json.dump({"data": self._data, "ttls": self._ttls}, f)
self._last_save = time.time()
except Exception as e:
logger.warning(f"Failed to save state to {self.path}: {e}")
def _maybe_save(self) -> None:
"""Save if auto_save is enabled and interval has passed."""
if self.auto_save and self.path:
if time.time() - self._last_save >= self.save_interval:
self._save()
def _check_ttl(self, key: str) -> bool:
"""Check if key is expired. Returns True if valid, False if expired."""
if key not in self._ttls:
return True
if self._ttls[key] <= time.time():
self._data.pop(key, None)
self._ttls.pop(key, None)
return False
return True
def get(self, key: str) -> Optional[Any]:
"""Get a value by key."""
with self._lock:
if not self._check_ttl(key):
return None
return self._data.get(key)
def set(
self,
key: str,
value: Any,
ttl: Optional[int] = None
) -> None:
"""Set a value with optional TTL."""
with self._lock:
self._data[key] = value
if ttl:
self._ttls[key] = time.time() + ttl
elif key in self._ttls:
del self._ttls[key]
self._maybe_save()
def delete(self, key: str) -> bool:
"""Delete a key."""
with self._lock:
existed = key in self._data
self._data.pop(key, None)
self._ttls.pop(key, None)
self._maybe_save()
return existed
def exists(self, key: str) -> bool:
"""Check if a key exists."""
with self._lock:
if not self._check_ttl(key):
return False
return key in self._data
def keys(self, pattern: str = "*") -> List[str]:
"""List keys matching pattern."""
import fnmatch
with self._lock:
# Clean expired keys first
now = time.time()
expired = [k for k, exp in self._ttls.items() if exp <= now]
for k in expired:
self._data.pop(k, None)
self._ttls.pop(k, None)
if pattern == "*":
return list(self._data.keys())
return [k for k in self._data.keys() if fnmatch.fnmatch(k, pattern)]
def ttl(self, key: str) -> Optional[int]:
"""Get remaining TTL in seconds."""
with self._lock:
if key not in self._ttls:
return None
remaining = self._ttls[key] - time.time()
if remaining <= 0:
self._data.pop(key, None)
self._ttls.pop(key, None)
return None
return int(remaining)
def expire(self, key: str, ttl: int) -> bool:
"""Set TTL on existing key."""
with self._lock:
if key not in self._data:
return False
self._ttls[key] = time.time() + ttl
self._maybe_save()
return True
def hget(self, key: str, field: str) -> Optional[Any]:
"""Get a field from a hash."""
with self._lock:
if not self._check_ttl(key):
return None
data = self._data.get(key)
if not isinstance(data, dict):
return None
return data.get(field)
def hset(self, key: str, field: str, value: Any) -> None:
"""Set a field in a hash."""
with self._lock:
if key not in self._data or not isinstance(self._data[key], dict):
self._data[key] = {}
self._data[key][field] = value
self._maybe_save()
def hgetall(self, key: str) -> Dict[str, Any]:
"""Get all fields from a hash."""
with self._lock:
if not self._check_ttl(key):
return {}
data = self._data.get(key)
if not isinstance(data, dict):
return {}
return dict(data)
def hdel(self, key: str, *fields: str) -> int:
"""Delete fields from a hash."""
with self._lock:
if key not in self._data or not isinstance(self._data[key], dict):
return 0
count = 0
for field in fields:
if field in self._data[key]:
del self._data[key][field]
count += 1
self._maybe_save()
return count
def flush(self) -> None:
"""Force save to file."""
with self._lock:
self._save()
def clear(self) -> None:
"""Clear all data."""
with self._lock:
self._data.clear()
self._ttls.clear()
self._maybe_save()
def close(self) -> None:
"""Close the store and save."""
with self._lock:
if self.path:
self._save()
@@ -0,0 +1,158 @@
"""
MongoDB implementation of StateStore.
Requires: pymongo
Install: pip install pymongo
"""
import json
import logging
import time
from typing import Any, Dict, List, Optional
from .base import StateStore
logger = logging.getLogger(__name__)
class MongoDBStateStore(StateStore):
"""
MongoDB-based state store.
Example:
store = MongoDBStateStore(
url="mongodb://localhost:27017",
database="praisonai"
)
"""
def __init__(
self,
url: str = "mongodb://localhost:27017",
database: str = "praisonai",
collection: str = "state",
):
try:
from pymongo import MongoClient
except ImportError:
raise ImportError(
"pymongo is required for MongoDB support. "
"Install with: pip install pymongo"
)
self._client = MongoClient(url)
self._db = self._client[database]
self._collection = self._db[collection]
# Create TTL index for automatic expiration
self._collection.create_index("expires_at", expireAfterSeconds=0)
logger.info(f"Connected to MongoDB: {database}.{collection}")
def get(self, key: str) -> Optional[Any]:
"""Get a value by key."""
doc = self._collection.find_one({"_id": key})
if not doc:
return None
# Check TTL
if doc.get("expires_at") and doc["expires_at"] <= time.time():
self._collection.delete_one({"_id": key})
return None
return doc.get("value")
def set(
self,
key: str,
value: Any,
ttl: Optional[int] = None
) -> None:
"""Set a value with optional TTL."""
doc = {"_id": key, "value": value, "updated_at": time.time()}
if ttl:
from datetime import datetime, timedelta
doc["expires_at"] = datetime.utcnow() + timedelta(seconds=ttl)
self._collection.replace_one({"_id": key}, doc, upsert=True)
def delete(self, key: str) -> bool:
"""Delete a key."""
result = self._collection.delete_one({"_id": key})
return result.deleted_count > 0
def exists(self, key: str) -> bool:
"""Check if a key exists."""
doc = self._collection.find_one({"_id": key}, {"_id": 1, "expires_at": 1})
if not doc:
return False
if doc.get("expires_at") and doc["expires_at"] <= time.time():
return False
return True
def keys(self, pattern: str = "*") -> List[str]:
"""List keys matching pattern."""
if pattern == "*":
cursor = self._collection.find({}, {"_id": 1})
else:
# Convert glob pattern to regex
import re
regex = pattern.replace("*", ".*").replace("?", ".")
cursor = self._collection.find({"_id": {"$regex": f"^{regex}$"}}, {"_id": 1})
return [doc["_id"] for doc in cursor]
def ttl(self, key: str) -> Optional[int]:
"""Get remaining TTL in seconds."""
doc = self._collection.find_one({"_id": key}, {"expires_at": 1})
if not doc or "expires_at" not in doc:
return None
remaining = doc["expires_at"].timestamp() - time.time()
if remaining <= 0:
return None
return int(remaining)
def expire(self, key: str, ttl: int) -> bool:
"""Set TTL on existing key."""
from datetime import datetime, timedelta
result = self._collection.update_one(
{"_id": key},
{"$set": {"expires_at": datetime.utcnow() + timedelta(seconds=ttl)}}
)
return result.modified_count > 0
def hget(self, key: str, field: str) -> Optional[Any]:
"""Get a field from a hash."""
doc = self._collection.find_one({"_id": key})
if not doc or not isinstance(doc.get("value"), dict):
return None
return doc["value"].get(field)
def hset(self, key: str, field: str, value: Any) -> None:
"""Set a field in a hash."""
self._collection.update_one(
{"_id": key},
{"$set": {f"value.{field}": value, "updated_at": time.time()}},
upsert=True
)
def hgetall(self, key: str) -> Dict[str, Any]:
"""Get all fields from a hash."""
doc = self._collection.find_one({"_id": key})
if not doc or not isinstance(doc.get("value"), dict):
return {}
return doc["value"]
def hdel(self, key: str, *fields: str) -> int:
"""Delete fields from a hash."""
unset = {f"value.{field}": "" for field in fields}
result = self._collection.update_one({"_id": key}, {"$unset": unset})
return len(fields) if result.modified_count > 0 else 0
def close(self) -> None:
"""Close the store."""
if self._client:
self._client.close()
@@ -0,0 +1,190 @@
"""
Redis implementation of StateStore.
Requires: redis
Install: pip install redis
"""
import json
import logging
from typing import Any, Dict, List, Optional
from .base import StateStore
logger = logging.getLogger(__name__)
class RedisStateStore(StateStore):
"""
Redis-based state store for fast key-value operations.
Example:
store = RedisStateStore(
url="redis://localhost:6379"
)
"""
def __init__(
self,
url: Optional[str] = None,
host: str = "localhost",
port: int = 6379,
db: int = 0,
password: Optional[str] = None,
prefix: str = "praison:",
decode_responses: bool = True,
socket_timeout: int = 5,
max_connections: int = 10,
):
"""
Initialize Redis state store.
Args:
url: Full Redis URL (overrides host/port/db/password)
host: Redis host
port: Redis port
db: Redis database number
password: Redis password
prefix: Key prefix for namespacing
decode_responses: Decode bytes to strings
socket_timeout: Socket timeout in seconds
max_connections: Max connections in pool
"""
try:
import redis as redis_lib
except ImportError:
raise ImportError(
"redis is required for Redis support. "
"Install with: pip install redis"
)
self._redis_lib = redis_lib
self.prefix = prefix
if url:
self._client = redis_lib.from_url(
url,
decode_responses=decode_responses,
socket_timeout=socket_timeout,
max_connections=max_connections,
)
else:
pool = redis_lib.ConnectionPool(
host=host,
port=port,
db=db,
password=password,
decode_responses=decode_responses,
socket_timeout=socket_timeout,
max_connections=max_connections,
)
self._client = redis_lib.Redis(connection_pool=pool)
# Test connection
self._client.ping()
logger.info(f"Connected to Redis at {url or f'{host}:{port}'}")
def _key(self, key: str) -> str:
"""Add prefix to key."""
return f"{self.prefix}{key}"
def get(self, key: str) -> Optional[Any]:
"""Get a value by key."""
value = self._client.get(self._key(key))
if value is None:
return None
# Try to deserialize JSON
try:
return json.loads(value)
except (json.JSONDecodeError, TypeError):
return value
def set(
self,
key: str,
value: Any,
ttl: Optional[int] = None
) -> None:
"""Set a value with optional TTL."""
# Serialize non-string values
if not isinstance(value, str):
value = json.dumps(value)
if ttl:
self._client.setex(self._key(key), ttl, value)
else:
self._client.set(self._key(key), value)
def delete(self, key: str) -> bool:
"""Delete a key."""
return self._client.delete(self._key(key)) > 0
def exists(self, key: str) -> bool:
"""Check if a key exists."""
return self._client.exists(self._key(key)) > 0
def keys(self, pattern: str = "*") -> List[str]:
"""List keys matching pattern."""
full_pattern = self._key(pattern)
keys = self._client.keys(full_pattern)
# Remove prefix from returned keys
prefix_len = len(self.prefix)
return [k[prefix_len:] if k.startswith(self.prefix) else k for k in keys]
def ttl(self, key: str) -> Optional[int]:
"""Get remaining TTL in seconds."""
result = self._client.ttl(self._key(key))
if result < 0: # -1 = no TTL, -2 = key doesn't exist
return None
return result
def expire(self, key: str, ttl: int) -> bool:
"""Set TTL on existing key."""
return self._client.expire(self._key(key), ttl)
def hget(self, key: str, field: str) -> Optional[Any]:
"""Get a field from a hash."""
value = self._client.hget(self._key(key), field)
if value is None:
return None
try:
return json.loads(value)
except (json.JSONDecodeError, TypeError):
return value
def hset(self, key: str, field: str, value: Any) -> None:
"""Set a field in a hash."""
if not isinstance(value, str):
value = json.dumps(value)
self._client.hset(self._key(key), field, value)
def hgetall(self, key: str) -> Dict[str, Any]:
"""Get all fields from a hash."""
data = self._client.hgetall(self._key(key))
result = {}
for k, v in data.items():
try:
result[k] = json.loads(v)
except (json.JSONDecodeError, TypeError):
result[k] = v
return result
def hdel(self, key: str, *fields: str) -> int:
"""Delete fields from a hash."""
if not fields:
return 0
return self._client.hdel(self._key(key), *fields)
def incr(self, key: str, amount: int = 1) -> int:
"""Increment a counter."""
return self._client.incrby(self._key(key), amount)
def decr(self, key: str, amount: int = 1) -> int:
"""Decrement a counter."""
return self._client.decrby(self._key(key), amount)
def close(self) -> None:
"""Close the store."""
if self._client:
self._client.close()
self._client = None
@@ -0,0 +1,144 @@
"""
Upstash implementation of StateStore.
Requires: upstash-redis
Install: pip install upstash-redis
"""
import json
import logging
import os
from typing import Any, Dict, List, Optional
from .base import StateStore
logger = logging.getLogger(__name__)
class UpstashStateStore(StateStore):
"""
Upstash-based state store (serverless Redis).
Example:
store = UpstashStateStore(
url="https://xxx.upstash.io",
token="your-token"
)
"""
def __init__(
self,
url: Optional[str] = None,
token: Optional[str] = None,
prefix: str = "praison:",
):
try:
from upstash_redis import Redis
except ImportError:
raise ImportError(
"upstash-redis is required for Upstash support. "
"Install with: pip install upstash-redis"
)
url = url or os.getenv("UPSTASH_REDIS_URL")
token = token or os.getenv("UPSTASH_REDIS_TOKEN")
if not url or not token:
raise ValueError("Upstash URL and token are required")
self._client = Redis(url=url, token=token)
self.prefix = prefix
logger.info(f"Connected to Upstash Redis")
def _key(self, key: str) -> str:
"""Add prefix to key."""
return f"{self.prefix}{key}"
def get(self, key: str) -> Optional[Any]:
"""Get a value by key."""
value = self._client.get(self._key(key))
if value is None:
return None
try:
return json.loads(value)
except (json.JSONDecodeError, TypeError):
return value
def set(
self,
key: str,
value: Any,
ttl: Optional[int] = None
) -> None:
"""Set a value with optional TTL."""
if not isinstance(value, str):
value = json.dumps(value)
if ttl:
self._client.setex(self._key(key), ttl, value)
else:
self._client.set(self._key(key), value)
def delete(self, key: str) -> bool:
"""Delete a key."""
return self._client.delete(self._key(key)) > 0
def exists(self, key: str) -> bool:
"""Check if a key exists."""
return self._client.exists(self._key(key)) > 0
def keys(self, pattern: str = "*") -> List[str]:
"""List keys matching pattern."""
full_pattern = self._key(pattern)
keys = self._client.keys(full_pattern)
prefix_len = len(self.prefix)
return [k[prefix_len:] if k.startswith(self.prefix) else k for k in keys]
def ttl(self, key: str) -> Optional[int]:
"""Get remaining TTL in seconds."""
result = self._client.ttl(self._key(key))
if result < 0:
return None
return result
def expire(self, key: str, ttl: int) -> bool:
"""Set TTL on existing key."""
return self._client.expire(self._key(key), ttl)
def hget(self, key: str, field: str) -> Optional[Any]:
"""Get a field from a hash."""
value = self._client.hget(self._key(key), field)
if value is None:
return None
try:
return json.loads(value)
except (json.JSONDecodeError, TypeError):
return value
def hset(self, key: str, field: str, value: Any) -> None:
"""Set a field in a hash."""
if not isinstance(value, str):
value = json.dumps(value)
self._client.hset(self._key(key), field, value)
def hgetall(self, key: str) -> Dict[str, Any]:
"""Get all fields from a hash."""
data = self._client.hgetall(self._key(key))
result = {}
for k, v in data.items():
try:
result[k] = json.loads(v)
except (json.JSONDecodeError, TypeError):
result[k] = v
return result
def hdel(self, key: str, *fields: str) -> int:
"""Delete fields from a hash."""
if not fields:
return 0
return self._client.hdel(self._key(key), *fields)
def close(self) -> None:
"""Close the store."""
pass # Upstash is stateless HTTP
@@ -0,0 +1,3 @@
"""
Comprehensive TDD tests for PraisonAI persistence layer.
"""
@@ -0,0 +1,633 @@
"""
Comprehensive TDD tests for all 22 database backends.
Tests are organized by category:
1. ConversationStore backends (6): PostgreSQL, MySQL, SQLite, SingleStore, Supabase, SurrealDB
2. KnowledgeStore backends (10): Qdrant, ChromaDB, Pinecone, Weaviate, LanceDB, Milvus, PGVector, Redis Vector, Cassandra, ClickHouse
3. StateStore backends (6): Redis, Memory, MongoDB, DynamoDB, Firestore, Upstash
Run with: python -m pytest praisonai/persistence/tests/test_all_backends.py -v
"""
import sys
import os
import time
import uuid
import random
import json
# Add the package to path
sys.path.insert(0, '/Users/praison/praisonai-package/src/praisonai')
from praisonai.persistence.conversation.base import ConversationStore, ConversationSession, ConversationMessage
from praisonai.persistence.knowledge.base import KnowledgeStore, KnowledgeDocument
from praisonai.persistence.state.base import StateStore
from praisonai.persistence.orchestrator import PersistenceOrchestrator
from praisonai.persistence.factory import create_conversation_store, create_knowledge_store, create_state_store
# =============================================================================
# Test Utilities
# =============================================================================
def generate_test_session() -> ConversationSession:
"""Generate a test session with unique ID."""
return ConversationSession(
session_id=f"test-{uuid.uuid4().hex[:8]}",
user_id="test-user",
agent_id="test-agent",
name="Test Session",
metadata={"test": True, "timestamp": time.time()}
)
def generate_test_message(session_id: str, role: str = "user", content: str = None) -> ConversationMessage:
"""Generate a test message."""
return ConversationMessage(
id=f"msg-{uuid.uuid4().hex[:8]}",
session_id=session_id,
role=role,
content=content or f"Test message from {role} at {time.time()}",
metadata={"test": True}
)
def generate_test_document(dim: int = 384) -> KnowledgeDocument:
"""Generate a test document with random embedding."""
random.seed(42)
return KnowledgeDocument(
id=f"doc-{uuid.uuid4().hex[:8]}",
content=f"Test document content {uuid.uuid4().hex[:8]}",
embedding=[random.random() for _ in range(dim)],
metadata={"test": True, "category": "test"}
)
# =============================================================================
# ConversationStore Tests
# =============================================================================
class ConversationStoreTestMixin:
"""Mixin with standard tests for all ConversationStore implementations."""
store: ConversationStore = None
def test_create_session(self):
"""Test session creation."""
session = generate_test_session()
result = self.store.create_session(session)
assert result.session_id == session.session_id
assert result.user_id == session.user_id
# Cleanup
self.store.delete_session(session.session_id)
def test_get_session(self):
"""Test session retrieval."""
session = generate_test_session()
self.store.create_session(session)
retrieved = self.store.get_session(session.session_id)
assert retrieved is not None
assert retrieved.session_id == session.session_id
assert retrieved.name == session.name
# Cleanup
self.store.delete_session(session.session_id)
def test_update_session(self):
"""Test session update."""
session = generate_test_session()
self.store.create_session(session)
session.name = "Updated Name"
session.metadata = {"updated": True}
self.store.update_session(session)
retrieved = self.store.get_session(session.session_id)
assert retrieved.name == "Updated Name"
# Cleanup
self.store.delete_session(session.session_id)
def test_delete_session(self):
"""Test session deletion."""
session = generate_test_session()
self.store.create_session(session)
result = self.store.delete_session(session.session_id)
assert result is True
retrieved = self.store.get_session(session.session_id)
assert retrieved is None
def test_add_message(self):
"""Test adding messages to session."""
session = generate_test_session()
self.store.create_session(session)
msg = generate_test_message(session.session_id, "user", "Hello!")
result = self.store.add_message(session.session_id, msg)
assert result.id == msg.id
assert result.content == "Hello!"
# Cleanup
self.store.delete_session(session.session_id)
def test_get_messages(self):
"""Test retrieving messages from session."""
session = generate_test_session()
self.store.create_session(session)
# Add multiple messages
for i in range(5):
role = "user" if i % 2 == 0 else "assistant"
msg = generate_test_message(session.session_id, role, f"Message {i}")
self.store.add_message(session.session_id, msg)
messages = self.store.get_messages(session.session_id)
assert len(messages) == 5
# Test limit
limited = self.store.get_messages(session.session_id, limit=3)
assert len(limited) == 3
# Cleanup
self.store.delete_session(session.session_id)
def test_list_sessions(self):
"""Test listing sessions."""
sessions = []
for i in range(3):
session = generate_test_session()
session.user_id = "list-test-user"
self.store.create_session(session)
sessions.append(session)
listed = self.store.list_sessions(user_id="list-test-user")
assert len(listed) >= 3
# Cleanup
for s in sessions:
self.store.delete_session(s.session_id)
# =============================================================================
# KnowledgeStore Tests
# =============================================================================
class KnowledgeStoreTestMixin:
"""Mixin with standard tests for all KnowledgeStore implementations."""
store: KnowledgeStore = None
collection_name: str = "test_collection"
dimension: int = 384
def test_create_collection(self):
"""Test collection creation."""
if self.store.collection_exists(self.collection_name):
self.store.delete_collection(self.collection_name)
self.store.create_collection(self.collection_name, self.dimension)
assert self.store.collection_exists(self.collection_name)
# Cleanup
self.store.delete_collection(self.collection_name)
def test_insert_documents(self):
"""Test document insertion."""
if not self.store.collection_exists(self.collection_name):
self.store.create_collection(self.collection_name, self.dimension)
docs = [generate_test_document(self.dimension) for _ in range(5)]
ids = self.store.insert(self.collection_name, docs)
assert len(ids) == 5
# Cleanup
self.store.delete_collection(self.collection_name)
def test_search_documents(self):
"""Test vector search."""
if not self.store.collection_exists(self.collection_name):
self.store.create_collection(self.collection_name, self.dimension)
# Insert documents
docs = [generate_test_document(self.dimension) for _ in range(10)]
self.store.insert(self.collection_name, docs)
# Search
random.seed(42)
query_embedding = [random.random() for _ in range(self.dimension)]
results = self.store.search(self.collection_name, query_embedding, limit=5)
assert len(results) <= 5
# Cleanup
self.store.delete_collection(self.collection_name)
def test_delete_documents(self):
"""Test document deletion."""
if not self.store.collection_exists(self.collection_name):
self.store.create_collection(self.collection_name, self.dimension)
docs = [generate_test_document(self.dimension) for _ in range(3)]
ids = self.store.insert(self.collection_name, docs)
deleted = self.store.delete(self.collection_name, ids=ids[:1])
assert deleted >= 1
# Cleanup
self.store.delete_collection(self.collection_name)
# =============================================================================
# StateStore Tests
# =============================================================================
class StateStoreTestMixin:
"""Mixin with standard tests for all StateStore implementations."""
store: StateStore = None
def test_set_get(self):
"""Test basic set/get operations."""
key = f"test:{uuid.uuid4().hex[:8]}"
value = {"test": True, "count": 42}
self.store.set(key, value)
retrieved = self.store.get(key)
assert retrieved == value
# Cleanup
self.store.delete(key)
def test_set_with_ttl(self):
"""Test set with TTL."""
key = f"test:ttl:{uuid.uuid4().hex[:8]}"
self.store.set(key, "temporary", ttl=3600)
assert self.store.exists(key)
ttl = self.store.ttl(key)
assert ttl is None or ttl > 0
# Cleanup
self.store.delete(key)
def test_delete(self):
"""Test key deletion."""
key = f"test:delete:{uuid.uuid4().hex[:8]}"
self.store.set(key, "to_delete")
result = self.store.delete(key)
assert result is True
assert not self.store.exists(key)
def test_exists(self):
"""Test key existence check."""
key = f"test:exists:{uuid.uuid4().hex[:8]}"
assert not self.store.exists(key)
self.store.set(key, "value")
assert self.store.exists(key)
# Cleanup
self.store.delete(key)
def test_hash_operations(self):
"""Test hash operations."""
key = f"test:hash:{uuid.uuid4().hex[:8]}"
self.store.hset(key, "field1", "value1")
self.store.hset(key, "field2", 42)
assert self.store.hget(key, "field1") == "value1"
all_fields = self.store.hgetall(key)
assert "field1" in all_fields
assert "field2" in all_fields
# Cleanup
self.store.delete(key)
# =============================================================================
# Concrete Test Classes
# =============================================================================
def run_test(test_func, name):
"""Run a single test and report result."""
try:
test_func()
print(f" ✅ {name}")
return True
except Exception as e:
print(f" ❌ {name}: {e}")
return False
def test_postgresql_conversation_store():
"""Test PostgreSQL ConversationStore."""
print("\n=== PostgreSQL ConversationStore ===")
try:
from praisonai.persistence.conversation.postgres import PostgresConversationStore
store = PostgresConversationStore(
host='localhost', port=5432, database='praisonai',
user='postgres', password='praison123'
)
except Exception as e:
print(f" ⚠️ SKIPPED: {e}")
return
class TestPostgres(ConversationStoreTestMixin):
pass
test = TestPostgres()
test.store = store
results = []
results.append(run_test(test.test_create_session, "create_session"))
results.append(run_test(test.test_get_session, "get_session"))
results.append(run_test(test.test_update_session, "update_session"))
results.append(run_test(test.test_delete_session, "delete_session"))
results.append(run_test(test.test_add_message, "add_message"))
results.append(run_test(test.test_get_messages, "get_messages"))
results.append(run_test(test.test_list_sessions, "list_sessions"))
store.close()
print(f" Results: {sum(results)}/{len(results)} passed")
def test_sqlite_conversation_store():
"""Test SQLite ConversationStore."""
print("\n=== SQLite ConversationStore ===")
try:
from praisonai.persistence.conversation.sqlite import SQLiteConversationStore
store = SQLiteConversationStore(path="/tmp/test_praison.db")
except Exception as e:
print(f" ⚠️ SKIPPED: {e}")
return
class TestSQLite(ConversationStoreTestMixin):
pass
test = TestSQLite()
test.store = store
results = []
results.append(run_test(test.test_create_session, "create_session"))
results.append(run_test(test.test_get_session, "get_session"))
results.append(run_test(test.test_update_session, "update_session"))
results.append(run_test(test.test_delete_session, "delete_session"))
results.append(run_test(test.test_add_message, "add_message"))
results.append(run_test(test.test_get_messages, "get_messages"))
results.append(run_test(test.test_list_sessions, "list_sessions"))
store.close()
print(f" Results: {sum(results)}/{len(results)} passed")
def test_qdrant_knowledge_store():
"""Test Qdrant KnowledgeStore."""
print("\n=== Qdrant KnowledgeStore ===")
try:
from praisonai.persistence.knowledge.qdrant import QdrantKnowledgeStore
store = QdrantKnowledgeStore(host='localhost', port=6333)
except Exception as e:
print(f" ⚠️ SKIPPED: {e}")
return
class TestQdrant(KnowledgeStoreTestMixin):
pass
test = TestQdrant()
test.store = store
test.collection_name = f"test_{uuid.uuid4().hex[:8]}"
results = []
results.append(run_test(test.test_create_collection, "create_collection"))
results.append(run_test(test.test_insert_documents, "insert_documents"))
results.append(run_test(test.test_search_documents, "search_documents"))
results.append(run_test(test.test_delete_documents, "delete_documents"))
store.close()
print(f" Results: {sum(results)}/{len(results)} passed")
def test_chroma_knowledge_store():
"""Test ChromaDB KnowledgeStore."""
print("\n=== ChromaDB KnowledgeStore ===")
try:
from praisonai.persistence.knowledge.chroma import ChromaKnowledgeStore
store = ChromaKnowledgeStore(path="/tmp/test_chroma")
except Exception as e:
print(f" ⚠️ SKIPPED: {e}")
return
class TestChroma(KnowledgeStoreTestMixin):
pass
test = TestChroma()
test.store = store
test.collection_name = f"test_{uuid.uuid4().hex[:8]}"
results = []
results.append(run_test(test.test_create_collection, "create_collection"))
results.append(run_test(test.test_insert_documents, "insert_documents"))
results.append(run_test(test.test_search_documents, "search_documents"))
results.append(run_test(test.test_delete_documents, "delete_documents"))
store.close()
print(f" Results: {sum(results)}/{len(results)} passed")
def test_redis_state_store():
"""Test Redis StateStore."""
print("\n=== Redis StateStore ===")
try:
from praisonai.persistence.state.redis import RedisStateStore
store = RedisStateStore(host='localhost', port=6379)
except Exception as e:
print(f" ⚠️ SKIPPED: {e}")
return
class TestRedis(StateStoreTestMixin):
pass
test = TestRedis()
test.store = store
results = []
results.append(run_test(test.test_set_get, "set_get"))
results.append(run_test(test.test_set_with_ttl, "set_with_ttl"))
results.append(run_test(test.test_delete, "delete"))
results.append(run_test(test.test_exists, "exists"))
results.append(run_test(test.test_hash_operations, "hash_operations"))
store.close()
print(f" Results: {sum(results)}/{len(results)} passed")
def test_memory_state_store():
"""Test Memory StateStore (zero-dependency)."""
print("\n=== Memory StateStore ===")
try:
from praisonai.persistence.state.memory import MemoryStateStore
store = MemoryStateStore(path="/tmp/test_state.json")
except Exception as e:
print(f" ⚠️ SKIPPED: {e}")
return
class TestMemory(StateStoreTestMixin):
pass
test = TestMemory()
test.store = store
results = []
results.append(run_test(test.test_set_get, "set_get"))
results.append(run_test(test.test_set_with_ttl, "set_with_ttl"))
results.append(run_test(test.test_delete, "delete"))
results.append(run_test(test.test_exists, "exists"))
results.append(run_test(test.test_hash_operations, "hash_operations"))
store.close()
print(f" Results: {sum(results)}/{len(results)} passed")
def test_orchestrator_integration():
"""Test PersistenceOrchestrator with all stores."""
print("\n=== PersistenceOrchestrator Integration ===")
try:
from praisonai.persistence.conversation.postgres import PostgresConversationStore
from praisonai.persistence.knowledge.qdrant import QdrantKnowledgeStore
from praisonai.persistence.state.redis import RedisStateStore
conv_store = PostgresConversationStore(
host='localhost', port=5432, database='praisonai',
user='postgres', password='praison123'
)
knowledge_store = QdrantKnowledgeStore(host='localhost', port=6333)
state_store = RedisStateStore(host='localhost', port=6379)
orchestrator = PersistenceOrchestrator(
conversation_store=conv_store,
knowledge_store=knowledge_store,
state_store=state_store,
)
except Exception as e:
print(f" ⚠️ SKIPPED: {e}")
return
results = []
# Test 1: Agent lifecycle
def test_agent_lifecycle():
class MockAgent:
name = "test-agent"
agent = MockAgent()
session_id = f"orch-test-{uuid.uuid4().hex[:8]}"
# Start
history = orchestrator.on_agent_start(agent, session_id=session_id, user_id="test-user")
assert isinstance(history, list)
# Messages
orchestrator.on_message(session_id, "user", "Hello!")
orchestrator.on_message(session_id, "assistant", "Hi there!")
# End
orchestrator.on_agent_end(agent, session_id)
# Verify
messages = orchestrator.get_messages(session_id)
assert len(messages) == 2
# Cleanup
orchestrator.delete_session(session_id)
results.append(run_test(test_agent_lifecycle, "agent_lifecycle"))
# Test 2: State management
def test_state_management():
key = f"orch:state:{uuid.uuid4().hex[:8]}"
orchestrator.set_state(key, {"test": True})
value = orchestrator.get_state(key)
assert value == {"test": True}
orchestrator.delete_state(key)
results.append(run_test(test_state_management, "state_management"))
# Test 3: Session resume
def test_session_resume():
class MockAgent:
name = "resume-agent"
agent = MockAgent()
session_id = f"resume-test-{uuid.uuid4().hex[:8]}"
# First run
orchestrator.on_agent_start(agent, session_id=session_id)
orchestrator.on_message(session_id, "user", "First message")
orchestrator.on_agent_end(agent, session_id)
# Second run - should resume
history = orchestrator.on_agent_start(agent, session_id=session_id, resume=True)
assert len(history) == 1
assert history[0].content == "First message"
# Cleanup
orchestrator.delete_session(session_id)
results.append(run_test(test_session_resume, "session_resume"))
orchestrator.close()
print(f" Results: {sum(results)}/{len(results)} passed")
def run_all_tests():
"""Run all database backend tests."""
print("=" * 60)
print("PraisonAI Persistence Layer - Comprehensive TDD Tests")
print("=" * 60)
# ConversationStore tests
print("\n" + "=" * 40)
print("CONVERSATION STORE BACKENDS")
print("=" * 40)
test_postgresql_conversation_store()
test_sqlite_conversation_store()
# KnowledgeStore tests
print("\n" + "=" * 40)
print("KNOWLEDGE STORE BACKENDS")
print("=" * 40)
test_qdrant_knowledge_store()
test_chroma_knowledge_store()
# StateStore tests
print("\n" + "=" * 40)
print("STATE STORE BACKENDS")
print("=" * 40)
test_redis_state_store()
test_memory_state_store()
# Integration tests
print("\n" + "=" * 40)
print("INTEGRATION TESTS")
print("=" * 40)
test_orchestrator_integration()
print("\n" + "=" * 60)
print("All tests completed!")
print("=" * 60)
if __name__ == "__main__":
run_all_tests()
+1 -1
View File
@@ -1 +1 @@
__version__ = "2.4.0"
__version__ = "2.4.2"
+1 -1
View File
@@ -12,7 +12,7 @@ dependencies = [
"rich>=13.7",
"markdown>=3.5",
"pyparsing>=3.0.0",
"praisonaiagents>=0.2.0",
"praisonaiagents>=0.2.1",
"python-dotenv>=0.19.0",
"instructor>=1.3.3",
"PyYAML>=6.0",
+4 -4
View File
@@ -4197,7 +4197,7 @@ requires-dist = [
{ name = "praisonai-tools", marker = "extra == 'autogen'", specifier = ">=0.0.22" },
{ name = "praisonai-tools", marker = "extra == 'autogen-v4'", specifier = ">=0.0.22" },
{ name = "praisonai-tools", marker = "extra == 'crewai'", specifier = ">=0.0.22" },
{ name = "praisonaiagents", specifier = ">=0.2.0" },
{ name = "praisonaiagents", specifier = ">=0.2.1" },
{ name = "pyautogen", marker = "extra == 'autogen'", specifier = "==0.2.29" },
{ name = "pydantic", marker = "extra == 'chat'", specifier = "<=2.10.1" },
{ name = "pydantic", marker = "extra == 'code'", specifier = "<=2.10.1" },
@@ -4252,7 +4252,7 @@ wheels = [
[[package]]
name = "praisonaiagents"
version = "0.2.0"
version = "0.2.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiohttp" },
@@ -4261,9 +4261,9 @@ dependencies = [
{ name = "pydantic" },
{ name = "rich" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e0/47/2ccdf169d00bef397202a0e2f4c13ba87097ba0f0e6c2feb50390dcca0fc/praisonaiagents-0.2.0.tar.gz", hash = "sha256:e1540a8a2919138293af3a7280bfd8b8c2d1d1f7da4353d8d2d29f8505eefb87", size = 717602, upload-time = "2025-12-24T07:43:33.045Z" }
sdist = { url = "https://files.pythonhosted.org/packages/24/58/16b6bd1bb1c9b3932a5e106f72ba1ca2d23b2dad87b45fb0e912b61ab9f7/praisonaiagents-0.2.1.tar.gz", hash = "sha256:7d392c635359696d22ecd92acf90bd2dd1ac24282211ef651612f3d194ea06e5", size = 730640, upload-time = "2025-12-24T14:57:46.426Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b9/14/b004b734e2247decee973f6cf3a52f892770c555ba74e1ca3ccc5dc14a88/praisonaiagents-0.2.0-py3-none-any.whl", hash = "sha256:7bf36a44af63f48e4965cdd6235dba1068bf51200b76d23739f8a7545a95a5d0", size = 669098, upload-time = "2025-12-24T07:43:31.178Z" },
{ url = "https://files.pythonhosted.org/packages/07/d5/a1776b1ad798dce02a896de62f693cee0f6556ac6a2cf06598bf114041a1/praisonaiagents-0.2.1-py3-none-any.whl", hash = "sha256:cf6394986a9d3e701cca49b3e57f5798fdb9066f689c61633e6bdc8421fa995b", size = 678483, upload-time = "2025-12-24T14:57:44.376Z" },
]
[[package]]