Compare commits

...
8 Commits
Author SHA1 Message Date
Mervin Praison ff3a4f2016 Release v2.5.2 2025-12-26 05:25:08 +00:00
Mervin Praison 0d5444fc23 fix: add missing praisonai.db module to git
- Add praisonai/db/ module that was being ignored by *.db pattern
- This fixes ModuleNotFoundError: No module named 'praisonai.db' in CI tests
2025-12-26 04:54:05 +00:00
Mervin Praison d7d5dab17b fix: add missing db module to git and update .gitignore
- Add praisonaiagents/db/ module that was being ignored by *.db pattern
- Add exception in .gitignore for the db module directory
- This fixes ModuleNotFoundError: No module named 'praisonaiagents.db' in CI
2025-12-26 04:49:31 +00:00
Mervin Praison 05678394e6 fix: remove sys.exit() at module level in test files causing pytest INTERNALERROR
- test_mini_agents_fix.py: Convert module-level sys.exit(1) to pytest.mark.skipif pattern
- test_xai_fix.py: Convert to proper pytest test function with cleanup

These sys.exit() calls at module level caused pytest to crash during test collection
with INTERNALERROR, breaking all CI pipelines.
2025-12-26 04:46:58 +00:00
Mervin Praison 54cddc09b4 Release v2.5.1 2025-12-26 04:23:51 +00:00
Mervin Praison 9cbab01785 Release v2.5.0 2025-12-26 02:20:59 +00:00
Mervin Praison de9d4bd3c6 Release v2.4.4 2025-12-26 01:21:26 +00:00
Mervin Praison aba2265e5e Release v2.4.3 2025-12-26 01:01:30 +00:00
127 changed files with 19207 additions and 364 deletions

No files matched your search

+3 -1
View File
@@ -80,4 +80,6 @@ CopilotKit*
issue_details.json
# MCP Registry tokens
.mcpregistry_*
.mcpregistry_*
*.log
+130 -4
View File
@@ -204,6 +204,12 @@ npm install praisonai
| ↳ Add Custom Knowledge | [Example](examples/python/concepts/knowledge-agents.py) | [📖](https://docs.praison.ai/features/knowledge) |
| ↳ RAG Agents | [Example](examples/python/concepts/rag-agents.py) | [📖](https://docs.praison.ai/features/rag) |
| ↳ Chat with PDF Agents | [Example](examples/python/concepts/chat-with-pdf.py) | [📖](https://docs.praison.ai/features/chat-with-pdf) |
| ↳ Data Readers (PDF, DOCX, etc.) | [CLI](#knowledge-cli) | [📖](https://docs.praison.ai/api/praisonai/knowledge-readers-api) |
| ↳ Vector Store Selection | [CLI](#knowledge-cli) | [📖](https://docs.praison.ai/api/praisonai/knowledge-vector-store-api) |
| ↳ Retrieval Strategies | [CLI](#knowledge-cli) | [📖](https://docs.praison.ai/api/praisonai/knowledge-retrieval-api) |
| ↳ Rerankers | [CLI](#knowledge-cli) | [📖](https://docs.praison.ai/api/praisonai/knowledge-reranker-api) |
| ↳ Index Types (Vector/Keyword/Hybrid) | [CLI](#knowledge-cli) | [📖](https://docs.praison.ai/api/praisonai/knowledge-index-api) |
| ↳ Query Engines (Sub-Question, etc.) | [CLI](#knowledge-cli) | [📖](https://docs.praison.ai/api/praisonai/knowledge-query-engine-api) |
| **🔬 Research & Intelligence** | | |
| ↳ Deep Research Agents | [Example](examples/python/agents/research-agent.py) | [📖](https://docs.praison.ai/agents/deep-research) |
| ↳ Query Rewriter Agent | [Example](#5-query-rewriter-agent) | [📖](https://docs.praison.ai/agents/query-rewriter) |
@@ -261,6 +267,10 @@ npm install praisonai
| ↳ 100+ LLM Support | [Example](examples/python/providers/openai/openai_gpt4_example.py) | [📖](https://docs.praison.ai/models) |
| ↳ Callback Agents | [Example](examples/python/general/advanced-callback-systems.py) | [📖](https://docs.praison.ai/features/callbacks) |
| ↳ Hooks | [Example](#9-hooks) | [📖](https://docs.praison.ai/features/hooks) |
| ↳ Middleware System | [Example](examples/middleware/basic_middleware.py) | [📖](https://docs.praison.ai/features/middleware) |
| ↳ Configurable Model | [Example](examples/middleware/configurable_model.py) | [📖](https://docs.praison.ai/features/configurable-model) |
| ↳ Rate Limiter | [Example](examples/middleware/rate_limiter.py) | [📖](https://docs.praison.ai/features/rate-limiter) |
| ↳ Injected Tool State | [Example](examples/middleware/injected_state.py) | [📖](https://docs.praison.ai/features/injected-state) |
| ↳ Shadow Git Checkpoints | [Example](#10-shadow-git-checkpoints) | [📖](https://docs.praison.ai/features/checkpoints) |
| ↳ Background Tasks | [Example](examples/background/basic_background.py) | [📖](https://docs.praison.ai/features/background-tasks) |
| ↳ Policy Engine | [Example](examples/policy/basic_policy.py) | [📖](https://docs.praison.ai/features/policy-engine) |
@@ -2637,6 +2647,84 @@ PraisonAI provides zero-dependency persistent memory for agents. For detailed ex
---
## 📚 Knowledge & Retrieval (RAG)
PraisonAI provides a complete knowledge stack for building RAG applications with multiple vector stores, retrieval strategies, rerankers, and query modes.
### Knowledge CLI Commands
| Command | Description |
|---------|-------------|
| `praisonai knowledge add <file\|dir\|url>` | Add documents to knowledge base |
| `praisonai knowledge query <question>` | Query knowledge base with RAG |
| `praisonai knowledge list` | List indexed documents |
| `praisonai knowledge clear` | Clear knowledge base |
| `praisonai knowledge stats` | Show knowledge base statistics |
### Knowledge CLI Options
| Option | Values | Description |
|--------|--------|-------------|
| `--vector-store` | `memory`, `chroma`, `pinecone`, `qdrant`, `weaviate` | Vector store backend |
| `--retrieval` | `basic`, `fusion`, `recursive`, `auto_merge` | Retrieval strategy |
| `--reranker` | `simple`, `llm`, `cross_encoder`, `cohere` | Reranking method |
| `--index-type` | `vector`, `keyword`, `hybrid` | Index type |
| `--query-mode` | `default`, `sub_question`, `summarize` | Query mode |
### Knowledge CLI Examples
```bash
# Add documents
praisonai knowledge add ./docs/
praisonai knowledge add https://example.com/page.html
praisonai knowledge add "*.pdf"
# Query with advanced options
praisonai knowledge query "How to authenticate?" --retrieval fusion --reranker llm
# Full advanced query
praisonai knowledge query "authentication flow" \
--vector-store chroma \
--retrieval fusion \
--reranker llm \
--index-type hybrid \
--query-mode sub_question
```
### Knowledge SDK Usage
```python
from praisonaiagents import Agent, Knowledge
# Simple usage with Agent
agent = Agent(
name="Research Assistant",
knowledge=["docs/manual.pdf", "data/faq.txt"],
knowledge_config={
"vector_store": {"provider": "chroma"}
}
)
response = agent.chat("How do I authenticate?")
# Direct Knowledge usage
knowledge = Knowledge()
knowledge.add("document.pdf")
results = knowledge.search("authentication", limit=5)
```
### Knowledge Stack Features Table
| Feature | Description | SDK Docs | CLI Docs |
|---------|-------------|----------|----------|
| **Data Readers** | Load PDF, Markdown, Text, HTML, URLs | [SDK](/docs/sdk/praisonaiagents/knowledge/protocols) | [CLI](/docs/cli/knowledge) |
| **Vector Stores** | ChromaDB, Pinecone, Qdrant, Weaviate, In-Memory | [SDK](/docs/sdk/praisonaiagents/knowledge/protocols) | [CLI](/docs/cli/knowledge) |
| **Retrieval Strategies** | Basic, Fusion (RRF), Recursive, Auto-Merge | [SDK](/docs/sdk/praisonaiagents/knowledge/protocols) | [CLI](/docs/cli/knowledge) |
| **Rerankers** | Simple, LLM, Cross-Encoder, Cohere | [SDK](/docs/sdk/praisonaiagents/knowledge/protocols) | [CLI](/docs/cli/knowledge) |
| **Index Types** | Vector, Keyword (BM25), Hybrid | [SDK](/docs/sdk/praisonaiagents/knowledge/protocols) | [CLI](/docs/cli/knowledge) |
| **Query Engines** | Default, Sub-Question, Summarize | [SDK](/docs/sdk/praisonaiagents/knowledge/protocols) | [CLI](/docs/cli/knowledge) |
---
## 🔬 Advanced Features
### Research & Intelligence
@@ -2751,6 +2839,44 @@ agent.chat("Hello!") # Auto-persists messages, runs, traces
| `praisonai persistence migrate` | Apply schema migrations |
| `praisonai persistence status` | Show schema status |
### Knowledge CLI Commands {#knowledge-cli}
| Command | Description |
|---------|-------------|
| `praisonai knowledge add <source>` | Add file, directory, URL, or glob pattern |
| `praisonai knowledge query "<question>"` | Query knowledge base with RAG |
| `praisonai knowledge list` | List indexed documents |
| `praisonai knowledge clear` | Clear knowledge base |
| `praisonai knowledge stats` | Show knowledge base statistics |
**Knowledge Query Flags:**
| Flag | Values | Default |
|------|--------|---------|
| `--vector-store` | `memory`, `chroma`, `pinecone`, `qdrant`, `weaviate` | `chroma` |
| `--retrieval-strategy` | `basic`, `fusion`, `recursive`, `auto_merge` | `basic` |
| `--reranker` | `none`, `simple`, `llm`, `cross_encoder`, `cohere` | `none` |
| `--index-type` | `vector`, `keyword`, `hybrid` | `vector` |
| `--query-mode` | `default`, `sub_question`, `summarize` | `default` |
| `--workspace` | Path to workspace directory | Current dir |
| `--session` | Session ID for persistence | - |
**Examples:**
```bash
# Add documents
praisonai knowledge add document.pdf
praisonai knowledge add ./docs/
praisonai knowledge add "*.md"
# Query with options
praisonai knowledge query "How to authenticate?" \
--vector-store chroma \
--retrieval-strategy fusion \
--reranker simple \
--query-mode sub_question
```
### Databases Table
| Database | Store Type | Install | Example | Docs |
@@ -2763,11 +2889,11 @@ agent.chat("Hello!") # Auto-persists messages, runs, traces
| 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) |
| Pinecone | Knowledge | `pip install pinecone` | [pinecone_wow.py](examples/vector/pinecone_wow.py) | [docs](https://docs.praison.ai/docs/databases/pinecone) |
| Weaviate | Knowledge | `pip install weaviate-client` | [weaviate_wow.py](examples/vector/weaviate_wow.py) | [docs](https://docs.praison.ai/docs/databases/weaviate) |
| LanceDB | Knowledge | `pip install lancedb` | [lancedb_real_wow.py](examples/vector/lancedb_real_wow.py) | [docs](https://docs.praison.ai/docs/databases/lancedb) |
| Milvus | Knowledge | `pip install "praisonai[tools]"` | - | [docs](https://docs.praison.ai/docs/databases/overview) |
| PGVector | Knowledge | `pip install "praisonai[tools]"` | - | [docs](https://docs.praison.ai/docs/databases/overview) |
| PGVector | Knowledge | `pip install psycopg2-binary` | [pgvector_real_wow.py](examples/vector/pgvector_real_wow.py) | [docs](https://docs.praison.ai/docs/databases/pgvector) |
| Redis Vector | Knowledge | `pip install "praisonai[tools]"` | - | [docs](https://docs.praison.ai/docs/databases/overview) |
| Cassandra | Knowledge | `pip install "praisonai[tools]"` | - | [docs](https://docs.praison.ai/docs/databases/overview) |
| ClickHouse | Knowledge | `pip install "praisonai[tools]"` | - | [docs](https://docs.praison.ai/docs/databases/overview) |
+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.2" \
"praisonai>=2.5.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.2" \
"praisonai>=2.5.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.2" \
"praisonai>=2.5.2" \
"praisonai[ui]" \
"praisonai[crewai]"
+35
View File
@@ -0,0 +1,35 @@
"""
Async Runs Examples
===================
This directory contains examples for the PraisonAI Async Jobs API.
Examples:
---------
1. sdk_submit_poll_result.py - Python SDK example for submit/poll/result
2. cli_submit_poll_result.sh - Bash/curl example for submit/poll/result
3. idempotency.sh - Demonstrates idempotency key usage
4. cancel.sh - Demonstrates job cancellation
5. sse_stream.sh - Demonstrates SSE streaming
Prerequisites:
--------------
1. Set OPENAI_API_KEY environment variable
2. Start the jobs server:
cd /path/to/praisonai-package/src/praisonai
python -m uvicorn praisonai.jobs.server:create_app --port 8005 --factory
3. Run examples:
python sdk_submit_poll_result.py
bash cli_submit_poll_result.sh
bash idempotency.sh
bash cancel.sh
bash sse_stream.sh
Environment Variables:
----------------------
- OPENAI_API_KEY: Required for agent execution
- PRAISONAI_BASE_URL: API server URL (default: http://127.0.0.1:8005)
"""
+54
View File
@@ -0,0 +1,54 @@
#!/bin/bash
# CLI Example: Cancel a Running Job
#
# This example demonstrates how to cancel a running job.
#
# Requirements:
# - Jobs server running on http://127.0.0.1:8005
#
# Usage:
# bash cancel.sh
set -e
API_URL="${PRAISONAI_BASE_URL:-http://127.0.0.1:8005}"
echo "============================================================"
echo "PraisonAI Job Cancellation Example"
echo "============================================================"
# Submit a long-running job
echo ""
echo "1. Submitting a job..."
RESPONSE=$(curl -s -X POST "$API_URL/api/v1/runs" \
-H "Content-Type: application/json" \
-d '{"prompt": "Write a detailed essay about the history of computing."}')
JOB_ID=$(echo "$RESPONSE" | python3 -c "import sys,json; print(json.load(sys.stdin)['job_id'])")
echo " Job ID: $JOB_ID"
# Wait briefly
echo ""
echo "2. Waiting 1 second..."
sleep 1
# Cancel the job
echo ""
echo "3. Cancelling job..."
CANCEL_RESPONSE=$(curl -s -X POST "$API_URL/api/v1/runs/$JOB_ID/cancel")
STATUS=$(echo "$CANCEL_RESPONSE" | python3 -c "import sys,json; print(json.load(sys.stdin)['status'])")
echo " Status after cancel: $STATUS"
# Verify cancellation
echo ""
echo "4. Verification:"
if [ "$STATUS" = "cancelled" ]; then
echo " ✓ SUCCESS: Job was cancelled"
else
echo " ℹ Job status: $STATUS (may have completed before cancel)"
fi
echo ""
echo "============================================================"
echo "Cancellation example completed!"
echo "============================================================"
@@ -0,0 +1,75 @@
#!/bin/bash
# CLI Example: Submit, Poll, and Get Result for Async Jobs
#
# This example demonstrates how to use the PraisonAI CLI to:
# 1. Submit an async job
# 2. Check job status
# 3. Get the result
#
# Requirements:
# - OPENAI_API_KEY environment variable set
# - Jobs server running on http://127.0.0.1:8005
#
# Usage:
# bash cli_submit_poll_result.sh
set -e
API_URL="${PRAISONAI_BASE_URL:-http://127.0.0.1:8005}"
echo "============================================================"
echo "PraisonAI Async Jobs CLI Example"
echo "============================================================"
# Check if server is running
echo ""
echo "Checking server health..."
if ! curl -s "$API_URL/health" > /dev/null 2>&1; then
echo "✗ Server not available at $API_URL"
echo " Start the server with: python -m uvicorn praisonai.jobs.server:create_app --port 8005 --factory"
exit 1
fi
echo "✓ Server is healthy"
# 1. Submit a job
echo ""
echo "1. Submitting job..."
RESPONSE=$(curl -s -X POST "$API_URL/api/v1/runs" \
-H "Content-Type: application/json" \
-d '{"prompt": "What is 2+2? Answer with just the number."}')
JOB_ID=$(echo "$RESPONSE" | python3 -c "import sys,json; print(json.load(sys.stdin)['job_id'])")
echo " Job ID: $JOB_ID"
# 2. Poll for status
echo ""
echo "2. Polling for status..."
while true; do
STATUS_RESPONSE=$(curl -s "$API_URL/api/v1/runs/$JOB_ID")
STATUS=$(echo "$STATUS_RESPONSE" | python3 -c "import sys,json; print(json.load(sys.stdin)['status'])")
PROGRESS=$(echo "$STATUS_RESPONSE" | python3 -c "import sys,json; print(json.load(sys.stdin)['progress']['percentage'])")
echo " Status: $STATUS | Progress: ${PROGRESS}%"
if [ "$STATUS" = "succeeded" ] || [ "$STATUS" = "failed" ] || [ "$STATUS" = "cancelled" ]; then
break
fi
sleep 2
done
# 3. Get result
echo ""
echo "3. Getting result..."
if [ "$STATUS" = "succeeded" ]; then
RESULT=$(curl -s "$API_URL/api/v1/runs/$JOB_ID/result")
echo " Result: $(echo "$RESULT" | python3 -c "import sys,json; print(json.load(sys.stdin)['result'])")"
echo " Duration: $(echo "$RESULT" | python3 -c "import sys,json; print(f\"{json.load(sys.stdin)['duration_seconds']:.2f}s\")")"
else
echo " Job did not succeed: $STATUS"
fi
echo ""
echo "============================================================"
echo "Example completed!"
echo "============================================================"
+57
View File
@@ -0,0 +1,57 @@
#!/bin/bash
# CLI Example: Idempotency Key Usage
#
# This example demonstrates how idempotency keys prevent duplicate job submissions.
# Submitting the same request with the same idempotency key returns the existing job.
#
# Requirements:
# - Jobs server running on http://127.0.0.1:8005
#
# Usage:
# bash idempotency.sh
set -e
API_URL="${PRAISONAI_BASE_URL:-http://127.0.0.1:8005}"
IDEM_KEY="example-idem-key-$(date +%s)"
echo "============================================================"
echo "PraisonAI Idempotency Key Example"
echo "============================================================"
# First submission
echo ""
echo "1. First submission with Idempotency-Key: $IDEM_KEY"
RESPONSE1=$(curl -s -X POST "$API_URL/api/v1/runs" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $IDEM_KEY" \
-d '{"prompt": "What is 1+1?"}')
JOB_ID1=$(echo "$RESPONSE1" | python3 -c "import sys,json; print(json.load(sys.stdin)['job_id'])")
echo " Job ID: $JOB_ID1"
# Second submission with same key
echo ""
echo "2. Second submission with SAME Idempotency-Key: $IDEM_KEY"
RESPONSE2=$(curl -s -X POST "$API_URL/api/v1/runs" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $IDEM_KEY" \
-d '{"prompt": "Different prompt but same key"}')
JOB_ID2=$(echo "$RESPONSE2" | python3 -c "import sys,json; print(json.load(sys.stdin)['job_id'])")
echo " Job ID: $JOB_ID2"
# Verify same job ID
echo ""
echo "3. Verification:"
if [ "$JOB_ID1" = "$JOB_ID2" ]; then
echo " ✓ SUCCESS: Same job ID returned (no duplicate created)"
else
echo " ✗ FAILURE: Different job IDs returned"
exit 1
fi
echo ""
echo "============================================================"
echo "Idempotency example completed!"
echo "============================================================"
@@ -0,0 +1,121 @@
#!/usr/bin/env python3
"""
SDK Example: Submit, Poll, and Get Result for Async Jobs
This example demonstrates how to use the PraisonAI Jobs SDK to:
1. Submit an async job
2. Poll for status
3. Get the result
Requirements:
- OPENAI_API_KEY environment variable set
- Jobs server running on http://127.0.0.1:8005
Usage:
python sdk_submit_poll_result.py
"""
import os
import time
import httpx
# Configuration
API_URL = os.getenv("PRAISONAI_BASE_URL", "http://127.0.0.1:8005")
POLL_INTERVAL = 2 # seconds
def submit_job(prompt: str, idempotency_key: str = None) -> dict:
"""Submit a job and return the response."""
headers = {}
if idempotency_key:
headers["Idempotency-Key"] = idempotency_key
with httpx.Client(timeout=30.0) as client:
response = client.post(
f"{API_URL}/api/v1/runs",
json={"prompt": prompt},
headers=headers if headers else None
)
response.raise_for_status()
return response.json()
def get_status(job_id: str) -> dict:
"""Get job status."""
with httpx.Client(timeout=30.0) as client:
response = client.get(f"{API_URL}/api/v1/runs/{job_id}")
response.raise_for_status()
return response.json()
def get_result(job_id: str) -> dict:
"""Get job result."""
with httpx.Client(timeout=30.0) as client:
response = client.get(f"{API_URL}/api/v1/runs/{job_id}/result")
response.raise_for_status()
return response.json()
def wait_for_completion(job_id: str, max_wait: int = 120) -> dict:
"""Poll until job completes."""
start = time.time()
while time.time() - start < max_wait:
status = get_status(job_id)
print(f" Status: {status['status']} | Progress: {status['progress']['percentage']:.0f}%")
if status["status"] in ("succeeded", "failed", "cancelled"):
return status
# Honor retry_after if present
wait_time = status.get("retry_after") or POLL_INTERVAL
time.sleep(wait_time)
raise TimeoutError(f"Job {job_id} did not complete within {max_wait}s")
def main():
print("=" * 60)
print("PraisonAI Async Jobs SDK Example")
print("=" * 60)
# Check if server is running
try:
with httpx.Client(timeout=5.0) as client:
health = client.get(f"{API_URL}/health")
health.raise_for_status()
print(f"✓ Server healthy: {health.json()['status']}")
except Exception as e:
print(f"✗ Server not available at {API_URL}: {e}")
print(" Start the server with: python -m uvicorn praisonai.jobs.server:create_app --port 8005 --factory")
return
# 1. Submit a job
print("\n1. Submitting job...")
prompt = "What is the capital of France? Answer in one word."
result = submit_job(prompt)
job_id = result["job_id"]
print(f" Job ID: {job_id}")
print(f" Status: {result['status']}")
print(f" Poll URL: {result['poll_url']}")
# 2. Wait for completion
print("\n2. Waiting for completion...")
final_status = wait_for_completion(job_id)
print(f" Final status: {final_status['status']}")
# 3. Get result
if final_status["status"] == "succeeded":
print("\n3. Getting result...")
result = get_result(job_id)
print(f" Result: {result['result']}")
print(f" Duration: {result['duration_seconds']:.2f}s")
else:
print(f"\n3. Job did not succeed: {final_status.get('error', 'Unknown error')}")
print("\n" + "=" * 60)
print("Example completed successfully!")
print("=" * 60)
if __name__ == "__main__":
main()
+51
View File
@@ -0,0 +1,51 @@
#!/bin/bash
# CLI Example: SSE Streaming
#
# This example demonstrates how to stream job progress via Server-Sent Events.
#
# Requirements:
# - Jobs server running on http://127.0.0.1:8005
#
# Usage:
# bash sse_stream.sh
set -e
API_URL="${PRAISONAI_BASE_URL:-http://127.0.0.1:8005}"
echo "============================================================"
echo "PraisonAI SSE Streaming Example"
echo "============================================================"
# Submit a job
echo ""
echo "1. Submitting job..."
RESPONSE=$(curl -s -X POST "$API_URL/api/v1/runs" \
-H "Content-Type: application/json" \
-d '{"prompt": "Count from 1 to 5."}')
JOB_ID=$(echo "$RESPONSE" | python3 -c "import sys,json; print(json.load(sys.stdin)['job_id'])")
echo " Job ID: $JOB_ID"
# Stream progress
echo ""
echo "2. Streaming progress (Ctrl+C to stop)..."
echo " Connecting to: $API_URL/api/v1/runs/$JOB_ID/stream"
echo ""
# Use curl to stream SSE events (timeout after 60s)
curl -s -N --max-time 60 "$API_URL/api/v1/runs/$JOB_ID/stream" | while read -r line; do
if [[ "$line" == data:* ]]; then
data="${line#data: }"
if [ "$data" = "[DONE]" ]; then
echo " Stream completed"
break
fi
echo " Event: $data"
fi
done
echo ""
echo "============================================================"
echo "Streaming example completed!"
echo "============================================================"
+55
View File
@@ -0,0 +1,55 @@
"""
Basic Middleware Example - PraisonAI Agents
Demonstrates before/after hooks and wrap decorators for model and tool calls.
"""
from praisonaiagents import Agent, tool
from praisonaiagents.hooks import (
before_model, after_model, wrap_model_call,
before_tool, after_tool, wrap_tool_call,
InvocationContext, ModelRequest, ModelResponse
)
# Simple tool for demonstration
@tool
def get_weather(city: str) -> str:
"""Get weather for a city."""
return f"Sunny, 22°C in {city}"
# Before model hook - adds context
@before_model
def add_context(request: ModelRequest) -> ModelRequest:
print(f"[before_model] Adding context to request")
return request
# After model hook - logs response
@after_model
def log_response(response: ModelResponse) -> ModelResponse:
print(f"[after_model] Response received")
return response
# Wrap tool call - retry on error
@wrap_tool_call
def retry_on_error(tool_call, call_next):
print(f"[wrap_tool_call] Executing tool")
try:
return call_next(tool_call)
except Exception as e:
print(f"[wrap_tool_call] Retrying after error: {e}")
return call_next(tool_call)
# Create agent with hooks
agent = Agent(
name="WeatherBot",
instructions="You help with weather queries.",
tools=[get_weather],
hooks=[add_context, log_response, retry_on_error]
)
if __name__ == "__main__":
# Test the tool directly
result = get_weather("London")
print(f"Weather: {result}")
print("\n✓ Middleware example complete")
+31
View File
@@ -0,0 +1,31 @@
"""
Configurable Model Example - PraisonAI Agents
Demonstrates runtime model switching without recreating the agent.
"""
from praisonaiagents import Agent
# Create agent with configurable model enabled
agent = Agent(
name="FlexBot",
instructions="You are a helpful assistant.",
llm="gpt-4o-mini",
llm_config={"configurable": True}
)
if __name__ == "__main__":
# Default model call
print("Using default model (gpt-4o-mini)...")
# response = agent.chat("Say hello in 5 words")
# Override model per-call
print("\nOverriding to different model...")
# response = agent.chat("Say hello in 5 words", config={"model": "gpt-4o"})
# Override temperature
print("\nOverriding temperature...")
# response = agent.chat("Say hello creatively", config={"temperature": 0.9})
print("\n✓ Configurable model example complete")
print("Note: Uncomment agent.chat() calls to run with API key")
+50
View File
@@ -0,0 +1,50 @@
"""
Injected State Example - PraisonAI Agents
Demonstrates injecting agent state into tools without exposing it in the schema.
"""
from praisonaiagents import Agent, tool
from praisonaiagents.tools import Injected
from praisonaiagents.tools.injected import AgentState, with_injection_context
# Tool with injected state - state param is NOT in the public schema
@tool
def show_context(query: str, state: Injected[dict]) -> str:
"""Show the current agent context."""
session_id = state.get('session_id', 'unknown')
agent_id = state.get('agent_id', 'unknown')
return f"Query: {query}, Session: {session_id}, Agent: {agent_id}"
# Create agent with the tool
agent = Agent(
name="ContextBot",
instructions="You help show context information.",
tools=[show_context],
session_id="my-session-123"
)
if __name__ == "__main__":
# Verify injected param is not in schema
schema = show_context.get_schema()
params = schema['function']['parameters']['properties']
print(f"Schema params: {list(params.keys())}")
assert 'state' not in params, "state should NOT be in schema"
print("✓ 'state' correctly excluded from schema")
# Test with manual injection context
mock_state = AgentState(
agent_id="test-agent",
run_id="run-1",
session_id="session-abc"
)
with with_injection_context(mock_state):
result = show_context(query="hello")
print(f"Result: {result}")
# Test via agent.execute_tool
result = agent.execute_tool("show_context", {"query": "test"})
print(f"Agent result: {result}")
print("\n✓ Injected state example complete")
+29
View File
@@ -0,0 +1,29 @@
"""
Rate Limiter Example - PraisonAI Agents
Demonstrates token bucket rate limiting for LLM API calls.
"""
from praisonaiagents import Agent
from praisonaiagents.llm import RateLimiter
# Create rate limiter: 60 requests per minute
limiter = RateLimiter(requests_per_minute=60, burst=5)
# Create agent with rate limiter
agent = Agent(
name="RateLimitedBot",
instructions="You are a helpful assistant.",
rate_limiter=limiter
)
if __name__ == "__main__":
print(f"Rate limiter: {limiter}")
print(f"Available tokens: {limiter.available_tokens}")
# Demonstrate rate limiting
for i in range(3):
limiter.acquire()
print(f"Request {i+1} acquired, tokens left: {limiter.available_tokens:.1f}")
print("\n✓ Rate limiter example complete")
@@ -0,0 +1,122 @@
"""
Multi-Agent Persistence Example
Demonstrates how multiple agents can share or isolate conversation
history using the persistence layer.
"""
from praisonai.persistence.factory import create_conversation_store, create_state_store
from praisonai.persistence.conversation.base import ConversationSession, ConversationMessage
import uuid
def main():
# Create stores
conv_store = create_conversation_store("sqlite", path=":memory:")
state_store = create_state_store("memory")
print("=== Multi-Agent Persistence Demo ===\n")
# Scenario 1: Shared session (agents collaborate)
print("--- Scenario 1: Shared Session ---")
shared_session_id = f"shared-{uuid.uuid4().hex[:8]}"
session = ConversationSession(
session_id=shared_session_id,
user_id="user-123",
name="Shared Conversation"
)
conv_store.create_session(session)
# Agent 1 (Researcher) adds context
conv_store.add_message(shared_session_id, ConversationMessage(
role="assistant",
content="[Researcher] I found relevant information about the topic.",
metadata={"agent_id": "researcher", "agent_role": "research"}
))
# Agent 2 (Writer) uses the context
conv_store.add_message(shared_session_id, ConversationMessage(
role="assistant",
content="[Writer] Based on the research, here's a summary...",
metadata={"agent_id": "writer", "agent_role": "writing"}
))
# Agent 3 (Editor) reviews
conv_store.add_message(shared_session_id, ConversationMessage(
role="assistant",
content="[Editor] I've reviewed and polished the content.",
metadata={"agent_id": "editor", "agent_role": "editing"}
))
messages = conv_store.get_messages(shared_session_id)
print(f"Shared session has {len(messages)} messages from different agents")
for msg in messages:
agent = msg.metadata.get("agent_id", "unknown") if msg.metadata else "unknown"
print(f" - {agent}: {msg.content[:50]}...")
# Scenario 2: Isolated sessions (agents work independently)
print("\n--- Scenario 2: Isolated Sessions ---")
agent_sessions = {}
for agent_name in ["planner", "executor", "validator"]:
session_id = f"{agent_name}-{uuid.uuid4().hex[:8]}"
agent_sessions[agent_name] = session_id
conv_store.create_session(ConversationSession(
session_id=session_id,
agent_id=agent_name,
name=f"{agent_name.title()} Session"
))
# Each agent has its own conversation
conv_store.add_message(session_id, ConversationMessage(
role="user",
content=f"Task for {agent_name}"
))
conv_store.add_message(session_id, ConversationMessage(
role="assistant",
content=f"[{agent_name.title()}] Processing task..."
))
print("Created isolated sessions for each agent:")
for agent, sid in agent_sessions.items():
msgs = conv_store.get_messages(sid)
print(f" - {agent}: {len(msgs)} messages")
# Scenario 3: Shared state across agents
print("\n--- Scenario 3: Shared State ---")
# Agents share state for coordination
state_store.set("workflow:status", {"phase": "research", "progress": 0})
state_store.set("workflow:context", {"topic": "AI Agents", "deadline": "2024-12-31"})
# Agent 1 updates progress
state = state_store.get("workflow:status")
state["progress"] = 33
state["phase"] = "writing"
state_store.set("workflow:status", state)
print(f"Agent 1 updated state: {state}")
# Agent 2 reads and updates
state = state_store.get("workflow:status")
state["progress"] = 66
state["phase"] = "editing"
state_store.set("workflow:status", state)
print(f"Agent 2 updated state: {state}")
# Agent 3 completes
state = state_store.get("workflow:status")
state["progress"] = 100
state["phase"] = "complete"
state_store.set("workflow:status", state)
print(f"Agent 3 completed: {state}")
# Cleanup
conv_store.close()
state_store.close()
print("\n✅ Multi-agent persistence demo complete!")
if __name__ == "__main__":
main()
+40
View File
@@ -0,0 +1,40 @@
"""Multi-Agent Shared Session - DB Persistence Test"""
import sys
sys.path.insert(0, 'src/praisonai')
from praisonai.persistence import create_conversation_store
from praisonai.persistence.conversation.base import ConversationSession, ConversationMessage
# Create shared session for multiple agents
store = create_conversation_store("sqlite", path="/tmp/multi_agent.db")
session_id = "shared-session-123"
# Create session
session = ConversationSession(session_id=session_id, agent_id="multi")
try:
store.create_session(session)
except Exception:
pass
# Agent 1 writes
msg1 = ConversationMessage(session_id=session_id, role="user", content="Agent1: Hello")
store.add_message(session_id, msg1)
msg2 = ConversationMessage(session_id=session_id, role="assistant", content="Agent1: Hi there!")
store.add_message(session_id, msg2)
# Agent 2 writes to same session
msg3 = ConversationMessage(session_id=session_id, role="user", content="Agent2: What did Agent1 say?")
store.add_message(session_id, msg3)
msg4 = ConversationMessage(session_id=session_id, role="assistant", content="Agent2: Agent1 said Hello")
store.add_message(session_id, msg4)
# Verify both agents' messages are in session
messages = store.get_messages(session_id)
print(f"Total messages in shared session: {len(messages)}")
assert len(messages) >= 4, "Should have messages from both agents"
# Verify content
contents = [m.content for m in messages]
assert any("Agent1" in c for c in contents), "Should have Agent1 messages"
assert any("Agent2" in c for c in contents), "Should have Agent2 messages"
print("PASSED: Multi-agent shared session persistence")
@@ -0,0 +1,16 @@
"""Multi-Agent Shared Session Demo"""
from praisonaiagents import Agent
from praisonai.persistence import create_conversation_store
store = create_conversation_store("sqlite", path="/tmp/multi_agent.db")
agent1 = Agent(name="Researcher", llm="gpt-4o-mini", db=store, session_id="shared-session")
agent2 = Agent(name="Writer", llm="gpt-4o-mini", db=store, session_id="shared-session")
r1 = agent1.chat("Research topic: AI in healthcare. Key finding: reduces diagnosis time by 50%")
print(f"Researcher: {r1[:50]}...")
r2 = agent2.chat("Based on the research, what was the key finding?")
print(f"Writer: {r2[:50]}...")
assert "50" in r2 or "diagnosis" in r2.lower(), "Writer should see Researcher's context"
print("PASSED: Multi-agent shared session works")
+48
View File
@@ -0,0 +1,48 @@
"""
Basic Observability Usage Example
This example demonstrates how to use the observability module
with PraisonAI agents.
Usage:
# Set your provider's API key first
export LANGFUSE_PUBLIC_KEY=pk-lf-xxx
export LANGFUSE_SECRET_KEY=sk-lf-xxx
python basic_usage.py
"""
from praisonai_tools.observability import obs
from praisonaiagents import Agent
# Initialize observability (auto-detects provider from env vars)
obs.init()
# Or specify a provider explicitly
# obs.init(provider="langfuse", project_name="my-project")
# Create an agent
agent = Agent(
instructions="You are a helpful assistant.",
model="gpt-4o-mini",
)
# Use trace context manager for complete workflow tracing
with obs.trace("chat-session", session_id="user-123"):
# Use span context manager for individual operations
with obs.span("user-query", kind=obs.SpanKind.AGENT):
response = agent.chat("What is the capital of France?")
print(response)
# Log LLM calls manually if needed
obs.log_llm_call(
model="gpt-4o-mini",
input_messages="What is 2+2?",
output="4",
input_tokens=10,
output_tokens=1,
)
# Check diagnostics
print("\nDiagnostics:")
print(obs.doctor())
@@ -0,0 +1,48 @@
"""
Langfuse Integration Example
This example shows how to use Langfuse for LLM observability.
Setup:
1. Sign up at https://langfuse.com/
2. Get your API keys from the project settings
3. Set environment variables:
export LANGFUSE_PUBLIC_KEY=pk-lf-xxx
export LANGFUSE_SECRET_KEY=sk-lf-xxx
4. Install dependencies:
pip install opentelemetry-sdk opentelemetry-exporter-otlp
Usage:
python langfuse_example.py
"""
import os
from praisonai_tools.observability import obs
from praisonaiagents import Agent
# Initialize Langfuse
success = obs.init(
provider="langfuse",
project_name="praisonai-demo",
)
if not success:
print("Failed to initialize Langfuse. Check your API keys.")
print("Required: LANGFUSE_PUBLIC_KEY, LANGFUSE_SECRET_KEY")
exit(1)
print("Langfuse initialized successfully!")
print(f"View traces at: https://cloud.langfuse.com/")
# Create agent
agent = Agent(
instructions="You are a helpful coding assistant.",
model="gpt-4o-mini",
)
# Run with tracing
with obs.trace("coding-session", user_id="developer-1"):
response = agent.chat("Write a Python function to calculate fibonacci numbers")
print(response)
print("\nCheck Langfuse dashboard for traces!")
@@ -0,0 +1,22 @@
"""MLflow Local Observability Demo (no API key needed)"""
import mlflow
import time
mlflow.set_tracking_uri("file:///tmp/mlflow_demo")
mlflow.set_experiment("praisonai-demo")
with mlflow.start_run(run_name="agent-trace"):
mlflow.log_param("agent_name", "Assistant")
mlflow.log_param("model", "gpt-4o-mini")
# Simulate agent work
start = time.time()
time.sleep(0.1)
latency = time.time() - start
mlflow.log_metric("latency_ms", latency * 1000)
mlflow.log_metric("tokens_used", 150)
print(f"Logged run to MLflow: latency={latency*1000:.1f}ms")
print("PASSED: MLflow local observability works")
@@ -0,0 +1,53 @@
"""
Multi-Agent Tracing Example
This example shows how to trace multi-agent workflows with
parent/child span relationships.
Usage:
# Set your provider's API key
export LANGFUSE_PUBLIC_KEY=pk-lf-xxx
export LANGFUSE_SECRET_KEY=sk-lf-xxx
python multi_agent_tracing.py
"""
from praisonai_tools.observability import obs
from praisonai_tools.observability.base import SpanKind
from praisonaiagents import Agent, PraisonAIAgents
# Initialize observability
obs.init()
# Create agents
researcher = Agent(
name="Researcher",
instructions="You research topics and provide detailed information.",
model="gpt-4o-mini",
)
writer = Agent(
name="Writer",
instructions="You write clear, engaging content based on research.",
model="gpt-4o-mini",
)
# Trace the multi-agent workflow
with obs.trace("content-creation", metadata={"workflow": "research-write"}):
# Research phase
with obs.span("research-phase", kind=SpanKind.AGENT) as research_span:
research_span.attributes["agent"] = "Researcher"
research_result = researcher.chat("Research the benefits of AI in healthcare")
# Writing phase
with obs.span("writing-phase", kind=SpanKind.AGENT) as write_span:
write_span.attributes["agent"] = "Writer"
write_span.attributes["input_length"] = len(str(research_result))
final_content = writer.chat(f"Write a blog post based on: {research_result}")
print("Final Content:")
print(final_content)
# Check trace info
print("\nDiagnostics:", obs.doctor())
+44 -52
View File
@@ -1,7 +1,7 @@
"""
Qdrant Knowledge Store Example for PraisonAI.
Qdrant Knowledge Store - Agent-First Example
Demonstrates vector insert + search through the knowledge store.
Demonstrates using Qdrant for knowledge-based RAG with an Agent.
Requirements:
pip install "praisonai[tools]"
@@ -13,67 +13,59 @@ Run:
python knowledge_qdrant.py
Expected Output:
=== Qdrant Knowledge Store Demo ===
Created collection: demo_knowledge
Inserted 3 documents
Search results: 2 documents found
Agent responds using knowledge from documents
"""
from praisonai.persistence.factory import create_knowledge_store
from praisonai.persistence.knowledge.base import KnowledgeDocument
import random
from praisonaiagents import Agent
import tempfile
import os
import shutil
# Create Qdrant knowledge store
store = create_knowledge_store("qdrant", url="http://localhost:6333")
# Create sample knowledge document
sample_doc = """
# AI and Programming Guide
collection_name = "demo_knowledge"
dimension = 384 # Embedding dimension
## Python Programming
Python is a versatile programming language used for web development, data science, and AI.
It has a simple syntax and extensive libraries.
# Create collection
print("=== Qdrant Knowledge Store Demo ===")
if store.collection_exists(collection_name):
store.delete_collection(collection_name)
## Machine Learning
Machine learning enables computers to learn patterns from data without explicit programming.
Common frameworks include TensorFlow, PyTorch, and scikit-learn.
store.create_collection(collection_name, dimension=dimension)
print(f"Created collection: {collection_name}")
## Databases
PostgreSQL is a powerful open-source relational database system.
It supports both SQL and JSON data types.
"""
# 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"}
),
]
# Save to temp file
temp_dir = tempfile.mkdtemp()
doc_path = os.path.join(temp_dir, "guide.txt")
with open(doc_path, "w") as f:
f.write(sample_doc)
# Insert documents
ids = store.insert(collection_name, documents)
print(f"Inserted {len(ids)} documents")
print("=== Qdrant Knowledge Store Demo (Agent-First) ===")
# 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")
# Agent-first approach: use knowledge parameter with Qdrant
agent = Agent(
name="KnowledgeAssistant",
instructions="You are a helpful assistant with access to technical documentation.",
knowledge=[doc_path],
knowledge_config={
"vector_store": "qdrant",
"url": "http://localhost:6333"
}
)
for doc in results:
print(f" - {doc.id}: {doc.content[:50]}...")
# Chat - agent uses knowledge for RAG
response = agent.chat("What programming language is good for AI?")
print(f"Response: {response}")
# Cleanup
store.delete_collection(collection_name)
store.close()
shutil.rmtree(temp_dir)
print("\n=== Demo Complete ===")
# --- Advanced: Direct Store Usage ---
# from praisonai.persistence.factory import create_knowledge_store
# store = create_knowledge_store("qdrant", url="http://localhost:6333")
+28 -65
View File
@@ -1,83 +1,46 @@
"""
Redis State Store
Redis State Store - Agent-First Example
Use Redis for state management, tracing, and caching.
Use Redis for state management with an Agent.
Docker Setup:
docker run -d --name redis -p 6379:6379 redis:7
Run:
python redis_state.py
Expected output:
- State operations (set/get/delete)
- Tracing operations
Agent responds with state persisted to Redis
"""
from praisonaiagents import db
from praisonaiagents import Agent, db
# Create Redis-only database (for state/tracing)
redis_db = db.RedisDB(host="localhost", port=6379)
print("=== Redis State Store (Agent-First) ===")
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"}
# Agent-first approach: use db parameter with Redis state store
my_db = db(
database_url="sqlite:///conversations.db", # Conversations
state_url="redis://localhost:6379" # State/tracing
)
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}
agent = Agent(
name="Assistant",
instructions="You are a helpful assistant.",
db=my_db,
session_id="redis-state-session"
)
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}")
# Chat - state is automatically managed via Redis
response = agent.chat("Hello! Remember my name is Alice.")
print(f"Response: {response}")
# End trace
redis_db.on_trace_end(
trace_id=trace_id,
status="ok",
metadata={"total_spans": 1}
)
print(f" Ended trace: {trace_id}")
# Second message to test state
response2 = agent.chat("What is my name?")
print(f"Response: {response2}")
redis_db.close()
my_db.close()
print("\n✅ Done")
# --- Advanced: Direct Store Usage ---
# from praisonaiagents import db
# redis_db = db.RedisDB(host="localhost", port=6379)
+6 -11
View File
@@ -26,23 +26,18 @@ Expected Output:
Messages persisted: 2
"""
from praisonaiagents import Agent
from praisonai.db import PostgresDB
from praisonaiagents import Agent, db
# Create database adapter
db = PostgresDB(
host="localhost",
port=5432,
database="praisonai",
user="postgres",
password="praison123"
# Create database adapter (simplified import)
db_instance = db(
database_url="postgresql://postgres:praison123@localhost:5432/praisonai"
)
# Create agent with persistence enabled
agent = Agent(
name="Assistant",
instructions="You are a helpful assistant. Keep responses brief.",
db=db,
db=db_instance,
session_id="demo-session-001", # Same session_id = resume conversation
verbose=False
)
@@ -57,7 +52,7 @@ print(f"Session ID: {agent.session_id}")
print(f"Messages in history: {len(agent.chat_history)}")
# Close DB connection
db.close()
db_instance.close()
print("\n=== Session persisted! ===")
print("Run again with same session_id to resume.")
+27 -48
View File
@@ -1,10 +1,7 @@
"""
Redis State Store Example for PraisonAI.
Redis State Store - Agent-First Example
Demonstrates set/get/delete + TTL operations.
Requirements:
pip install "praisonai[tools]"
Use Redis for state management with an Agent.
Docker Setup:
docker run -d --name praison-redis -p 6379:6379 redis:7
@@ -13,55 +10,37 @@ 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'}
Agent responds with state persisted to Redis
"""
from praisonai.persistence.factory import create_state_store
from praisonaiagents import Agent, db
# Create Redis state store
store = create_state_store("redis", url="redis://localhost:6379")
print("=== Redis State Store (Agent-First) ===")
print("=== Redis State Store Demo ===")
# Agent-first approach: use db parameter with Redis state store
my_db = db(
database_url="sqlite:///conversations.db", # Conversations
state_url="redis://localhost:6379" # State/tracing
)
# Basic set/get
key = "session:user123:preferences"
value = {"theme": "dark", "language": "en", "notifications": True}
agent = Agent(
name="Assistant",
instructions="You are a helpful assistant.",
db=my_db,
session_id="redis-state-example"
)
store.set(key, value)
print(f"Set key: {key}")
# Chat - state is automatically managed via Redis
response = agent.chat("Hello! Remember my favorite color is blue.")
print(f"Response: {response}")
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()
# Second message to test state
response2 = agent.chat("What is my favorite color?")
print(f"Response: {response2}")
my_db.close()
print("\n=== Demo Complete ===")
# --- Advanced: Direct Store Usage ---
# from praisonai.persistence.factory import create_state_store
# store = create_state_store("redis", url="redis://localhost:6379")
@@ -15,6 +15,7 @@ Features demonstrated:
from praisonaiagents import Agent
import tempfile
import os
import shutil
# Create a sample knowledge document for demonstration
sample_document = """
@@ -91,38 +92,15 @@ doc_path = os.path.join(temp_dir, "ai_healthcare_guide.txt")
with open(doc_path, "w") as f:
f.write(sample_document)
# Create an agent with advanced knowledge management configuration
# Create an agent with knowledge management
knowledge_agent = Agent(
name="AdvancedKnowledgeAgent",
role="Healthcare AI Knowledge Expert",
goal="Provide comprehensive answers about AI in healthcare using advanced knowledge retrieval",
backstory="You are an expert in healthcare AI with access to comprehensive knowledge bases and advanced retrieval capabilities.",
# Advanced knowledge configuration
# Knowledge configuration - simple and working
knowledge=[doc_path],
knowledge_config={
"vector_store": {
"provider": "chroma",
"collection_name": "healthcare_ai_knowledge"
},
"chunking": {
"strategy": "semantic", # Advanced semantic chunking
"chunk_size": 500, # Optimal chunk size for retrieval
"chunk_overlap": 50, # Overlap for context preservation
"separators": ["\n\n", "\n", ".", "!", "?"] # Smart separators
},
"retrieval": {
"search_type": "similarity",
"k": 5, # Retrieve top 5 most relevant chunks
"score_threshold": 0.7, # Minimum relevance score
"rerank": True # Enable reranking for better results
},
"embedding": {
"provider": "openai",
"model": "text-embedding-3-small", # Efficient embedding model
"dimensions": 1536
}
},
instructions="""You are an expert in healthcare AI. Use the knowledge base to provide
comprehensive, accurate answers. Always cite specific information from the documents
@@ -180,7 +158,6 @@ relationship_result = knowledge_agent.start(
print(f"Relationship analysis result:\n{relationship_result}\n")
# Clean up temporary files
import shutil
shutil.rmtree(temp_dir)
print("="*80)
+69
View File
@@ -0,0 +1,69 @@
#!/usr/bin/env python3
"""Run all examples and collect PASS/SKIP/FAIL results."""
import subprocess
import sys
import os
from pathlib import Path
EXAMPLES_DIR = Path(__file__).parent
RESULTS = []
def run_example(path: Path) -> tuple:
"""Run an example and return (name, status, reason)."""
name = f"{path.parent.name}/{path.name}"
try:
result = subprocess.run(
[sys.executable, str(path)],
capture_output=True,
text=True,
timeout=60,
env={**os.environ, "PYTHONPATH": str(EXAMPLES_DIR.parent / "src")}
)
output = result.stdout + result.stderr
if "PASSED" in output:
return (name, "PASS", "")
elif "SKIPPED" in output:
return (name, "SKIP", output.split("SKIPPED:")[-1].strip()[:50])
elif result.returncode != 0:
error = output.strip().split("\n")[-1][:80]
return (name, "FAIL", error)
else:
return (name, "PASS", "")
except subprocess.TimeoutExpired:
return (name, "FAIL", "Timeout")
except Exception as e:
return (name, "FAIL", str(e)[:50])
def main():
print("=" * 70)
print("EXAMPLE RUNNER - PASS/SKIP/FAIL SUMMARY")
print("=" * 70)
# Find all example files
examples = []
for subdir in ["db", "vector", "observability", "multi_agent", "workflows"]:
subpath = EXAMPLES_DIR / subdir
if subpath.exists():
examples.extend(sorted(subpath.glob("*_wow.py")))
print(f"\nFound {len(examples)} examples to run\n")
for ex in examples:
name, status, reason = run_example(ex)
RESULTS.append((name, status, reason))
icon = {"PASS": "✅", "SKIP": "⏭️", "FAIL": "❌"}.get(status, "?")
reason_str = f" ({reason})" if reason else ""
print(f"{icon} {status:4} | {name:45} {reason_str}")
# Summary
print("\n" + "=" * 70)
passed = sum(1 for _, s, _ in RESULTS if s == "PASS")
skipped = sum(1 for _, s, _ in RESULTS if s == "SKIP")
failed = sum(1 for _, s, _ in RESULTS if s == "FAIL")
print(f"SUMMARY: {passed} PASS | {skipped} SKIP | {failed} FAIL | {len(RESULTS)} TOTAL")
print("=" * 70)
return 0 if failed == 0 else 1
if __name__ == "__main__":
sys.exit(main())
+23
View File
@@ -0,0 +1,23 @@
"""ChromaDB Vector Store - Agent-First Example"""
from praisonaiagents import Agent
# Agent-first approach: use knowledge parameter with ChromaDB
agent = Agent(
name="Assistant",
instructions="You are a helpful assistant with access to documents.",
knowledge=["./docs/guide.pdf"], # Add your documents here
knowledge_config={
"vector_store": "chroma",
"path": "/tmp/chroma_demo"
}
)
# Chat - agent uses knowledge for RAG
response = agent.chat("What information do you have?")
print(f"Response: {response}")
print("PASSED: ChromaDB with Agent")
# --- Advanced: Direct Store Usage ---
# from praisonai.persistence import create_knowledge_store
# store = create_knowledge_store("chroma", path="/tmp/chroma_demo")
+25
View File
@@ -0,0 +1,25 @@
"""LanceDB Vector Store - Agent-First Example"""
from praisonaiagents import Agent
# Agent-first approach: use knowledge parameter with LanceDB
agent = Agent(
name="Assistant",
instructions="You are a helpful assistant with access to documents.",
knowledge=["./docs/guide.pdf"], # Add your documents here
knowledge_config={
"vector_store": "lancedb",
"path": "/tmp/lancedb_test"
}
)
# Chat - agent uses knowledge for RAG
response = agent.chat("What information do you have?")
print(f"Response: {response}")
print("PASSED: LanceDB with Agent")
# --- Advanced: Direct LanceDB Usage ---
# import lancedb
# db = lancedb.connect("/tmp/lancedb_test")
# data = [{"id": "1", "text": "ML is AI", "vector": [0.1] * 128}]
# table = db.create_table("demo", data)
+23
View File
@@ -0,0 +1,23 @@
"""LanceDB Vector Store - Agent-First Example"""
from praisonaiagents import Agent
# Agent-first approach: use knowledge parameter with LanceDB
agent = Agent(
name="Assistant",
instructions="You are a helpful assistant with access to documents.",
knowledge=["./docs/guide.pdf"], # Add your documents here
knowledge_config={
"vector_store": "lancedb",
"path": "/tmp/lancedb_demo"
}
)
# Chat - agent uses knowledge for RAG
response = agent.chat("What information do you have?")
print(f"Response: {response}")
print("PASSED: LanceDB with Agent")
# --- Advanced: Direct Store Usage ---
# from praisonai.persistence import create_knowledge_store
# store = create_knowledge_store("lancedb", path="/tmp/lancedb_demo")
+31
View File
@@ -0,0 +1,31 @@
"""PGVector Vector Store - Agent-First Example
Docker: docker run -d --name pgvector -e POSTGRES_PASSWORD=postgres -p 5433:5432 pgvector/pgvector:pg16
"""
import os
from praisonaiagents import Agent
url = os.getenv("PGVECTOR_URL", "postgresql://postgres:postgres@localhost:5433/postgres")
# Agent-first approach: use knowledge parameter with PGVector
agent = Agent(
name="Assistant",
instructions="You are a helpful assistant with access to documents.",
knowledge=["./docs/guide.pdf"], # Add your documents here
knowledge_config={
"vector_store": "pgvector",
"url": url
}
)
# Chat - agent uses knowledge for RAG
response = agent.chat("What information do you have?")
print(f"Response: {response}")
print("PASSED: PGVector with Agent")
# --- Advanced: Direct PGVector Usage ---
# import psycopg2
# conn = psycopg2.connect(url)
# cur = conn.cursor()
# cur.execute("CREATE EXTENSION IF NOT EXISTS vector")
+29
View File
@@ -0,0 +1,29 @@
"""PGVector Knowledge Store - Agent-First Example (requires Docker)
Docker: docker run -d --name pgvector -e POSTGRES_PASSWORD=postgres -p 5433:5432 pgvector/pgvector:pg16
"""
import os
from praisonaiagents import Agent
url = os.getenv("PGVECTOR_URL", "postgresql://postgres:postgres@localhost:5433/postgres")
# Agent-first approach: use knowledge parameter with PGVector
agent = Agent(
name="Assistant",
instructions="You are a helpful assistant with access to documents.",
knowledge=["./docs/guide.pdf"], # Add your documents here
knowledge_config={
"vector_store": "pgvector",
"url": url
}
)
# Chat - agent uses knowledge for RAG
response = agent.chat("What information do you have?")
print(f"Response: {response}")
print("PASSED: PGVector with Agent")
# --- Advanced: Direct Store Usage ---
# from praisonai.persistence import create_knowledge_store
# store = create_knowledge_store("pgvector", url=url)
+34
View File
@@ -0,0 +1,34 @@
"""Pinecone Vector Store - Agent-First Example
Requires: export PINECONE_API_KEY=...
"""
import os
import sys
from praisonaiagents import Agent
api_key = os.getenv("PINECONE_API_KEY")
if not api_key:
print("SKIPPED: Pinecone - PINECONE_API_KEY not set")
sys.exit(0)
# Agent-first approach: use knowledge parameter with Pinecone
agent = Agent(
name="Assistant",
instructions="You are a helpful assistant with access to documents.",
knowledge=["./docs/guide.pdf"], # Add your documents here
knowledge_config={
"vector_store": "pinecone",
"api_key": api_key,
"environment": "us-east-1"
}
)
# Chat - agent uses knowledge for RAG
response = agent.chat("What information do you have?")
print(f"Response: {response}")
print("PASSED: Pinecone with Agent")
# --- Advanced: Direct Store Usage ---
# from pinecone import Pinecone
# pc = Pinecone(api_key=api_key)
+29
View File
@@ -0,0 +1,29 @@
"""Qdrant Vector Store - Agent-First Example (requires Docker)
Docker: docker run -d --name qdrant -p 6333:6333 qdrant/qdrant
"""
import os
from praisonaiagents import Agent
# Agent-first approach: use knowledge parameter with Qdrant
url = os.getenv("QDRANT_URL", "http://localhost:6333")
agent = Agent(
name="Assistant",
instructions="You are a helpful assistant with access to documents.",
knowledge=["./docs/guide.pdf"], # Add your documents here
knowledge_config={
"vector_store": "qdrant",
"url": url
}
)
# Chat - agent uses knowledge for RAG
response = agent.chat("What information do you have?")
print(f"Response: {response}")
print("PASSED: Qdrant with Agent")
# --- Advanced: Direct Store Usage ---
# from praisonai.persistence import create_knowledge_store
# store = create_knowledge_store("qdrant", url=url)
+36
View File
@@ -0,0 +1,36 @@
"""Weaviate Cloud Vector Store - Agent-First Example
Requires: export WEAVIATE_URL=... and WEAVIATE_API_KEY=...
"""
import os
import sys
from praisonaiagents import Agent
weaviate_url = os.getenv("WEAVIATE_URL", "https://your-cluster.weaviate.cloud")
weaviate_key = os.getenv("WEAVIATE_API_KEY")
if not weaviate_key:
print("SKIPPED: Weaviate - WEAVIATE_API_KEY not set")
sys.exit(0)
# Agent-first approach: use knowledge parameter with Weaviate
agent = Agent(
name="Assistant",
instructions="You are a helpful assistant with access to documents.",
knowledge=["./docs/guide.pdf"], # Add your documents here
knowledge_config={
"vector_store": "weaviate",
"url": weaviate_url,
"api_key": weaviate_key
}
)
# Chat - agent uses knowledge for RAG
response = agent.chat("What information do you have?")
print(f"Response: {response}")
print("PASSED: Weaviate with Agent")
# --- Advanced: Direct Store Usage ---
# import weaviate
# from weaviate.classes.init import Auth
# client = weaviate.connect_to_weaviate_cloud(cluster_url=weaviate_url, auth_credentials=Auth.api_key(weaviate_key))
+2
View File
@@ -62,6 +62,8 @@ docs/generated/
*.log
*.sqlite
*.db
# But keep the db module directory
!praisonaiagents/db/
# OS generated files
.DS_Store
@@ -43,8 +43,7 @@ from .tools.base import BaseTool, ToolResult, ToolValidationError, validate_tool
from .tools.decorator import tool, FunctionTool
from .tools.registry import get_registry, register_tool, get_tool, ToolRegistry
from .agents.autoagents import AutoAgents
from .knowledge.knowledge import Knowledge
from .knowledge.chunking import Chunking
# Knowledge is lazy-loaded to avoid importing heavy deps (chromadb, mem0) at startup
# MCP support (optional)
try:
from .mcp.mcp import MCP
@@ -53,8 +52,9 @@ except ImportError:
_mcp_available = False
MCP = None
from .session import Session
from .memory.memory import Memory
# Memory is lazy-loaded to avoid importing chromadb at startup
from .db import db
from .obs import obs
# Workflows - import from dedicated workflows module
from .workflows import (
Workflow, WorkflowStep, WorkflowContext, StepResult,
@@ -66,8 +66,16 @@ from .guardrails import GuardrailResult, LLMGuardrail
# Fast Context support (lazy loaded to avoid performance impact)
def __getattr__(name):
"""Lazy load FastContext to avoid impacting package load time."""
if name == "FastContext":
"""Lazy load heavy modules to avoid impacting package load time."""
# Knowledge module (imports chromadb, mem0)
if name == "Knowledge":
from praisonaiagents.knowledge.knowledge import Knowledge
return Knowledge
elif name == "Chunking":
from praisonaiagents.knowledge.chunking import Chunking
return Chunking
# FastContext support
elif name == "FastContext":
from praisonaiagents.context.fast import FastContext
return FastContext
elif name == "FastContextResult":
@@ -92,6 +100,10 @@ def __getattr__(name):
elif name == "SkillLoader":
from praisonaiagents.skills import SkillLoader
return SkillLoader
# Memory module (imports chromadb)
elif name == "Memory":
from praisonaiagents.memory.memory import Memory
return Memory
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
# Planning mode support
@@ -259,6 +271,7 @@ __all__ = [
'Session',
'Memory',
'db',
'obs',
'display_interaction',
'display_self_reflection',
'display_instruction',
@@ -254,7 +254,10 @@ class Agent:
skills: Optional[List[str]] = None,
skills_dirs: Optional[List[str]] = None,
db: Optional[Any] = None,
session_id: Optional[str] = None
session_id: Optional[str] = None,
hooks: Optional[List[Any]] = None,
llm_config: Optional[Dict[str, Any]] = None,
rate_limiter: Optional[Any] = None
):
"""Initialize an Agent instance.
@@ -395,6 +398,17 @@ class Agent:
# Flag to track if final result has been displayed to prevent duplicates
self._final_display_shown = False
# Store hooks for middleware system (zero overhead when empty)
self._hooks = hooks or []
self._middleware_manager = None # Lazy init
# Store llm_config for configurable model switching
self._llm_config = llm_config or {}
self._llm_configurable = self._llm_config.get('configurable', False)
# Store rate limiter (optional, zero overhead when None)
self._rate_limiter = rate_limiter
# Store OpenAI client parameters for lazy initialization
self._openai_api_key = api_key
self._openai_base_url = base_url
@@ -1622,8 +1636,32 @@ Your Goal: {self.goal}"""
def execute_tool(self, function_name, arguments):
"""
Execute a tool dynamically based on the function name and arguments.
Injects agent state for tools with Injected[T] parameters.
"""
logging.debug(f"{self.name} executing tool {function_name} with arguments: {arguments}")
# Set up injection context for tools with Injected parameters
from ..tools.injected import AgentState, with_injection_context
state = AgentState(
agent_id=self.name,
run_id=getattr(self, '_current_run_id', 'unknown'),
session_id=getattr(self, '_session_id', None) or 'default',
last_user_message=self.chat_history[-1].get('content') if self.chat_history else None,
metadata={'agent_name': self.name}
)
# Execute within injection context
return self._execute_tool_with_context(function_name, arguments, state)
def _execute_tool_with_context(self, function_name, arguments, state):
"""Execute tool within injection context."""
from ..tools.injected import with_injection_context
with with_injection_context(state):
return self._execute_tool_impl(function_name, arguments)
def _execute_tool_impl(self, function_name, arguments):
"""Internal tool execution implementation."""
# Check if approval is required for this tool
from ..approval import is_approval_required, console_approval_callback, get_risk_level, mark_approved, ApprovalDecision, get_approval_callback
@@ -2058,7 +2096,11 @@ Your Goal: {self.goal}"""
"""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):
def chat(self, prompt, temperature=1.0, tools=None, output_json=None, output_pydantic=None, reasoning_steps=False, stream=None, task_name=None, task_description=None, task_id=None, config=None):
# Apply rate limiter if configured (before any LLM call)
if self._rate_limiter is not None:
self._rate_limiter.acquire()
# Initialize DB session on first chat (lazy)
self._init_db_session()
@@ -0,0 +1,85 @@
"""
Database adapter interface for PraisonAI Agents.
This module provides the protocol and types for database persistence.
Implementations are provided by the wrapper layer (praisonai.db).
Usage (simplest - recommended):
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
)
agent.chat("Hello!") # auto-persists messages, runs, traces, tool calls
Alternative (explicit backend):
db=db.PraisonDB(database_url="...") # Auto-detect backend
db=db.PostgresDB(host="localhost") # PostgreSQL
db=db.SQLiteDB(path="data.db") # SQLite
db=db.RedisDB(host="localhost") # Redis (state only)
"""
from .protocol import (
DbAdapter,
AsyncDbAdapter,
DbMessage,
DbToolCall,
DbRun,
DbSpan,
DbTrace,
)
class _LazyDbModule:
"""
Lazy proxy for database backends.
Allows `from praisonaiagents import db` without importing heavy deps.
Actual backend classes are loaded only when accessed.
"""
_BACKENDS = {
"PraisonDB": "praisonai.db.adapter",
"PostgresDB": "praisonai.db.adapter",
"SQLiteDB": "praisonai.db.adapter",
"RedisDB": "praisonai.db.adapter",
}
def __getattr__(self, name: str):
if name in self._BACKENDS:
try:
import importlib
module = importlib.import_module(self._BACKENDS[name])
return getattr(module, name)
except ImportError as e:
raise ImportError(
f"Database backend '{name}' requires the praisonai package. "
f"Install with: pip install praisonai\n"
f"Original error: {e}"
) from e
raise AttributeError(f"module 'db' has no attribute {name!r}")
def __repr__(self):
return "<module 'praisonaiagents.db' (lazy database backends)>"
def __call__(self, **kwargs):
"""Shortcut: db(...) is equivalent to db.PraisonDB(...)"""
return self.PraisonDB(**kwargs)
# Singleton lazy module instance
db = _LazyDbModule()
__all__ = [
"DbAdapter",
"AsyncDbAdapter",
"DbMessage",
"DbToolCall",
"DbRun",
"DbSpan",
"DbTrace",
"db",
]
@@ -0,0 +1,464 @@
"""
Database adapter protocol for PraisonAI Agents.
This module uses only stdlib (typing, dataclasses) to define the interface.
No external dependencies are imported here.
Implementations are provided by the wrapper layer (praisonai.db).
"""
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Protocol, runtime_checkable
import time
@dataclass
class DbToolCall:
"""A tool call to be persisted."""
tool_name: str
args: Dict[str, Any]
result: Any = None
call_id: Optional[str] = None
timestamp: float = field(default_factory=time.time)
duration_ms: Optional[float] = None
error: Optional[str] = None
@dataclass
class DbMessage:
"""A message to be persisted."""
role: str # "user", "assistant", "system", "tool"
content: str
metadata: Dict[str, Any] = field(default_factory=dict)
timestamp: float = field(default_factory=time.time)
id: Optional[str] = None
tool_calls: Optional[List["DbToolCall"]] = None # For assistant messages with tool calls
run_id: Optional[str] = None # Group messages by run
@dataclass
class DbRun:
"""A single agent run (turn) to be persisted."""
run_id: str
session_id: str
agent_name: str
started_at: float = field(default_factory=time.time)
ended_at: Optional[float] = None
status: str = "running" # running, completed, error, cancelled
input_content: Optional[str] = None
output_content: Optional[str] = None
messages: List[DbMessage] = field(default_factory=list)
tool_calls: List[DbToolCall] = field(default_factory=list)
metadata: Dict[str, Any] = field(default_factory=dict)
metrics: Dict[str, Any] = field(default_factory=dict) # tokens, latency, etc.
@dataclass
class DbSpan:
"""A span for tracing/observability."""
span_id: str
trace_id: str
name: str
started_at: float = field(default_factory=time.time)
ended_at: Optional[float] = None
parent_span_id: Optional[str] = None
status: str = "running" # running, ok, error
attributes: Dict[str, Any] = field(default_factory=dict)
events: List[Dict[str, Any]] = field(default_factory=list)
@dataclass
class DbTrace:
"""A trace for observability (groups spans)."""
trace_id: str
session_id: Optional[str] = None
run_id: Optional[str] = None
agent_name: Optional[str] = None
user_id: Optional[str] = None
started_at: float = field(default_factory=time.time)
ended_at: Optional[float] = None
status: str = "running" # running, ok, error
spans: List[DbSpan] = field(default_factory=list)
metadata: Dict[str, Any] = field(default_factory=dict)
@runtime_checkable
class DbAdapter(Protocol):
"""
Protocol for database adapters that provide persistence for agents.
Implementations must provide these methods. The agent will call them
at appropriate lifecycle points if a db adapter is provided.
All methods are synchronous. Async support can be added via wrapper.
"""
def on_agent_start(
self,
agent_name: str,
session_id: str,
user_id: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> List[DbMessage]:
"""
Called when agent starts a session.
Args:
agent_name: Name of the agent
session_id: Session identifier
user_id: Optional user identifier
metadata: Optional session metadata
Returns:
List of previous messages for this session (for resume)
"""
...
def on_user_message(
self,
session_id: str,
content: str,
metadata: Optional[Dict[str, Any]] = None,
) -> None:
"""
Called when user sends a message.
Args:
session_id: Session identifier
content: Message content
metadata: Optional message metadata
"""
...
def on_agent_message(
self,
session_id: str,
content: str,
metadata: Optional[Dict[str, Any]] = None,
) -> None:
"""
Called when agent produces a response.
Args:
session_id: Session identifier
content: Response content
metadata: Optional message metadata
"""
...
def on_tool_call(
self,
session_id: str,
tool_name: str,
args: Dict[str, Any],
result: Any,
metadata: Optional[Dict[str, Any]] = None,
) -> None:
"""
Called when a tool is executed.
Args:
session_id: Session identifier
tool_name: Name of the tool
args: Tool arguments
result: Tool result
metadata: Optional call metadata
"""
...
def on_agent_end(
self,
session_id: str,
metadata: Optional[Dict[str, Any]] = None,
) -> None:
"""
Called when agent session ends.
Args:
session_id: Session identifier
metadata: Optional end metadata
"""
...
def on_run_start(
self,
session_id: str,
run_id: str,
input_content: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> None:
"""
Called when a new run (turn) starts.
Args:
session_id: Session identifier
run_id: Run identifier
input_content: User input for this run
metadata: Optional run metadata
"""
...
def on_run_end(
self,
session_id: str,
run_id: str,
output_content: Optional[str] = None,
status: str = "completed",
metrics: Optional[Dict[str, Any]] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> None:
"""
Called when a run (turn) ends.
Args:
session_id: Session identifier
run_id: Run identifier
output_content: Agent output for this run
status: Run status (completed, error, cancelled)
metrics: Optional metrics (tokens, latency)
metadata: Optional run metadata
"""
...
def get_runs(
self,
session_id: str,
limit: Optional[int] = None,
) -> List["DbRun"]:
"""
Get runs for a session.
Args:
session_id: Session identifier
limit: Maximum number of runs to return
Returns:
List of runs for this session
"""
...
def export_session(
self,
session_id: str,
) -> Dict[str, Any]:
"""
Export a session as a dictionary (for JSONL export).
Args:
session_id: Session identifier
Returns:
Dictionary with session data, messages, runs
"""
...
def import_session(
self,
data: Dict[str, Any],
) -> str:
"""
Import a session from a dictionary.
Args:
data: Session data dictionary
Returns:
Session ID of imported session
"""
...
# --- Tracing/Observability ---
def on_trace_start(
self,
trace_id: str,
session_id: Optional[str] = None,
run_id: Optional[str] = None,
agent_name: Optional[str] = None,
user_id: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> None:
"""
Called when a new trace starts.
Args:
trace_id: Unique trace identifier
session_id: Optional session identifier
run_id: Optional run identifier
agent_name: Optional agent name
user_id: Optional user identifier
metadata: Optional trace metadata
"""
...
def on_trace_end(
self,
trace_id: str,
status: str = "ok",
metadata: Optional[Dict[str, Any]] = None,
) -> None:
"""
Called when a trace ends.
Args:
trace_id: Trace identifier
status: Trace status (ok, error)
metadata: Optional end metadata
"""
...
def on_span_start(
self,
span_id: str,
trace_id: str,
name: str,
parent_span_id: Optional[str] = None,
attributes: Optional[Dict[str, Any]] = None,
) -> None:
"""
Called when a new span starts.
Args:
span_id: Unique span identifier
trace_id: Parent trace identifier
name: Span name (e.g., "llm_call", "tool_execution")
parent_span_id: Optional parent span for nesting
attributes: Optional span attributes
"""
...
def on_span_end(
self,
span_id: str,
status: str = "ok",
attributes: Optional[Dict[str, Any]] = None,
) -> None:
"""
Called when a span ends.
Args:
span_id: Span identifier
status: Span status (ok, error)
attributes: Optional end attributes
"""
...
def get_traces(
self,
session_id: Optional[str] = None,
user_id: Optional[str] = None,
limit: Optional[int] = None,
) -> List["DbTrace"]:
"""
Get traces with optional filters.
Args:
session_id: Filter by session
user_id: Filter by user
limit: Maximum number of traces
Returns:
List of traces
"""
...
def close(self) -> None:
"""Close the database connection."""
...
@runtime_checkable
class AsyncDbAdapter(Protocol):
"""
Async protocol for database adapters.
Provides async variants of all DbAdapter methods for use with
async/await patterns and async backends like asyncpg.
"""
async def on_agent_start(
self,
agent_name: str,
session_id: str,
user_id: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> List["DbMessage"]:
"""Async version of on_agent_start."""
...
async def on_user_message(
self,
session_id: str,
content: str,
metadata: Optional[Dict[str, Any]] = None,
) -> None:
"""Async version of on_user_message."""
...
async def on_agent_message(
self,
session_id: str,
content: str,
metadata: Optional[Dict[str, Any]] = None,
) -> None:
"""Async version of on_agent_message."""
...
async def on_tool_call(
self,
session_id: str,
tool_name: str,
args: Dict[str, Any],
result: Any,
metadata: Optional[Dict[str, Any]] = None,
) -> None:
"""Async version of on_tool_call."""
...
async def on_agent_end(
self,
session_id: str,
metadata: Optional[Dict[str, Any]] = None,
) -> None:
"""Async version of on_agent_end."""
...
async def on_run_start(
self,
session_id: str,
run_id: str,
input_content: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> None:
"""Async version of on_run_start."""
...
async def on_run_end(
self,
session_id: str,
run_id: str,
output_content: Optional[str] = None,
status: str = "completed",
metrics: Optional[Dict[str, Any]] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> None:
"""Async version of on_run_end."""
...
async def export_session(
self,
session_id: str,
) -> Dict[str, Any]:
"""Async version of export_session."""
...
async def import_session(
self,
data: Dict[str, Any],
) -> str:
"""Async version of import_session."""
...
async def close(self) -> None:
"""Async close the database connection."""
...
@@ -0,0 +1 @@
"""TDD tests for db integration."""
@@ -0,0 +1,246 @@
"""
TDD tests for PraisonAI Agents DB integration.
Tests:
1. Lazy import verification - no heavy deps on import
2. Agent(db=MockDb) calls hook lifecycle in correct order
3. Session resume injects history
4. db=None behavior unchanged
"""
import sys
import time
def test_lazy_import_no_heavy_deps():
"""Test that importing praisonaiagents.db does not import DB libs."""
print("=== Test: Lazy Import Verification ===")
# Record modules before import
before_modules = set(sys.modules.keys())
# Import only the db module (not full praisonaiagents which loads Knowledge)
from praisonaiagents.db import DbAdapter, DbMessage
# Check that heavy DB libs are NOT loaded by db module itself
# Note: praisonaiagents main package loads qdrant/chroma via Knowledge
# but the db module should not add any additional heavy deps
heavy_libs = ['psycopg2', 'pymysql', 'redis'] # DB-specific libs
loaded_heavy = [lib for lib in heavy_libs if lib in sys.modules]
if loaded_heavy:
print(f" ❌ Heavy DB libs loaded by db module: {loaded_heavy}")
return False
print(" ✅ No heavy DB libs loaded by praisonaiagents.db module")
return True
def test_agent_accepts_db_parameter():
"""Test that Agent accepts db and session_id parameters."""
print("\n=== Test: Agent Accepts db Parameter ===")
import inspect
from praisonaiagents import Agent
sig = inspect.signature(Agent.__init__)
params = list(sig.parameters.keys())
if 'db' not in params:
print(" ❌ Agent does not accept 'db' parameter")
return False
if 'session_id' not in params:
print(" ❌ Agent does not accept 'session_id' parameter")
return False
print(" ✅ Agent accepts db and session_id parameters")
return True
class MockDbAdapter:
"""Mock DB adapter for testing hook lifecycle."""
def __init__(self):
self.calls = []
self.history_to_return = []
def on_agent_start(self, agent_name, session_id, user_id=None, metadata=None):
self.calls.append(('on_agent_start', agent_name, session_id))
return self.history_to_return
def on_user_message(self, session_id, content, metadata=None):
self.calls.append(('on_user_message', session_id, content))
def on_agent_message(self, session_id, content, metadata=None):
self.calls.append(('on_agent_message', session_id, content))
def on_tool_call(self, session_id, tool_name, args, result, metadata=None):
self.calls.append(('on_tool_call', session_id, tool_name))
def on_agent_end(self, session_id, metadata=None):
self.calls.append(('on_agent_end', session_id))
def close(self):
self.calls.append(('close',))
def test_db_hook_lifecycle():
"""Test that Agent calls db hooks in correct order."""
print("\n=== Test: DB Hook Lifecycle ===")
from praisonaiagents import Agent
mock_db = MockDbAdapter()
# Create agent with mock db
agent = Agent(
name="TestAgent",
instructions="Test agent",
db=mock_db,
session_id="test-session-001",
verbose=False
)
# Verify db is stored
if agent._db is not mock_db:
print(" ❌ Agent did not store db adapter")
return False
if agent._session_id != "test-session-001":
print(" ❌ Agent did not store session_id")
return False
print(" ✅ Agent stores db and session_id correctly")
return True
def test_db_none_unchanged_behavior():
"""Test that db=None behavior is unchanged."""
print("\n=== Test: db=None Unchanged Behavior ===")
from praisonaiagents import Agent
# Create agent without db
agent = Agent(
name="TestAgent",
instructions="Test agent",
verbose=False
)
# Verify db is None
if agent._db is not None:
print(" ❌ Agent._db should be None when not provided")
return False
# Verify _init_db_session does nothing when db is None
agent._init_db_session()
if agent._db_initialized:
print(" ❌ _db_initialized should remain False when db is None")
return False
print(" ✅ db=None behavior unchanged")
return True
def test_session_id_auto_generation():
"""Test that session_id is auto-generated if not provided."""
print("\n=== Test: Session ID Auto-Generation ===")
from praisonaiagents import Agent
mock_db = MockDbAdapter()
agent = Agent(
name="TestAgent",
instructions="Test agent",
db=mock_db,
# No session_id provided
verbose=False
)
# Trigger db initialization
agent._init_db_session()
if agent._session_id is None:
print(" ❌ session_id should be auto-generated")
return False
if not agent._session_id.startswith("session-"):
print(f" ❌ session_id format unexpected: {agent._session_id}")
return False
print(f" ✅ session_id auto-generated: {agent._session_id}")
return True
def test_protocol_compliance():
"""Test that DbAdapter protocol is properly defined."""
print("\n=== Test: DbAdapter Protocol Compliance ===")
from praisonaiagents.db.protocol import DbAdapter, DbMessage
# Check DbMessage fields
msg = DbMessage(role="user", content="test")
if msg.role != "user" or msg.content != "test":
print(" ❌ DbMessage fields not working")
return False
# Check protocol methods
required_methods = [
'on_agent_start',
'on_user_message',
'on_agent_message',
'on_tool_call',
'on_agent_end',
'close'
]
mock = MockDbAdapter()
for method in required_methods:
if not hasattr(mock, method):
print(f" ❌ MockDbAdapter missing {method}")
return False
# Check protocol isinstance
if not isinstance(mock, DbAdapter):
print(" ❌ MockDbAdapter should implement DbAdapter protocol")
return False
print(" ✅ DbAdapter protocol properly defined")
return True
def run_all_tests():
"""Run all TDD tests."""
print("=" * 60)
print("PraisonAI Agents DB Integration - TDD Tests")
print("=" * 60)
results = []
results.append(("Lazy Import", test_lazy_import_no_heavy_deps()))
results.append(("Agent db Parameter", test_agent_accepts_db_parameter()))
results.append(("DB Hook Lifecycle", test_db_hook_lifecycle()))
results.append(("db=None Unchanged", test_db_none_unchanged_behavior()))
results.append(("Session ID Auto-Gen", test_session_id_auto_generation()))
results.append(("Protocol Compliance", test_protocol_compliance()))
print("\n" + "=" * 60)
passed = sum(1 for _, r in results if r)
total = len(results)
print(f"Results: {passed}/{total} tests passed")
for name, result in results:
status = "✅" if result else "❌"
print(f" {status} {name}")
print("=" * 60)
return passed == total
if __name__ == "__main__":
success = run_all_tests()
sys.exit(0 if success else 1)
@@ -65,6 +65,23 @@ __all__ = [
"AfterAgentInput",
"SessionStartInput",
"SessionEndInput",
# Middleware types
"InvocationContext",
"ModelRequest",
"ModelResponse",
"ToolRequest",
"ToolResponse",
# Middleware decorators
"before_model",
"after_model",
"wrap_model_call",
"before_tool",
"after_tool",
"wrap_tool_call",
# Middleware utilities
"MiddlewareChain",
"AsyncMiddlewareChain",
"MiddlewareManager",
]
@@ -94,4 +111,27 @@ def __getattr__(name: str):
)
return locals()[name]
# Middleware types
if name in ("InvocationContext", "ModelRequest", "ModelResponse",
"ToolRequest", "ToolResponse"):
from .middleware import (
InvocationContext, ModelRequest, ModelResponse,
ToolRequest, ToolResponse
)
return locals()[name]
# Middleware decorators
if name in ("before_model", "after_model", "wrap_model_call",
"before_tool", "after_tool", "wrap_tool_call"):
from .middleware import (
before_model, after_model, wrap_model_call,
before_tool, after_tool, wrap_tool_call
)
return locals()[name]
# Middleware utilities
if name in ("MiddlewareChain", "AsyncMiddlewareChain", "MiddlewareManager"):
from .middleware import MiddlewareChain, AsyncMiddlewareChain, MiddlewareManager
return locals()[name]
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,439 @@
"""
Middleware System for PraisonAI Agents.
Provides wrap_model_call and wrap_tool_call patterns for intercepting
and modifying agent behavior at model/tool call boundaries.
Zero Performance Impact:
- All middleware is optional
- Empty middleware chain is O(1) fast path
- No overhead when middleware not registered
Usage:
from praisonaiagents.hooks import before_model, wrap_model_call, wrap_tool_call
@before_model
def add_context(request):
request.messages.append({"role": "system", "content": "Extra"})
return request
@wrap_model_call
def retry_on_error(request, call_next):
for _ in range(3):
try:
return call_next(request)
except Exception:
pass
raise RuntimeError("All retries failed")
agent = Agent(name="Test", hooks=[add_context, retry_on_error])
"""
from dataclasses import dataclass, field
from typing import Any, Callable, Dict, List, Optional, TypeVar, Generic
from functools import wraps
@dataclass
class InvocationContext:
"""Context passed through middleware chain.
Contains identifiers for multi-agent safety and optional metadata.
"""
agent_id: str
run_id: str
session_id: str
tool_name: Optional[str] = None
model_name: Optional[str] = None
metadata: Dict[str, Any] = field(default_factory=dict)
@dataclass
class ModelRequest:
"""Request data for model calls."""
messages: List[Dict[str, Any]]
model: str
temperature: float = 1.0
context: Optional[InvocationContext] = None
tools: Optional[List[Any]] = None
extra: Dict[str, Any] = field(default_factory=dict)
@dataclass
class ModelResponse:
"""Response data from model calls."""
content: str
model: str
usage: Optional[Dict[str, int]] = None
context: Optional[InvocationContext] = None
tool_calls: Optional[List[Any]] = None
extra: Dict[str, Any] = field(default_factory=dict)
@dataclass
class ToolRequest:
"""Request data for tool calls."""
tool_name: str
arguments: Dict[str, Any]
context: Optional[InvocationContext] = None
extra: Dict[str, Any] = field(default_factory=dict)
@dataclass
class ToolResponse:
"""Response data from tool calls."""
tool_name: str
result: Any
error: Optional[str] = None
context: Optional[InvocationContext] = None
extra: Dict[str, Any] = field(default_factory=dict)
# Type aliases for middleware functions
T = TypeVar('T')
BeforeModelFn = Callable[[ModelRequest], ModelRequest]
AfterModelFn = Callable[[ModelResponse], ModelResponse]
WrapModelCallFn = Callable[[ModelRequest, Callable[[ModelRequest], ModelResponse]], ModelResponse]
BeforeToolFn = Callable[[ToolRequest], ToolRequest]
AfterToolFn = Callable[[ToolResponse], ToolResponse]
WrapToolCallFn = Callable[[ToolRequest, Callable[[ToolRequest], ToolResponse]], ToolResponse]
class MiddlewareChain:
"""Synchronous middleware chain executor.
Composes middleware functions in registration order.
First registered middleware wraps all subsequent ones.
"""
def __init__(self, middlewares: Optional[List[Callable]] = None):
self._middlewares = middlewares or []
def execute(self, request: Any, final_handler: Callable[[Any], Any]) -> Any:
"""Execute the middleware chain with a final handler.
Args:
request: The request object to pass through
final_handler: The actual operation to perform
Returns:
The result from the chain
"""
if not self._middlewares:
return final_handler(request)
# Build the chain from inside out
# Last middleware wraps final_handler
# First middleware is outermost
def build_chain(index: int) -> Callable[[Any], Any]:
if index >= len(self._middlewares):
return final_handler
middleware = self._middlewares[index]
next_handler = build_chain(index + 1)
def handler(req: Any) -> Any:
return middleware(req, next_handler)
return handler
chain = build_chain(0)
return chain(request)
def __len__(self) -> int:
return len(self._middlewares)
class AsyncMiddlewareChain:
"""Asynchronous middleware chain executor.
Same as MiddlewareChain but for async middleware and handlers.
"""
def __init__(self, middlewares: Optional[List[Callable]] = None):
self._middlewares = middlewares or []
async def execute(self, request: Any, final_handler: Callable) -> Any:
"""Execute the async middleware chain.
Args:
request: The request object to pass through
final_handler: The actual async operation to perform
Returns:
The result from the chain
"""
if not self._middlewares:
return await final_handler(request)
# Build the chain from inside out
def build_chain(index: int) -> Callable:
if index >= len(self._middlewares):
return final_handler
middleware = self._middlewares[index]
next_handler = build_chain(index + 1)
async def handler(req: Any) -> Any:
return await middleware(req, next_handler)
return handler
chain = build_chain(0)
return await chain(request)
def __len__(self) -> int:
return len(self._middlewares)
# Decorator functions for tagging middleware
def before_model(func: BeforeModelFn) -> BeforeModelFn:
"""Decorator to mark a function as a before_model hook.
The function receives a ModelRequest and should return a (possibly modified) ModelRequest.
Example:
@before_model
def add_context(request):
request.messages.append({"role": "system", "content": "Extra"})
return request
"""
func._hook_type = 'before_model'
return func
def after_model(func: AfterModelFn) -> AfterModelFn:
"""Decorator to mark a function as an after_model hook.
The function receives a ModelResponse and should return a (possibly modified) ModelResponse.
Example:
@after_model
def log_response(response):
print(f"Model returned: {response.content[:50]}")
return response
"""
func._hook_type = 'after_model'
return func
def wrap_model_call(func: WrapModelCallFn) -> WrapModelCallFn:
"""Decorator to mark a function as a wrap_model_call middleware.
The function receives (request, call_next) and should call call_next(request)
to continue the chain, or return early to short-circuit.
Example:
@wrap_model_call
def retry_on_error(request, call_next):
for _ in range(3):
try:
return call_next(request)
except Exception:
pass
raise RuntimeError("All retries failed")
"""
func._hook_type = 'wrap_model_call'
return func
def before_tool(func: BeforeToolFn) -> BeforeToolFn:
"""Decorator to mark a function as a before_tool hook.
The function receives a ToolRequest and should return a (possibly modified) ToolRequest.
Example:
@before_tool
def validate_args(request):
if 'dangerous' in request.arguments:
raise ValueError("Dangerous argument detected")
return request
"""
func._hook_type = 'before_tool'
return func
def after_tool(func: AfterToolFn) -> AfterToolFn:
"""Decorator to mark a function as an after_tool hook.
The function receives a ToolResponse and should return a (possibly modified) ToolResponse.
Example:
@after_tool
def log_result(response):
print(f"Tool {response.tool_name} returned: {response.result}")
return response
"""
func._hook_type = 'after_tool'
return func
def wrap_tool_call(func: WrapToolCallFn) -> WrapToolCallFn:
"""Decorator to mark a function as a wrap_tool_call middleware.
The function receives (request, call_next) and should call call_next(request)
to continue the chain, or return early to short-circuit.
Example:
@wrap_tool_call
def retry_flaky_tool(request, call_next):
last_error = None
for _ in range(3):
try:
return call_next(request)
except Exception as e:
last_error = e
raise last_error
"""
func._hook_type = 'wrap_tool_call'
return func
def get_hook_type(func: Callable) -> Optional[str]:
"""Get the hook type of a decorated function."""
return getattr(func, '_hook_type', None)
def is_middleware(func: Callable) -> bool:
"""Check if a function is a middleware (wrap_* type)."""
hook_type = get_hook_type(func)
return hook_type in ('wrap_model_call', 'wrap_tool_call')
def categorize_hooks(hooks: List[Callable]) -> Dict[str, List[Callable]]:
"""Categorize hooks by their type.
Returns:
Dict with keys: before_model, after_model, wrap_model_call,
before_tool, after_tool, wrap_tool_call
"""
categories = {
'before_model': [],
'after_model': [],
'wrap_model_call': [],
'before_tool': [],
'after_tool': [],
'wrap_tool_call': [],
}
for hook in hooks or []:
hook_type = get_hook_type(hook)
if hook_type and hook_type in categories:
categories[hook_type].append(hook)
return categories
class MiddlewareManager:
"""Manages middleware for an agent.
Provides methods to execute before/after hooks and middleware chains
for both model and tool calls.
"""
def __init__(self, hooks: Optional[List[Callable]] = None):
self._hooks = hooks or []
self._categorized = categorize_hooks(self._hooks)
# Pre-build chains for efficiency
self._model_chain = MiddlewareChain(self._categorized['wrap_model_call'])
self._tool_chain = MiddlewareChain(self._categorized['wrap_tool_call'])
@property
def has_model_hooks(self) -> bool:
"""Check if any model hooks are registered."""
return bool(
self._categorized['before_model'] or
self._categorized['after_model'] or
self._categorized['wrap_model_call']
)
@property
def has_tool_hooks(self) -> bool:
"""Check if any tool hooks are registered."""
return bool(
self._categorized['before_tool'] or
self._categorized['after_tool'] or
self._categorized['wrap_tool_call']
)
def run_before_model(self, request: ModelRequest) -> ModelRequest:
"""Run all before_model hooks."""
for hook in self._categorized['before_model']:
request = hook(request)
return request
def run_after_model(self, response: ModelResponse) -> ModelResponse:
"""Run all after_model hooks (in reverse order)."""
for hook in reversed(self._categorized['after_model']):
response = hook(response)
return response
def run_before_tool(self, request: ToolRequest) -> ToolRequest:
"""Run all before_tool hooks."""
for hook in self._categorized['before_tool']:
request = hook(request)
return request
def run_after_tool(self, response: ToolResponse) -> ToolResponse:
"""Run all after_tool hooks (in reverse order)."""
for hook in reversed(self._categorized['after_tool']):
response = hook(response)
return response
def execute_model_call(
self,
request: ModelRequest,
model_fn: Callable[[ModelRequest], ModelResponse]
) -> ModelResponse:
"""Execute a model call with all hooks and middleware.
Order:
1. before_model hooks (in order)
2. wrap_model_call middleware chain
3. actual model call
4. after_model hooks (in reverse order)
"""
# Fast path: no hooks
if not self.has_model_hooks:
return model_fn(request)
# Run before hooks
request = self.run_before_model(request)
# Build wrapped handler that includes after hooks
def wrapped_model_fn(req: ModelRequest) -> ModelResponse:
response = model_fn(req)
return self.run_after_model(response)
# Execute through middleware chain
return self._model_chain.execute(request, wrapped_model_fn)
def execute_tool_call(
self,
request: ToolRequest,
tool_fn: Callable[[ToolRequest], ToolResponse]
) -> ToolResponse:
"""Execute a tool call with all hooks and middleware.
Order:
1. before_tool hooks (in order)
2. wrap_tool_call middleware chain
3. actual tool call
4. after_tool hooks (in reverse order)
"""
# Fast path: no hooks
if not self.has_tool_hooks:
return tool_fn(request)
# Run before hooks
request = self.run_before_tool(request)
# Build wrapped handler that includes after hooks
def wrapped_tool_fn(req: ToolRequest) -> ToolResponse:
response = tool_fn(req)
return self.run_after_tool(response)
# Execute through middleware chain
return self._tool_chain.execute(request, wrapped_tool_fn)
@@ -1,8 +1,130 @@
"""
PraisonAI Knowledge - Advanced knowledge management system with configurable features
PraisonAI Knowledge - Advanced knowledge management system with configurable features.
This module provides:
- Document readers (ReaderProtocol, ReaderRegistry)
- Vector stores (VectorStoreProtocol, VectorStoreRegistry)
- Retrieval strategies (RetrieverProtocol, RetrievalStrategy)
- Rerankers (RerankerProtocol, RerankerRegistry)
- Index types (IndexProtocol, IndexType)
- Query engines (QueryEngineProtocol, QueryMode)
"""
# Core Knowledge class (always available)
from praisonaiagents.knowledge.knowledge import Knowledge
from praisonaiagents.knowledge.chunking import Chunking
__all__ = ["Knowledge", "Chunking"]
# Lazy loading for protocols and registries to avoid import overhead
_LAZY_IMPORTS = {
# Readers
"Document": ("praisonaiagents.knowledge.readers", "Document"),
"ReaderProtocol": ("praisonaiagents.knowledge.readers", "ReaderProtocol"),
"ReaderRegistry": ("praisonaiagents.knowledge.readers", "ReaderRegistry"),
"get_reader_registry": ("praisonaiagents.knowledge.readers", "get_reader_registry"),
"detect_source_kind": ("praisonaiagents.knowledge.readers", "detect_source_kind"),
# Vector stores
"VectorRecord": ("praisonaiagents.knowledge.vector_store", "VectorRecord"),
"VectorStoreProtocol": ("praisonaiagents.knowledge.vector_store", "VectorStoreProtocol"),
"VectorStoreRegistry": ("praisonaiagents.knowledge.vector_store", "VectorStoreRegistry"),
"get_vector_store_registry": ("praisonaiagents.knowledge.vector_store", "get_vector_store_registry"),
"InMemoryVectorStore": ("praisonaiagents.knowledge.vector_store", "InMemoryVectorStore"),
# Retrieval
"RetrievalResult": ("praisonaiagents.knowledge.retrieval", "RetrievalResult"),
"RetrievalStrategy": ("praisonaiagents.knowledge.retrieval", "RetrievalStrategy"),
"RetrieverProtocol": ("praisonaiagents.knowledge.retrieval", "RetrieverProtocol"),
"RetrieverRegistry": ("praisonaiagents.knowledge.retrieval", "RetrieverRegistry"),
"get_retriever_registry": ("praisonaiagents.knowledge.retrieval", "get_retriever_registry"),
"reciprocal_rank_fusion": ("praisonaiagents.knowledge.retrieval", "reciprocal_rank_fusion"),
"merge_adjacent_chunks": ("praisonaiagents.knowledge.retrieval", "merge_adjacent_chunks"),
# Rerankers
"RerankResult": ("praisonaiagents.knowledge.rerankers", "RerankResult"),
"RerankerProtocol": ("praisonaiagents.knowledge.rerankers", "RerankerProtocol"),
"RerankerRegistry": ("praisonaiagents.knowledge.rerankers", "RerankerRegistry"),
"get_reranker_registry": ("praisonaiagents.knowledge.rerankers", "get_reranker_registry"),
"SimpleReranker": ("praisonaiagents.knowledge.rerankers", "SimpleReranker"),
# Index
"IndexType": ("praisonaiagents.knowledge.index", "IndexType"),
"IndexStats": ("praisonaiagents.knowledge.index", "IndexStats"),
"IndexProtocol": ("praisonaiagents.knowledge.index", "IndexProtocol"),
"IndexRegistry": ("praisonaiagents.knowledge.index", "IndexRegistry"),
"get_index_registry": ("praisonaiagents.knowledge.index", "get_index_registry"),
"KeywordIndex": ("praisonaiagents.knowledge.index", "KeywordIndex"),
# Query engine
"QueryMode": ("praisonaiagents.knowledge.query_engine", "QueryMode"),
"QueryResult": ("praisonaiagents.knowledge.query_engine", "QueryResult"),
"QueryEngineProtocol": ("praisonaiagents.knowledge.query_engine", "QueryEngineProtocol"),
"QueryEngineRegistry": ("praisonaiagents.knowledge.query_engine", "QueryEngineRegistry"),
"get_query_engine_registry": ("praisonaiagents.knowledge.query_engine", "get_query_engine_registry"),
"decompose_question": ("praisonaiagents.knowledge.query_engine", "decompose_question"),
"SimpleQueryEngine": ("praisonaiagents.knowledge.query_engine", "SimpleQueryEngine"),
"SubQuestionEngine": ("praisonaiagents.knowledge.query_engine", "SubQuestionEngine"),
}
def __getattr__(name: str):
"""Lazy load protocols and registries."""
if name in _LAZY_IMPORTS:
module_path, attr_name = _LAZY_IMPORTS[name]
import importlib
module = importlib.import_module(module_path)
return getattr(module, attr_name)
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
def __dir__():
"""List available attributes."""
return list(_LAZY_IMPORTS.keys()) + ["Knowledge", "Chunking"]
__all__ = [
# Core
"Knowledge",
"Chunking",
# Readers
"Document",
"ReaderProtocol",
"ReaderRegistry",
"get_reader_registry",
"detect_source_kind",
# Vector stores
"VectorRecord",
"VectorStoreProtocol",
"VectorStoreRegistry",
"get_vector_store_registry",
"InMemoryVectorStore",
# Retrieval
"RetrievalResult",
"RetrievalStrategy",
"RetrieverProtocol",
"RetrieverRegistry",
"get_retriever_registry",
"reciprocal_rank_fusion",
"merge_adjacent_chunks",
# Rerankers
"RerankResult",
"RerankerProtocol",
"RerankerRegistry",
"get_reranker_registry",
"SimpleReranker",
# Index
"IndexType",
"IndexStats",
"IndexProtocol",
"IndexRegistry",
"get_index_registry",
"KeywordIndex",
# Query engine
"QueryMode",
"QueryResult",
"QueryEngineProtocol",
"QueryEngineRegistry",
"get_query_engine_registry",
"decompose_question",
"SimpleQueryEngine",
"SubQuestionEngine",
]
@@ -0,0 +1,332 @@
"""
Index Type Selection for PraisonAI Agents.
This module defines index types and protocols.
NO heavy imports - only stdlib and typing.
Implementations are provided by the wrapper layer.
"""
from dataclasses import dataclass, field
from typing import Any, Callable, Dict, List, Optional, Protocol, runtime_checkable
from enum import Enum
import logging
import math
logger = logging.getLogger(__name__)
class IndexType(str, Enum):
"""Available index types."""
VECTOR = "vector"
KEYWORD = "keyword"
HYBRID = "hybrid"
GRAPH = "graph"
@dataclass
class IndexStats:
"""Statistics about an index."""
index_type: IndexType
document_count: int
chunk_count: int
total_tokens: int = 0
metadata: Dict[str, Any] = field(default_factory=dict)
@runtime_checkable
class IndexProtocol(Protocol):
"""
Protocol for document indices.
Implementations must provide methods for indexing and querying.
"""
name: str
index_type: IndexType
def add_documents(
self,
texts: List[str],
metadatas: Optional[List[Dict[str, Any]]] = None,
ids: Optional[List[str]] = None,
embeddings: Optional[List[List[float]]] = None
) -> List[str]:
"""
Add documents to the index.
Args:
texts: List of text content
metadatas: Optional list of metadata dicts
ids: Optional list of IDs
embeddings: Optional pre-computed embeddings (for vector index)
Returns:
List of document IDs
"""
...
def query(
self,
query: str,
top_k: int = 10,
filter: Optional[Dict[str, Any]] = None,
query_embedding: Optional[List[float]] = None
) -> List[Dict[str, Any]]:
"""
Query the index.
Args:
query: Search query
top_k: Number of results
filter: Optional metadata filter
query_embedding: Optional pre-computed query embedding
Returns:
List of results with text, score, metadata
"""
...
def delete(
self,
ids: Optional[List[str]] = None,
filter: Optional[Dict[str, Any]] = None,
delete_all: bool = False
) -> int:
"""Delete documents from the index."""
...
def stats(self) -> IndexStats:
"""Get index statistics."""
...
class IndexRegistry:
"""Registry for index types."""
_instance: Optional["IndexRegistry"] = None
def __new__(cls) -> "IndexRegistry":
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._instance._indices: Dict[str, Callable[..., IndexProtocol]] = {}
return cls._instance
def register(self, name: str, factory: Callable[..., IndexProtocol]) -> None:
"""Register an index factory."""
self._indices[name] = factory
def get(self, name: str, **kwargs) -> Optional[IndexProtocol]:
"""Get an index by name."""
if name in self._indices:
try:
return self._indices[name](**kwargs)
except Exception as e:
logger.warning(f"Failed to initialize index '{name}': {e}")
return None
return None
def list_indices(self) -> List[str]:
"""List all registered index names."""
return list(self._indices.keys())
def clear(self) -> None:
"""Clear all registered indices."""
self._indices.clear()
def get_index_registry() -> IndexRegistry:
"""Get the global index registry instance."""
return IndexRegistry()
class KeywordIndex:
"""
Simple BM25-like keyword index.
Pure Python implementation with no external dependencies.
"""
name: str = "keyword"
index_type: IndexType = IndexType.KEYWORD
def __init__(self, k1: float = 1.5, b: float = 0.75, **kwargs):
self.k1 = k1
self.b = b
self._documents: Dict[str, Dict[str, Any]] = {} # id -> {text, metadata, terms}
self._term_doc_freq: Dict[str, int] = {} # term -> doc count
self._avg_doc_len: float = 0.0
self._total_docs: int = 0
def add_documents(
self,
texts: List[str],
metadatas: Optional[List[Dict[str, Any]]] = None,
ids: Optional[List[str]] = None,
embeddings: Optional[List[List[float]]] = None
) -> List[str]:
"""Add documents to the keyword index."""
import uuid
metadatas = metadatas or [{} for _ in texts]
ids = ids or [str(uuid.uuid4()) for _ in texts]
total_len = sum(len(doc["text"].split()) for doc in self._documents.values())
for text, metadata, doc_id in zip(texts, metadatas, ids):
terms = self._tokenize(text)
term_freq = {}
for term in terms:
term_freq[term] = term_freq.get(term, 0) + 1
self._documents[doc_id] = {
"text": text,
"metadata": metadata,
"terms": term_freq,
"length": len(terms)
}
# Update document frequency
for term in set(terms):
self._term_doc_freq[term] = self._term_doc_freq.get(term, 0) + 1
total_len += len(terms)
self._total_docs = len(self._documents)
self._avg_doc_len = total_len / self._total_docs if self._total_docs > 0 else 0
return ids
def query(
self,
query: str,
top_k: int = 10,
filter: Optional[Dict[str, Any]] = None,
query_embedding: Optional[List[float]] = None
) -> List[Dict[str, Any]]:
"""Query using BM25 scoring."""
query_terms = self._tokenize(query)
scores = []
for doc_id, doc in self._documents.items():
# Apply filter
if filter:
match = True
for key, value in filter.items():
if doc["metadata"].get(key) != value:
match = False
break
if not match:
continue
score = self._bm25_score(query_terms, doc)
scores.append({
"id": doc_id,
"text": doc["text"],
"score": score,
"metadata": doc["metadata"]
})
# Sort by score
scores.sort(key=lambda x: x["score"], reverse=True)
return scores[:top_k]
def delete(
self,
ids: Optional[List[str]] = None,
filter: Optional[Dict[str, Any]] = None,
delete_all: bool = False
) -> int:
"""Delete documents from the index."""
if delete_all:
count = len(self._documents)
self._documents.clear()
self._term_doc_freq.clear()
self._total_docs = 0
self._avg_doc_len = 0
return count
deleted = 0
to_delete = []
if ids:
to_delete = [id_ for id_ in ids if id_ in self._documents]
elif filter:
for doc_id, doc in self._documents.items():
match = True
for key, value in filter.items():
if doc["metadata"].get(key) != value:
match = False
break
if match:
to_delete.append(doc_id)
for doc_id in to_delete:
doc = self._documents[doc_id]
# Update term frequencies
for term in doc["terms"]:
if term in self._term_doc_freq:
self._term_doc_freq[term] -= 1
if self._term_doc_freq[term] <= 0:
del self._term_doc_freq[term]
del self._documents[doc_id]
deleted += 1
self._total_docs = len(self._documents)
if self._total_docs > 0:
self._avg_doc_len = sum(d["length"] for d in self._documents.values()) / self._total_docs
else:
self._avg_doc_len = 0
return deleted
def stats(self) -> IndexStats:
"""Get index statistics."""
return IndexStats(
index_type=self.index_type,
document_count=self._total_docs,
chunk_count=self._total_docs,
total_tokens=sum(d["length"] for d in self._documents.values()),
metadata={"unique_terms": len(self._term_doc_freq)}
)
def _tokenize(self, text: str) -> List[str]:
"""Simple tokenization."""
import re
# Lowercase and split on non-alphanumeric
tokens = re.findall(r'\b\w+\b', text.lower())
# Remove very short tokens
return [t for t in tokens if len(t) > 1]
def _bm25_score(self, query_terms: List[str], doc: Dict[str, Any]) -> float:
"""Calculate BM25 score."""
score = 0.0
doc_len = doc["length"]
term_freq = doc["terms"]
for term in query_terms:
if term not in term_freq:
continue
tf = term_freq[term]
df = self._term_doc_freq.get(term, 0)
# IDF component
idf = math.log((self._total_docs - df + 0.5) / (df + 0.5) + 1)
# TF component with length normalization
tf_norm = (tf * (self.k1 + 1)) / (tf + self.k1 * (1 - self.b + self.b * doc_len / self._avg_doc_len))
score += idf * tf_norm
return score
# Register the keyword index by default
def _register_default_indices():
"""Register default indices."""
registry = get_index_registry()
registry.register("keyword", KeywordIndex)
_register_default_indices()
@@ -0,0 +1,351 @@
"""
Query Engine Patterns for PraisonAI Agents.
This module defines query modes and protocols.
NO heavy imports - only stdlib and typing.
Implementations are provided by the wrapper layer.
"""
from dataclasses import dataclass, field
from typing import Any, Callable, Dict, List, Optional, Protocol, runtime_checkable
from enum import Enum
import logging
logger = logging.getLogger(__name__)
class QueryMode(str, Enum):
"""Available query modes."""
DEFAULT = "default"
SUB_QUESTION = "sub_question"
SQL = "sql"
ROUTER = "router"
SUMMARIZE = "summarize"
@dataclass
class QueryResult:
"""
Result from a query operation.
Attributes:
answer: The synthesized answer
sources: List of source documents/chunks used
sub_questions: Optional list of sub-questions (for sub_question mode)
metadata: Additional metadata
"""
answer: str
sources: List[Dict[str, Any]] = field(default_factory=list)
sub_questions: Optional[List[str]] = None
metadata: Dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
return {
"answer": self.answer,
"sources": self.sources,
"sub_questions": self.sub_questions,
"metadata": self.metadata
}
@runtime_checkable
class QueryEngineProtocol(Protocol):
"""
Protocol for query engines.
Implementations must provide a query method that takes a question
and returns an answer with sources.
"""
name: str
mode: QueryMode
def query(
self,
question: str,
context: Optional[List[str]] = None,
**kwargs
) -> QueryResult:
"""
Query the knowledge base.
Args:
question: The user's question
context: Optional pre-retrieved context
**kwargs: Additional engine-specific options
Returns:
QueryResult with answer and sources
"""
...
async def aquery(
self,
question: str,
context: Optional[List[str]] = None,
**kwargs
) -> QueryResult:
"""Async version of query."""
...
class QueryEngineRegistry:
"""Registry for query engines."""
_instance: Optional["QueryEngineRegistry"] = None
def __new__(cls) -> "QueryEngineRegistry":
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._instance._engines: Dict[str, Callable[..., QueryEngineProtocol]] = {}
return cls._instance
def register(self, name: str, factory: Callable[..., QueryEngineProtocol]) -> None:
"""Register a query engine factory."""
self._engines[name] = factory
def get(self, name: str, **kwargs) -> Optional[QueryEngineProtocol]:
"""Get a query engine by name."""
if name in self._engines:
try:
return self._engines[name](**kwargs)
except Exception as e:
logger.warning(f"Failed to initialize query engine '{name}': {e}")
return None
return None
def list_engines(self) -> List[str]:
"""List all registered engine names."""
return list(self._engines.keys())
def clear(self) -> None:
"""Clear all registered engines."""
self._engines.clear()
def get_query_engine_registry() -> QueryEngineRegistry:
"""Get the global query engine registry instance."""
return QueryEngineRegistry()
def decompose_question(question: str) -> List[str]:
"""
Simple deterministic question decomposition.
Splits complex questions into sub-questions without LLM.
Used as fallback when LLM is not available.
Args:
question: The complex question
Returns:
List of sub-questions
"""
sub_questions = []
# Split on common conjunctions
conjunctions = [" and ", " also ", " additionally ", " furthermore ", " moreover "]
parts = [question]
for conj in conjunctions:
new_parts = []
for part in parts:
new_parts.extend(part.split(conj))
parts = new_parts
# Clean up parts
for part in parts:
part = part.strip()
if part:
# Ensure it ends with a question mark
if not part.endswith("?"):
part = part + "?"
# Capitalize first letter
part = part[0].upper() + part[1:] if len(part) > 1 else part.upper()
sub_questions.append(part)
# If no decomposition happened, return original
if len(sub_questions) <= 1:
return [question]
return sub_questions
def synthesize_answer(
question: str,
contexts: List[str],
max_context_length: int = 4000
) -> str:
"""
Simple answer synthesis without LLM.
Concatenates relevant context with the question.
Used as fallback when LLM is not available.
Args:
question: The original question
contexts: List of context strings
max_context_length: Maximum total context length
Returns:
Synthesized answer string
"""
if not contexts:
return "No relevant information found."
# Truncate contexts to fit
total_length = 0
selected_contexts = []
for ctx in contexts:
if total_length + len(ctx) > max_context_length:
remaining = max_context_length - total_length
if remaining > 100:
selected_contexts.append(ctx[:remaining] + "...")
break
selected_contexts.append(ctx)
total_length += len(ctx)
# Format as a simple answer
context_text = "\n\n".join(selected_contexts)
return f"Based on the available information:\n\n{context_text}"
class SimpleQueryEngine:
"""
Simple query engine for testing and fallback.
Uses basic context concatenation without LLM synthesis.
"""
name: str = "simple"
mode: QueryMode = QueryMode.DEFAULT
def __init__(self, **kwargs):
pass
def query(
self,
question: str,
context: Optional[List[str]] = None,
**kwargs
) -> QueryResult:
"""Simple query that returns context as answer."""
contexts = context or []
answer = synthesize_answer(question, contexts)
return QueryResult(
answer=answer,
sources=[{"text": ctx} for ctx in contexts],
metadata={"mode": self.mode.value}
)
async def aquery(
self,
question: str,
context: Optional[List[str]] = None,
**kwargs
) -> QueryResult:
"""Async version (just calls sync)."""
return self.query(question, context, **kwargs)
class SubQuestionEngine:
"""
Sub-question decomposition query engine.
Decomposes complex questions into simpler sub-questions.
Uses deterministic fallback when LLM is not available.
"""
name: str = "sub_question"
mode: QueryMode = QueryMode.SUB_QUESTION
def __init__(self, llm: Optional[Any] = None, **kwargs):
self.llm = llm
def query(
self,
question: str,
context: Optional[List[str]] = None,
retriever: Optional[Any] = None,
**kwargs
) -> QueryResult:
"""Query with sub-question decomposition."""
# Decompose question
if self.llm:
sub_questions = self._decompose_with_llm(question)
else:
sub_questions = decompose_question(question)
# Gather context for each sub-question
all_contexts = list(context or [])
sub_answers = []
for sub_q in sub_questions:
if retriever:
# Retrieve for each sub-question
try:
results = retriever.retrieve(sub_q, top_k=3)
for r in results:
if hasattr(r, 'text'):
all_contexts.append(r.text)
elif isinstance(r, dict):
all_contexts.append(r.get('text', str(r)))
except Exception as e:
logger.warning(f"Retrieval failed for sub-question: {e}")
# Simple sub-answer (would use LLM in full implementation)
sub_answers.append(f"Sub-question: {sub_q}")
# Synthesize final answer
answer = synthesize_answer(question, all_contexts)
return QueryResult(
answer=answer,
sources=[{"text": ctx} for ctx in all_contexts[:10]],
sub_questions=sub_questions,
metadata={"mode": self.mode.value, "sub_question_count": len(sub_questions)}
)
async def aquery(
self,
question: str,
context: Optional[List[str]] = None,
**kwargs
) -> QueryResult:
"""Async version (just calls sync)."""
return self.query(question, context, **kwargs)
def _decompose_with_llm(self, question: str) -> List[str]:
"""Decompose question using LLM."""
try:
prompt = f"""Break down this complex question into simpler sub-questions that can be answered independently.
Question: {question}
Return only the sub-questions, one per line, without numbering or bullets."""
response = self.llm.chat(prompt)
if response:
lines = response.strip().split("\n")
return [line.strip() for line in lines if line.strip()]
except Exception as e:
logger.warning(f"LLM decomposition failed: {e}")
return decompose_question(question)
# Register default engines
def _register_default_engines():
"""Register default query engines."""
registry = get_query_engine_registry()
registry.register("default", SimpleQueryEngine)
registry.register("simple", SimpleQueryEngine)
registry.register("sub_question", SubQuestionEngine)
_register_default_engines()
@@ -0,0 +1,286 @@
"""
Data Reader Adapter Protocol for PraisonAI Agents.
This module defines the protocol for document readers and a registry system.
NO heavy imports - only stdlib and typing.
Implementations are provided by the wrapper layer (praisonai.adapters.readers).
"""
from dataclasses import dataclass, field
from typing import Any, Callable, Dict, List, Optional, Protocol, runtime_checkable
import os
import logging
logger = logging.getLogger(__name__)
@dataclass
class Document:
"""
Lightweight document representation.
Attributes:
content: The text content of the document
metadata: Optional metadata dict (source, filename, page, etc.)
doc_id: Optional unique identifier
embedding: Optional pre-computed embedding
"""
content: str
metadata: Dict[str, Any] = field(default_factory=dict)
doc_id: Optional[str] = None
embedding: Optional[List[float]] = None
def __post_init__(self):
if self.doc_id is None:
import uuid
self.doc_id = str(uuid.uuid4())
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
return {
"content": self.content,
"metadata": self.metadata,
"doc_id": self.doc_id,
"embedding": self.embedding
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "Document":
"""Create from dictionary."""
return cls(
content=data.get("content", ""),
metadata=data.get("metadata", {}),
doc_id=data.get("doc_id"),
embedding=data.get("embedding")
)
@runtime_checkable
class ReaderProtocol(Protocol):
"""
Protocol for document readers.
Implementations must provide:
- name: Identifier for the reader
- supported_extensions: List of file extensions this reader handles
- load(): Method to load documents from a source
"""
name: str
supported_extensions: List[str]
def load(
self,
source: str,
*,
metadata: Optional[Dict[str, Any]] = None
) -> List[Document]:
"""
Load documents from a source.
Args:
source: File path, URL, or other source identifier
metadata: Optional metadata to attach to all documents
Returns:
List of Document objects
"""
...
def can_handle(self, source: str) -> bool:
"""
Check if this reader can handle the given source.
Args:
source: File path, URL, or other source identifier
Returns:
True if this reader can handle the source
"""
...
class ReaderRegistry:
"""
Registry for document readers.
Provides lazy loading and automatic reader selection based on source type.
"""
_instance: Optional["ReaderRegistry"] = None
def __new__(cls) -> "ReaderRegistry":
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._instance._readers: Dict[str, Callable[[], ReaderProtocol]] = {}
cls._instance._extension_map: Dict[str, str] = {}
cls._instance._initialized_readers: Dict[str, ReaderProtocol] = {}
return cls._instance
def register(
self,
name: str,
factory: Callable[[], ReaderProtocol],
extensions: Optional[List[str]] = None
) -> None:
"""
Register a reader factory.
Args:
name: Unique name for the reader
factory: Callable that returns a ReaderProtocol instance
extensions: List of file extensions this reader handles
"""
self._readers[name] = factory
if extensions:
for ext in extensions:
ext_lower = ext.lower().lstrip(".")
self._extension_map[ext_lower] = name
def get(self, name: str) -> Optional[ReaderProtocol]:
"""
Get a reader by name (lazy initialization).
Args:
name: Reader name
Returns:
ReaderProtocol instance or None
"""
if name in self._initialized_readers:
return self._initialized_readers[name]
if name in self._readers:
try:
reader = self._readers[name]()
self._initialized_readers[name] = reader
return reader
except Exception as e:
logger.warning(f"Failed to initialize reader '{name}': {e}")
return None
return None
def get_for_source(self, source: str) -> Optional[ReaderProtocol]:
"""
Get the best reader for a given source.
Args:
source: File path, URL, or other source identifier
Returns:
Best matching ReaderProtocol or None
"""
source_kind = detect_source_kind(source)
if source_kind == "file":
ext = os.path.splitext(source)[1].lower().lstrip(".")
if ext in self._extension_map:
return self.get(self._extension_map[ext])
elif source_kind == "url":
# Try URL reader
if "url" in self._readers:
return self.get("url")
elif source_kind == "directory":
if "directory" in self._readers:
return self.get("directory")
# Fallback to auto reader if available
if "auto" in self._readers:
return self.get("auto")
return None
def list_readers(self) -> List[str]:
"""List all registered reader names."""
return list(self._readers.keys())
def list_extensions(self) -> Dict[str, str]:
"""List all registered extensions and their readers."""
return dict(self._extension_map)
def clear(self) -> None:
"""Clear all registered readers (mainly for testing)."""
self._readers.clear()
self._extension_map.clear()
self._initialized_readers.clear()
def get_reader_registry() -> ReaderRegistry:
"""Get the global reader registry instance."""
return ReaderRegistry()
def detect_source_kind(source: str) -> str:
"""
Detect the kind of source without importing heavy libraries.
Args:
source: File path, URL, directory, or glob pattern
Returns:
One of: "file", "url", "directory", "glob", "unknown"
"""
if not isinstance(source, str):
return "unknown"
source = source.strip()
# Check for URL
if source.startswith(("http://", "https://", "ftp://", "s3://", "gs://")):
return "url"
# Check for glob pattern
if "*" in source or "?" in source or "[" in source:
return "glob"
# Check if it's a directory
if os.path.isdir(source):
return "directory"
# Check if it's a file
if os.path.isfile(source):
return "file"
# Check if it looks like a file path (has extension)
if os.path.splitext(source)[1]:
return "file"
return "unknown"
def get_file_extension(source: str) -> str:
"""
Get the file extension from a source path.
Args:
source: File path or URL
Returns:
Lowercase extension without dot, or empty string
"""
# Handle URLs by extracting path
if source.startswith(("http://", "https://")):
from urllib.parse import urlparse
path = urlparse(source).path
return os.path.splitext(path)[1].lower().lstrip(".")
return os.path.splitext(source)[1].lower().lstrip(".")
# Default extension mappings (reader name -> extensions)
DEFAULT_EXTENSION_MAPPINGS = {
"text": ["txt", "text"],
"markdown": ["md", "markdown"],
"json": ["json", "jsonl"],
"csv": ["csv", "tsv"],
"html": ["html", "htm"],
"xml": ["xml"],
"pdf": ["pdf"],
"docx": ["docx", "doc"],
"xlsx": ["xlsx", "xls"],
"pptx": ["pptx", "ppt"],
"image": ["jpg", "jpeg", "png", "gif", "bmp", "tiff", "webp"],
"audio": ["mp3", "wav", "ogg", "m4a", "flac"],
"video": ["mp4", "avi", "mov", "mkv", "webm"],
}
@@ -0,0 +1,196 @@
"""
Reranker Protocol for PraisonAI Agents.
This module defines the protocol for rerankers and a registry system.
NO heavy imports - only stdlib and typing.
Implementations are provided by praisonai-tools or wrapper layer.
"""
from dataclasses import dataclass
from typing import Any, Callable, Dict, List, Optional, Protocol, runtime_checkable
import logging
logger = logging.getLogger(__name__)
@dataclass
class RerankResult:
"""
Result from a reranking operation.
Attributes:
text: The text content
score: Reranking score (higher is more relevant)
original_index: Original position before reranking
metadata: Optional metadata
"""
text: str
score: float
original_index: int
metadata: Dict[str, Any] = None
def __post_init__(self):
if self.metadata is None:
self.metadata = {}
@runtime_checkable
class RerankerProtocol(Protocol):
"""
Protocol for rerankers.
Implementations must provide a rerank method that takes a query
and a list of documents and returns reranked results.
"""
name: str
def rerank(
self,
query: str,
documents: List[str],
top_k: Optional[int] = None,
**kwargs
) -> List[RerankResult]:
"""
Rerank documents based on relevance to query.
Args:
query: The search query
documents: List of document texts to rerank
top_k: Optional limit on results (None = return all)
**kwargs: Additional reranker-specific options
Returns:
List of RerankResult objects sorted by relevance
"""
...
async def arerank(
self,
query: str,
documents: List[str],
top_k: Optional[int] = None,
**kwargs
) -> List[RerankResult]:
"""Async version of rerank."""
...
class RerankerRegistry:
"""
Registry for rerankers.
"""
_instance: Optional["RerankerRegistry"] = None
def __new__(cls) -> "RerankerRegistry":
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._instance._rerankers: Dict[str, Callable[..., RerankerProtocol]] = {}
return cls._instance
def register(
self,
name: str,
factory: Callable[..., RerankerProtocol]
) -> None:
"""Register a reranker factory."""
self._rerankers[name] = factory
def get(
self,
name: str,
**kwargs
) -> Optional[RerankerProtocol]:
"""Get a reranker by name."""
if name in self._rerankers:
try:
return self._rerankers[name](**kwargs)
except Exception as e:
logger.warning(f"Failed to initialize reranker '{name}': {e}")
return None
return None
def list_rerankers(self) -> List[str]:
"""List all registered reranker names."""
return list(self._rerankers.keys())
def clear(self) -> None:
"""Clear all registered rerankers."""
self._rerankers.clear()
def get_reranker_registry() -> RerankerRegistry:
"""Get the global reranker registry instance."""
return RerankerRegistry()
class SimpleReranker:
"""
Simple keyword-based reranker for testing and fallback.
Uses term frequency overlap for scoring. No external dependencies.
"""
name: str = "simple"
def __init__(self, **kwargs):
pass
def rerank(
self,
query: str,
documents: List[str],
top_k: Optional[int] = None,
**kwargs
) -> List[RerankResult]:
"""Rerank using simple term overlap."""
query_terms = set(query.lower().split())
results = []
for i, doc in enumerate(documents):
doc_terms = set(doc.lower().split())
# Calculate Jaccard similarity
if not query_terms or not doc_terms:
score = 0.0
else:
intersection = len(query_terms & doc_terms)
union = len(query_terms | doc_terms)
score = intersection / union if union > 0 else 0.0
results.append(RerankResult(
text=doc,
score=score,
original_index=i
))
# Sort by score descending
results.sort(key=lambda x: x.score, reverse=True)
if top_k is not None:
results = results[:top_k]
return results
async def arerank(
self,
query: str,
documents: List[str],
top_k: Optional[int] = None,
**kwargs
) -> List[RerankResult]:
"""Async version (just calls sync)."""
return self.rerank(query, documents, top_k, **kwargs)
# Register the simple reranker by default
def _register_default_rerankers():
"""Register default rerankers."""
registry = get_reranker_registry()
registry.register("simple", SimpleReranker)
_register_default_rerankers()
@@ -0,0 +1,285 @@
"""
Retrieval Strategy Patterns for PraisonAI Agents.
This module defines protocols and strategies for document retrieval.
NO heavy imports - only stdlib and typing.
Implementations are provided by the wrapper layer.
"""
from dataclasses import dataclass, field
from typing import Any, Callable, Dict, List, Optional, Protocol, runtime_checkable
from enum import Enum
import logging
logger = logging.getLogger(__name__)
class RetrievalStrategy(str, Enum):
"""Available retrieval strategies."""
BASIC = "basic"
FUSION = "fusion"
RECURSIVE = "recursive"
AUTO_MERGE = "auto_merge"
HYBRID = "hybrid"
@dataclass
class RetrievalResult:
"""
Result from a retrieval operation.
Attributes:
text: The retrieved text content
score: Relevance score (0-1)
metadata: Source metadata
doc_id: Document identifier
chunk_index: Index of chunk within document
"""
text: str
score: float
metadata: Dict[str, Any] = field(default_factory=dict)
doc_id: Optional[str] = None
chunk_index: Optional[int] = None
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
return {
"text": self.text,
"score": self.score,
"metadata": self.metadata,
"doc_id": self.doc_id,
"chunk_index": self.chunk_index
}
@runtime_checkable
class RetrieverProtocol(Protocol):
"""
Protocol for document retrievers.
Implementations must provide a retrieve method that takes a query
and returns relevant documents.
"""
name: str
strategy: RetrievalStrategy
def retrieve(
self,
query: str,
top_k: int = 10,
filter: Optional[Dict[str, Any]] = None,
**kwargs
) -> List[RetrievalResult]:
"""
Retrieve relevant documents for a query.
Args:
query: The search query
top_k: Maximum number of results
filter: Optional metadata filter
**kwargs: Additional retriever-specific options
Returns:
List of RetrievalResult objects sorted by relevance
"""
...
async def aretrieve(
self,
query: str,
top_k: int = 10,
filter: Optional[Dict[str, Any]] = None,
**kwargs
) -> List[RetrievalResult]:
"""Async version of retrieve."""
...
class RetrieverRegistry:
"""
Registry for retrieval strategies.
"""
_instance: Optional["RetrieverRegistry"] = None
def __new__(cls) -> "RetrieverRegistry":
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._instance._retrievers: Dict[str, Callable[..., RetrieverProtocol]] = {}
return cls._instance
def register(
self,
name: str,
factory: Callable[..., RetrieverProtocol]
) -> None:
"""Register a retriever factory."""
self._retrievers[name] = factory
def get(
self,
name: str,
**kwargs
) -> Optional[RetrieverProtocol]:
"""Get a retriever by name."""
if name in self._retrievers:
try:
return self._retrievers[name](**kwargs)
except Exception as e:
logger.warning(f"Failed to initialize retriever '{name}': {e}")
return None
return None
def list_retrievers(self) -> List[str]:
"""List all registered retriever names."""
return list(self._retrievers.keys())
def clear(self) -> None:
"""Clear all registered retrievers."""
self._retrievers.clear()
def get_retriever_registry() -> RetrieverRegistry:
"""Get the global retriever registry instance."""
return RetrieverRegistry()
def reciprocal_rank_fusion(
result_lists: List[List[RetrievalResult]],
k: int = 60
) -> List[RetrievalResult]:
"""
Reciprocal Rank Fusion (RRF) algorithm for combining multiple result lists.
Pure Python implementation with no external dependencies.
Args:
result_lists: List of result lists from different retrievers
k: RRF constant (default 60)
Returns:
Fused and re-ranked list of results
"""
# Track scores by document ID
fused_scores: Dict[str, float] = {}
doc_map: Dict[str, RetrievalResult] = {}
for results in result_lists:
for rank, result in enumerate(results):
doc_key = result.doc_id or result.text[:100] # Use text prefix as fallback key
# RRF score: 1 / (k + rank)
rrf_score = 1.0 / (k + rank + 1)
if doc_key in fused_scores:
fused_scores[doc_key] += rrf_score
else:
fused_scores[doc_key] = rrf_score
doc_map[doc_key] = result
# Sort by fused score
sorted_keys = sorted(fused_scores.keys(), key=lambda x: fused_scores[x], reverse=True)
# Build result list with updated scores
fused_results = []
for key in sorted_keys:
result = doc_map[key]
fused_results.append(RetrievalResult(
text=result.text,
score=fused_scores[key],
metadata=result.metadata,
doc_id=result.doc_id,
chunk_index=result.chunk_index
))
return fused_results
def merge_adjacent_chunks(
results: List[RetrievalResult],
max_gap: int = 1
) -> List[RetrievalResult]:
"""
Merge adjacent chunks from the same document.
Args:
results: List of retrieval results
max_gap: Maximum gap between chunk indices to merge
Returns:
Merged results
"""
if not results:
return results
# Group by document ID
doc_groups: Dict[str, List[RetrievalResult]] = {}
for result in results:
doc_id = result.doc_id or "unknown"
if doc_id not in doc_groups:
doc_groups[doc_id] = []
doc_groups[doc_id].append(result)
merged_results = []
for doc_id, chunks in doc_groups.items():
# Sort by chunk index
chunks_with_index = [c for c in chunks if c.chunk_index is not None]
chunks_without_index = [c for c in chunks if c.chunk_index is None]
if not chunks_with_index:
merged_results.extend(chunks_without_index)
continue
chunks_with_index.sort(key=lambda x: x.chunk_index or 0)
# Merge adjacent chunks
current_group = [chunks_with_index[0]]
for chunk in chunks_with_index[1:]:
last_chunk = current_group[-1]
last_idx = last_chunk.chunk_index or 0
curr_idx = chunk.chunk_index or 0
if curr_idx - last_idx <= max_gap:
current_group.append(chunk)
else:
# Merge current group and start new one
merged_results.append(_merge_chunk_group(current_group))
current_group = [chunk]
# Don't forget the last group
if current_group:
merged_results.append(_merge_chunk_group(current_group))
merged_results.extend(chunks_without_index)
# Sort by score
merged_results.sort(key=lambda x: x.score, reverse=True)
return merged_results
def _merge_chunk_group(chunks: List[RetrievalResult]) -> RetrievalResult:
"""Merge a group of chunks into a single result."""
if len(chunks) == 1:
return chunks[0]
# Combine text
merged_text = "\n".join(c.text for c in chunks)
# Average score
avg_score = sum(c.score for c in chunks) / len(chunks)
# Use first chunk's metadata
metadata = chunks[0].metadata.copy()
metadata["merged_chunks"] = len(chunks)
return RetrievalResult(
text=merged_text,
score=avg_score,
metadata=metadata,
doc_id=chunks[0].doc_id,
chunk_index=chunks[0].chunk_index
)
@@ -0,0 +1,422 @@
"""
Vector Store Adapter Protocol for PraisonAI Agents.
This module defines the protocol for vector stores and a registry system.
NO heavy imports - only stdlib and typing.
Implementations are provided by praisonai-tools or wrapper layer.
"""
from dataclasses import dataclass, field
from typing import Any, Callable, Dict, List, Optional, Protocol, runtime_checkable
import logging
logger = logging.getLogger(__name__)
@dataclass
class VectorRecord:
"""
A record in a vector store.
Attributes:
id: Unique identifier
text: The text content
embedding: The vector embedding
metadata: Optional metadata dict
score: Optional similarity score (for query results)
"""
id: str
text: str
embedding: List[float]
metadata: Dict[str, Any] = field(default_factory=dict)
score: Optional[float] = None
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
return {
"id": self.id,
"text": self.text,
"embedding": self.embedding,
"metadata": self.metadata,
"score": self.score
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "VectorRecord":
"""Create from dictionary."""
return cls(
id=data["id"],
text=data["text"],
embedding=data["embedding"],
metadata=data.get("metadata", {}),
score=data.get("score")
)
@runtime_checkable
class VectorStoreProtocol(Protocol):
"""
Protocol for vector stores.
Implementations must provide methods for:
- Adding vectors
- Querying by vector similarity
- Deleting vectors
- Getting count
"""
name: str
def add(
self,
texts: List[str],
embeddings: List[List[float]],
metadatas: Optional[List[Dict[str, Any]]] = None,
ids: Optional[List[str]] = None,
namespace: Optional[str] = None
) -> List[str]:
"""
Add vectors to the store.
Args:
texts: List of text content
embeddings: List of embedding vectors
metadatas: Optional list of metadata dicts
ids: Optional list of IDs (auto-generated if not provided)
namespace: Optional namespace for multi-tenant isolation
Returns:
List of IDs for the added vectors
"""
...
def query(
self,
embedding: List[float],
top_k: int = 10,
namespace: Optional[str] = None,
filter: Optional[Dict[str, Any]] = None
) -> List[VectorRecord]:
"""
Query vectors by similarity.
Args:
embedding: Query embedding vector
top_k: Number of results to return
namespace: Optional namespace filter
filter: Optional metadata filter
Returns:
List of VectorRecord objects sorted by similarity
"""
...
def delete(
self,
ids: Optional[List[str]] = None,
namespace: Optional[str] = None,
filter: Optional[Dict[str, Any]] = None,
delete_all: bool = False
) -> int:
"""
Delete vectors from the store.
Args:
ids: Optional list of IDs to delete
namespace: Optional namespace filter
filter: Optional metadata filter
delete_all: If True, delete all vectors
Returns:
Number of vectors deleted
"""
...
def count(self, namespace: Optional[str] = None) -> int:
"""
Get the number of vectors in the store.
Args:
namespace: Optional namespace filter
Returns:
Number of vectors
"""
...
def get(
self,
ids: List[str],
namespace: Optional[str] = None
) -> List[VectorRecord]:
"""
Get vectors by ID.
Args:
ids: List of IDs to retrieve
namespace: Optional namespace filter
Returns:
List of VectorRecord objects
"""
...
class VectorStoreRegistry:
"""
Registry for vector stores.
Provides lazy loading and configuration-based initialization.
"""
_instance: Optional["VectorStoreRegistry"] = None
def __new__(cls) -> "VectorStoreRegistry":
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._instance._stores: Dict[str, Callable[..., VectorStoreProtocol]] = {}
cls._instance._initialized_stores: Dict[str, VectorStoreProtocol] = {}
return cls._instance
def register(
self,
name: str,
factory: Callable[..., VectorStoreProtocol]
) -> None:
"""
Register a vector store factory.
Args:
name: Unique name for the store (e.g., "chroma", "pinecone")
factory: Callable that returns a VectorStoreProtocol instance
"""
self._stores[name] = factory
def get(
self,
name: str,
config: Optional[Dict[str, Any]] = None,
namespace: Optional[str] = None
) -> Optional[VectorStoreProtocol]:
"""
Get a vector store by name (lazy initialization).
Args:
name: Store name
config: Optional configuration dict
namespace: Optional namespace for multi-tenant isolation
Returns:
VectorStoreProtocol instance or None
"""
cache_key = f"{name}:{namespace or 'default'}"
if cache_key in self._initialized_stores:
return self._initialized_stores[cache_key]
if name in self._stores:
try:
store = self._stores[name](config=config, namespace=namespace)
self._initialized_stores[cache_key] = store
return store
except Exception as e:
logger.warning(f"Failed to initialize vector store '{name}': {e}")
return None
return None
def list_stores(self) -> List[str]:
"""List all registered store names."""
return list(self._stores.keys())
def clear(self) -> None:
"""Clear all registered stores (mainly for testing)."""
self._stores.clear()
self._initialized_stores.clear()
def get_vector_store_registry() -> VectorStoreRegistry:
"""Get the global vector store registry instance."""
return VectorStoreRegistry()
class InMemoryVectorStore:
"""
Pure Python in-memory vector store for testing and default usage.
Uses cosine similarity for queries. No external dependencies.
"""
name: str = "memory"
def __init__(
self,
config: Optional[Dict[str, Any]] = None,
namespace: Optional[str] = None
):
self.config = config or {}
self.default_namespace = namespace or "default"
self._data: Dict[str, Dict[str, VectorRecord]] = {} # namespace -> id -> record
def _get_namespace_data(self, namespace: Optional[str] = None) -> Dict[str, VectorRecord]:
"""Get or create namespace data."""
ns = namespace or self.default_namespace
if ns not in self._data:
self._data[ns] = {}
return self._data[ns]
def add(
self,
texts: List[str],
embeddings: List[List[float]],
metadatas: Optional[List[Dict[str, Any]]] = None,
ids: Optional[List[str]] = None,
namespace: Optional[str] = None
) -> List[str]:
"""Add vectors to the store."""
import uuid
ns_data = self._get_namespace_data(namespace)
metadatas = metadatas or [{} for _ in texts]
ids = ids or [str(uuid.uuid4()) for _ in texts]
result_ids = []
for i, (text, embedding, metadata, id_) in enumerate(zip(texts, embeddings, metadatas, ids)):
record = VectorRecord(
id=id_,
text=text,
embedding=embedding,
metadata=metadata
)
ns_data[id_] = record
result_ids.append(id_)
return result_ids
def query(
self,
embedding: List[float],
top_k: int = 10,
namespace: Optional[str] = None,
filter: Optional[Dict[str, Any]] = None
) -> List[VectorRecord]:
"""Query vectors by cosine similarity."""
ns_data = self._get_namespace_data(namespace)
if not ns_data:
return []
# Calculate cosine similarity for all records
results = []
for record in ns_data.values():
# Apply metadata filter if provided
if filter:
match = True
for key, value in filter.items():
if record.metadata.get(key) != value:
match = False
break
if not match:
continue
score = self._cosine_similarity(embedding, record.embedding)
results.append(VectorRecord(
id=record.id,
text=record.text,
embedding=record.embedding,
metadata=record.metadata,
score=score
))
# Sort by score descending and return top_k
results.sort(key=lambda x: x.score or 0, reverse=True)
return results[:top_k]
def delete(
self,
ids: Optional[List[str]] = None,
namespace: Optional[str] = None,
filter: Optional[Dict[str, Any]] = None,
delete_all: bool = False
) -> int:
"""Delete vectors from the store."""
ns = namespace or self.default_namespace
if delete_all:
if ns in self._data:
count = len(self._data[ns])
self._data[ns] = {}
return count
return 0
if ns not in self._data:
return 0
ns_data = self._data[ns]
deleted = 0
if ids:
for id_ in ids:
if id_ in ns_data:
del ns_data[id_]
deleted += 1
elif filter:
to_delete = []
for id_, record in ns_data.items():
match = True
for key, value in filter.items():
if record.metadata.get(key) != value:
match = False
break
if match:
to_delete.append(id_)
for id_ in to_delete:
del ns_data[id_]
deleted += 1
return deleted
def count(self, namespace: Optional[str] = None) -> int:
"""Get the number of vectors in the store."""
ns = namespace or self.default_namespace
if ns in self._data:
return len(self._data[ns])
return 0
def get(
self,
ids: List[str],
namespace: Optional[str] = None
) -> List[VectorRecord]:
"""Get vectors by ID."""
ns_data = self._get_namespace_data(namespace)
results = []
for id_ in ids:
if id_ in ns_data:
results.append(ns_data[id_])
return results
@staticmethod
def _cosine_similarity(a: List[float], b: List[float]) -> float:
"""Calculate cosine similarity between two vectors."""
if len(a) != len(b):
return 0.0
dot_product = sum(x * y for x, y in zip(a, b))
norm_a = sum(x * x for x in a) ** 0.5
norm_b = sum(x * x for x in b) ** 0.5
if norm_a == 0 or norm_b == 0:
return 0.0
return dot_product / (norm_a * norm_b)
# Register the in-memory store by default
def _register_default_stores():
"""Register default vector stores."""
registry = get_vector_store_registry()
registry.register("memory", InMemoryVectorStore)
_register_default_stores()
@@ -27,6 +27,7 @@ from .model_router import (
TaskComplexity,
create_routing_agent
)
from .rate_limiter import RateLimiter
__all__ = [
"LLM",
@@ -46,5 +47,6 @@ __all__ = [
"ModelRouter",
"ModelProfile",
"TaskComplexity",
"create_routing_agent"
"create_routing_agent",
"RateLimiter"
]
@@ -0,0 +1,147 @@
"""
Rate Limiter for PraisonAI Agents.
Provides token bucket rate limiting for LLM API calls.
Zero Performance Impact:
- When rate_limiter is None, no overhead
- Uses monotonic clock for accuracy
- Injectable clock/sleep for deterministic testing
Usage:
from praisonaiagents.llm import RateLimiter
from praisonaiagents import Agent
limiter = RateLimiter(requests_per_minute=60)
agent = Agent(name="Test", rate_limiter=limiter)
"""
import time
import asyncio
from dataclasses import dataclass, field
from typing import Optional, Callable
@dataclass
class RateLimiter:
"""Token bucket rate limiter for API calls.
Uses the token bucket algorithm:
- Tokens are added at a fixed rate (requests_per_minute / 60 per second)
- Each request consumes one token
- If no tokens available, wait until one is available
- Burst allows multiple requests in quick succession
Args:
requests_per_minute: Maximum requests per minute
tokens_per_minute: Optional token-based rate limiting (for future use)
burst: Maximum burst size (default: 1)
Example:
limiter = RateLimiter(requests_per_minute=60) # 1 req/sec
limiter.acquire() # Blocks if rate exceeded
# Async usage
await limiter.acquire_async()
"""
requests_per_minute: int
tokens_per_minute: Optional[int] = None
burst: int = 1
# Internal state
_tokens: float = field(default=None, init=False, repr=False)
_last_update: float = field(default=None, init=False, repr=False)
_lock: asyncio.Lock = field(default=None, init=False, repr=False)
# Injectable functions for testing
_get_time: Callable[[], float] = field(default=None, init=False, repr=False)
_sleep: Callable[[float], None] = field(default=None, init=False, repr=False)
_async_sleep: Callable[[float], None] = field(default=None, init=False, repr=False)
def __post_init__(self):
"""Initialize internal state."""
self._tokens = float(self.burst)
self._last_update = time.monotonic()
self._lock = asyncio.Lock()
# Default implementations (can be overridden for testing)
if self._get_time is None:
self._get_time = time.monotonic
if self._sleep is None:
self._sleep = time.sleep
if self._async_sleep is None:
self._async_sleep = asyncio.sleep
@property
def _rate(self) -> float:
"""Tokens per second."""
return self.requests_per_minute / 60.0
def _refill(self) -> None:
"""Refill tokens based on elapsed time."""
now = self._get_time()
elapsed = now - self._last_update
self._tokens = min(self.burst, self._tokens + elapsed * self._rate)
self._last_update = now
def _wait_time(self) -> float:
"""Calculate time to wait for next token."""
if self._tokens >= 1.0:
return 0.0
return (1.0 - self._tokens) / self._rate
def acquire(self) -> None:
"""Acquire a token, blocking if necessary.
This is the synchronous version. Use acquire_async() for async code.
"""
self._refill()
wait = self._wait_time()
if wait > 0:
self._sleep(wait)
self._refill()
self._tokens -= 1.0
async def acquire_async(self) -> None:
"""Acquire a token asynchronously, waiting if necessary.
This is the async version. Use acquire() for sync code.
"""
async with self._lock:
self._refill()
wait = self._wait_time()
if wait > 0:
await self._async_sleep(wait)
self._refill()
self._tokens -= 1.0
def try_acquire(self) -> bool:
"""Try to acquire a token without blocking.
Returns:
True if token acquired, False if rate limit exceeded
"""
self._refill()
if self._tokens >= 1.0:
self._tokens -= 1.0
return True
return False
def reset(self) -> None:
"""Reset the rate limiter to initial state."""
self._tokens = float(self.burst)
self._last_update = self._get_time()
@property
def available_tokens(self) -> float:
"""Get current available tokens (for monitoring)."""
self._refill()
return self._tokens
def __repr__(self) -> str:
return f"RateLimiter(rpm={self.requests_per_minute}, burst={self.burst})"
@@ -0,0 +1,240 @@
"""
Observability interface for PraisonAI Agents.
This module provides the protocol and types for observability/tracing.
Implementations are provided by the wrapper layer (praisonai_tools.observability).
Usage (simplest - recommended):
from praisonaiagents import Agent, obs
agent = Agent(
name="Assistant",
observability=obs.auto(), # Auto-detect from env vars
)
agent.chat("Hello!") # Auto-traces to configured provider
Alternative (explicit provider):
observability=obs.langfuse() # Langfuse
observability=obs.langsmith() # LangSmith
observability=obs.agentops() # AgentOps
observability=obs.arize() # Arize Phoenix
observability=obs.datadog() # Datadog
"""
from typing import Any, Dict, List, Optional, Protocol, runtime_checkable
from dataclasses import dataclass, field
import time
@dataclass
class Span:
"""Represents a span in a trace."""
span_id: str
trace_id: str
name: str
parent_span_id: Optional[str] = None
start_time: float = field(default_factory=time.time)
end_time: Optional[float] = None
attributes: Dict[str, Any] = field(default_factory=dict)
status: str = "ok"
@dataclass
class Trace:
"""Represents a complete trace."""
trace_id: str
session_id: Optional[str] = None
agent_name: Optional[str] = None
user_id: Optional[str] = None
start_time: float = field(default_factory=time.time)
end_time: Optional[float] = None
spans: List[Span] = field(default_factory=list)
metadata: Dict[str, Any] = field(default_factory=dict)
status: str = "ok"
@runtime_checkable
class ObservabilityAdapter(Protocol):
"""Protocol for observability adapters."""
def on_trace_start(
self,
trace_id: str,
session_id: Optional[str] = None,
agent_name: Optional[str] = None,
user_id: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> None:
"""Called when a trace starts."""
...
def on_trace_end(
self,
trace_id: str,
status: str = "ok",
metadata: Optional[Dict[str, Any]] = None,
) -> None:
"""Called when a trace ends."""
...
def on_span_start(
self,
span_id: str,
trace_id: str,
name: str,
parent_span_id: Optional[str] = None,
attributes: Optional[Dict[str, Any]] = None,
) -> None:
"""Called when a span starts."""
...
def on_span_end(
self,
span_id: str,
status: str = "ok",
attributes: Optional[Dict[str, Any]] = None,
) -> None:
"""Called when a span ends."""
...
def on_llm_call(
self,
span_id: str,
model: str,
messages: List[Dict[str, Any]],
response: Optional[str] = None,
tokens: Optional[Dict[str, int]] = None,
latency_ms: Optional[float] = None,
) -> None:
"""Called when an LLM call is made."""
...
def on_tool_call(
self,
span_id: str,
tool_name: str,
args: Dict[str, Any],
result: Any,
latency_ms: Optional[float] = None,
) -> None:
"""Called when a tool is invoked."""
...
def flush(self) -> None:
"""Flush any pending data."""
...
def close(self) -> None:
"""Close the adapter and release resources."""
...
class _LazyObsModule:
"""
Lazy proxy for observability backends.
Allows `from praisonaiagents import obs` without importing heavy deps.
Actual provider classes are loaded only when accessed.
"""
_PROVIDERS = {
"langfuse": "praisonai_tools.observability.providers.langfuse_provider",
"langsmith": "praisonai_tools.observability.providers.langsmith_provider",
"agentops": "praisonai_tools.observability.providers.agentops_provider",
"arize": "praisonai_tools.observability.providers.arize_phoenix_provider",
"phoenix": "praisonai_tools.observability.providers.arize_phoenix_provider",
"datadog": "praisonai_tools.observability.providers.datadog_provider",
"mlflow": "praisonai_tools.observability.providers.mlflow_provider",
"openlit": "praisonai_tools.observability.providers.openlit_provider",
"opik": "praisonai_tools.observability.providers.opik_provider",
"portkey": "praisonai_tools.observability.providers.portkey_provider",
"traceloop": "praisonai_tools.observability.providers.traceloop_provider",
"weave": "praisonai_tools.observability.providers.weave_provider",
"braintrust": "praisonai_tools.observability.providers.braintrust_provider",
"langtrace": "praisonai_tools.observability.providers.langtrace_provider",
"langwatch": "praisonai_tools.observability.providers.langwatch_provider",
"maxim": "praisonai_tools.observability.providers.maxim_provider",
}
def __getattr__(self, name: str):
if name in self._PROVIDERS:
return self._create_provider_factory(name)
raise AttributeError(f"module 'obs' has no attribute {name!r}")
def _create_provider_factory(self, provider_name: str):
"""Create a factory function for a provider."""
def factory(**kwargs):
try:
import importlib
module = importlib.import_module(self._PROVIDERS[provider_name])
# Get the provider class (naming convention: XxxProvider)
class_name = f"{provider_name.title().replace('_', '')}Provider"
provider_cls = getattr(module, class_name, None)
if provider_cls:
return provider_cls(**kwargs)
# Fallback: try to find any class ending with Provider
for attr_name in dir(module):
if attr_name.endswith("Provider"):
return getattr(module, attr_name)(**kwargs)
raise AttributeError(f"No provider class found in {module}")
except ImportError as e:
raise ImportError(
f"Observability provider '{provider_name}' requires praisonai-tools. "
f"Install with: pip install praisonai-tools\n"
f"Original error: {e}"
) from e
return factory
def auto(self, **kwargs):
"""
Auto-detect observability provider from environment variables.
Checks for common env vars like LANGFUSE_PUBLIC_KEY, LANGSMITH_API_KEY, etc.
Returns None if no provider is detected.
"""
import os
# Check for common provider env vars
provider_env_map = {
"LANGFUSE_PUBLIC_KEY": "langfuse",
"LANGSMITH_API_KEY": "langsmith",
"AGENTOPS_API_KEY": "agentops",
"ARIZE_API_KEY": "arize",
"DATADOG_API_KEY": "datadog",
"MLFLOW_TRACKING_URI": "mlflow",
"OPENLIT_API_KEY": "openlit",
"OPIK_API_KEY": "opik",
"PORTKEY_API_KEY": "portkey",
"TRACELOOP_API_KEY": "traceloop",
"WANDB_API_KEY": "weave",
"BRAINTRUST_API_KEY": "braintrust",
}
for env_var, provider in provider_env_map.items():
if os.getenv(env_var):
return self._create_provider_factory(provider)(**kwargs)
return None
def __repr__(self):
return "<module 'praisonaiagents.obs' (lazy observability backends)>"
def __call__(self, provider: str = "auto", **kwargs):
"""
Shortcut: obs("langfuse") or obs() for auto-detection.
"""
if provider == "auto":
return self.auto(**kwargs)
return self._create_provider_factory(provider)(**kwargs)
# Singleton lazy module instance
obs = _LazyObsModule()
__all__ = [
"ObservabilityAdapter",
"Span",
"Trace",
"obs",
]
@@ -4,16 +4,20 @@ Session Management for PraisonAI Agents
A simple wrapper around existing stateful capabilities to provide a unified
session API for developers building stateful agent applications.
"""
from __future__ import annotations
import os
import uuid
import requests
import json
import time
from typing import Any, Dict, List, Optional
from typing import Any, Dict, List, Optional, TYPE_CHECKING
if TYPE_CHECKING:
from .memory.memory import Memory
from .knowledge.knowledge import Knowledge
from .agent import Agent
from .memory import Memory
from .knowledge import Knowledge
class Session:
@@ -112,6 +116,7 @@ class Session:
if self.is_remote:
raise ValueError("Memory operations are not available for remote agent sessions")
if self._memory is None:
from .memory.memory import Memory
self._memory = Memory(config=self.memory_config)
return self._memory
@@ -121,6 +126,7 @@ class Session:
if self.is_remote:
raise ValueError("Knowledge operations are not available for remote agent sessions")
if self._knowledge is None:
from .knowledge.knowledge import Knowledge
self._knowledge = Knowledge(config=self.knowledge_config)
return self._knowledge
@@ -2,6 +2,9 @@
from importlib import import_module
from typing import Any
# Export Injected type for tool state injection
from .injected import Injected
# Map of function names to their module and class (if any)
TOOL_MAPPINGS = {
# Direct functions
@@ -286,4 +289,4 @@ def __getattr__(name: str) -> Any:
method = getattr(_instances[class_name], name)
return method
__all__ = list(TOOL_MAPPINGS.keys())
__all__ = list(TOOL_MAPPINGS.keys()) + ['Injected']
@@ -14,6 +14,14 @@ Usage:
@tool(name="web_search", description="Search the internet")
def search(query: str, max_results: int = 5) -> list:
return [...]
# With injected state:
from praisonaiagents.tools import Injected
@tool
def my_tool(query: str, state: Injected[dict]) -> str:
'''Tool with injected state.'''
return f"session={state.get('session_id')}"
"""
import inspect
@@ -22,12 +30,19 @@ import logging
from typing import Any, Callable, Dict, Optional, Union, get_type_hints
from .base import BaseTool
from .injected import (
is_injected_type,
get_injected_params,
inject_state_into_kwargs,
filter_injected_from_schema
)
class FunctionTool(BaseTool):
"""A BaseTool wrapper for plain functions.
Created automatically by the @tool decorator.
Supports Injected[T] parameters for state injection.
"""
def __init__(
@@ -42,6 +57,9 @@ class FunctionTool(BaseTool):
self.description = description or func.__doc__ or f"Tool: {self.name}"
self.version = version
# Detect injected parameters
self._injected_params = get_injected_params(func)
# Generate schema from the original function, not run()
self.parameters = self._generate_schema_from_func(func)
@@ -55,8 +73,16 @@ class FunctionTool(BaseTool):
if not self.description:
self.description = self.__class__.__doc__ or f"Tool: {self.name}"
@property
def injected_params(self) -> Dict[str, Any]:
"""Get the injected parameters for this tool."""
return self._injected_params
def _generate_schema_from_func(self, func: Callable) -> Dict[str, Any]:
"""Generate JSON Schema from the wrapped function's signature."""
"""Generate JSON Schema from the wrapped function's signature.
Injected parameters are excluded from the schema.
"""
schema = {
"type": "object",
"properties": {},
@@ -71,8 +97,17 @@ class FunctionTool(BaseTool):
if param_name in ('self', 'cls'):
continue
# Skip injected parameters - they don't go in schema
if param_name in self._injected_params:
continue
# Get type hint
param_type = hints.get(param_name, Any)
# Double-check it's not an Injected type
if is_injected_type(param_type):
continue
json_type = BaseTool._python_type_to_json(param_type)
schema["properties"][param_name] = {"type": json_type}
@@ -86,11 +121,18 @@ class FunctionTool(BaseTool):
return schema
def run(self, **kwargs) -> Any:
"""Execute the wrapped function."""
"""Execute the wrapped function with injected state."""
# Inject state for any Injected parameters
kwargs = inject_state_into_kwargs(kwargs, self._injected_params)
return self._func(**kwargs)
def __call__(self, *args, **kwargs) -> Any:
"""Allow calling with positional args like the original function."""
"""Allow calling with positional args like the original function.
Injects state for Injected parameters.
"""
# Inject state for any Injected parameters
kwargs = inject_state_into_kwargs(kwargs, self._injected_params)
return self._func(*args, **kwargs)
@@ -0,0 +1,213 @@
"""
Injected State for PraisonAI Tools.
Provides a mechanism for tools to receive injected state (agent context)
without exposing it in the tool's public schema.
Zero Performance Impact:
- When no Injected params, no overhead
- Context is thread-local for safety
- Lazy evaluation of state
Usage:
from praisonaiagents import tool
from praisonaiagents.tools import Injected
@tool
def my_tool(query: str, state: Injected[dict]) -> str:
'''Tool with injected state.'''
session_id = state.get('session_id')
return f"Query: {query}, Session: {session_id}"
"""
from dataclasses import dataclass, field
from typing import Any, Dict, Generic, List, Optional, TypeVar, get_type_hints, get_origin, get_args
from contextvars import ContextVar
from contextlib import contextmanager
T = TypeVar('T')
class Injected(Generic[T]):
"""Type marker for injected parameters.
Parameters annotated with Injected[T] will:
- Not appear in the tool's public schema
- Be automatically injected at runtime with the current agent state
Supported types:
- Injected[dict] - Receives state as a dictionary
- Injected[AgentState] - Receives state as AgentState dataclass
Example:
@tool
def my_tool(query: str, state: Injected[dict]) -> str:
return f"session={state.get('session_id')}"
"""
pass
@dataclass
class AgentState:
"""State object injected into tools.
Contains context about the current agent execution.
"""
agent_id: str
run_id: str
session_id: str
last_user_message: Optional[str] = None
last_agent_message: Optional[str] = None
memory: Optional[Any] = None
previous_tool_results: List[Any] = field(default_factory=list)
metadata: Dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary for Injected[dict] usage."""
return {
'agent_id': self.agent_id,
'run_id': self.run_id,
'session_id': self.session_id,
'last_user_message': self.last_user_message,
'last_agent_message': self.last_agent_message,
'previous_tool_results': self.previous_tool_results,
'metadata': self.metadata,
}
# Thread-local context for current state
_current_state: ContextVar[Optional[AgentState]] = ContextVar('current_state', default=None)
def get_current_state() -> Optional[AgentState]:
"""Get the current injection state."""
return _current_state.get()
def set_current_state(state: Optional[AgentState]) -> None:
"""Set the current injection state."""
_current_state.set(state)
@contextmanager
def with_injection_context(state: AgentState):
"""Context manager for setting injection state.
Usage:
with with_injection_context(state):
result = my_tool(query="test")
"""
token = _current_state.set(state)
try:
yield
finally:
_current_state.reset(token)
def is_injected_type(annotation: Any) -> bool:
"""Check if a type annotation is Injected[T]."""
origin = get_origin(annotation)
if origin is Injected:
return True
# Handle string annotations
if isinstance(annotation, str) and 'Injected' in annotation:
return True
return False
def get_injected_type(annotation: Any) -> Optional[type]:
"""Get the inner type T from Injected[T]."""
origin = get_origin(annotation)
if origin is Injected:
args = get_args(annotation)
if args:
return args[0]
return None
def resolve_injected_value(annotation: Any, state: Optional[AgentState]) -> Any:
"""Resolve the value to inject based on annotation type.
Args:
annotation: The Injected[T] annotation
state: The current agent state
Returns:
The resolved value (dict or AgentState)
"""
if state is None:
# Return empty dict if no state available
return {}
inner_type = get_injected_type(annotation)
if inner_type is dict or inner_type is Dict:
return state.to_dict()
elif inner_type is AgentState:
return state
else:
# Default to dict
return state.to_dict()
def get_injected_params(func) -> Dict[str, Any]:
"""Get all injected parameters from a function.
Returns:
Dict mapping param name to its Injected[T] annotation
"""
try:
hints = get_type_hints(func)
except Exception:
# If we can't get hints, return empty
return {}
injected = {}
for name, hint in hints.items():
if name == 'return':
continue
if is_injected_type(hint):
injected[name] = hint
return injected
def filter_injected_from_schema(properties: Dict[str, Any], required: List[str],
injected_params: Dict[str, Any]) -> tuple:
"""Remove injected parameters from schema.
Args:
properties: Schema properties dict
required: List of required param names
injected_params: Dict of injected param names
Returns:
Tuple of (filtered_properties, filtered_required)
"""
filtered_props = {k: v for k, v in properties.items() if k not in injected_params}
filtered_required = [r for r in required if r not in injected_params]
return filtered_props, filtered_required
def inject_state_into_kwargs(kwargs: Dict[str, Any], injected_params: Dict[str, Any]) -> Dict[str, Any]:
"""Inject state values into kwargs for injected parameters.
Args:
kwargs: The original kwargs
injected_params: Dict of injected param names to annotations
Returns:
Updated kwargs with injected values
"""
if not injected_params:
return kwargs
state = get_current_state()
result = kwargs.copy()
for param_name, annotation in injected_params.items():
if param_name not in result:
result[param_name] = resolve_injected_value(annotation, state)
return result
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "praisonaiagents"
version = "0.2.1"
version = "0.3.2"
description = "Praison AI agents for completing complex tasks with Self Reflection Agents"
readme = "README.md"
requires-python = ">=3.10"
+7 -7
View File
@@ -672,16 +672,16 @@ class TestPlanningAgent:
planner = PlanningAgent()
assert planner.llm_model == "gpt-4o-mini"
assert planner.llm_model in ["gpt-4o-mini", "gpt-5-nano"]
assert planner.read_only is True
def test_planning_agent_custom_llm(self):
"""Test PlanningAgent with custom LLM."""
from praisonaiagents.planning import PlanningAgent
planner = PlanningAgent(llm="gpt-4o")
planner = PlanningAgent(llm="gpt-5-nano")
assert planner.llm_model == "gpt-4o"
assert planner.llm_model == "gpt-5-nano"
def test_planning_agent_allowed_tools(self):
"""Test PlanningAgent has only read-only tools."""
@@ -840,10 +840,10 @@ class TestPraisonAIAgentsPlanningIntegration:
agents=[agent],
tasks=[task],
planning=True,
planning_llm="gpt-4o"
planning_llm="gpt-5-nano"
)
assert agents.planning_llm == "gpt-4o"
assert agents.planning_llm == "gpt-5-nano"
def test_agents_auto_approve_plan(self):
"""Test auto-approve plan option."""
@@ -867,7 +867,7 @@ class TestPraisonAIAgentsPlanningIntegration:
from praisonaiagents import Agent, Task, PraisonAIAgents
from praisonaiagents.planning import Plan, PlanStep
agent = Agent(name="Test", role="Tester", llm="gpt-4o-mini")
agent = Agent(name="Test", role="Tester", llm="gpt-5-nano")
task = Task(description="Test task", agent=agent)
agents = PraisonAIAgents(
@@ -880,7 +880,7 @@ class TestPraisonAIAgentsPlanningIntegration:
# Test that planning is enabled and configured correctly
assert agents.planning is True
assert agents.auto_approve_plan is True
assert agents.planning_llm == "gpt-4o-mini"
assert agents.planning_llm == "gpt-5-nano"
assert agents._current_plan is None # No plan created yet
assert agents._todo_list is None
@@ -0,0 +1,234 @@
"""
TDD Tests for Configurable Model (runtime model switching).
These tests are written FIRST before implementation.
"""
import pytest
from unittest.mock import Mock, patch
class TestConfigurableModelBasic:
"""Test basic configurable model functionality."""
def test_agent_accepts_llm_config_configurable(self):
"""Agent should accept llm_config with configurable=True."""
from praisonaiagents import Agent
agent = Agent(
name="Test",
instructions="Test agent",
llm="gpt-4o",
llm_config={"configurable": True}
)
assert agent is not None
assert getattr(agent, '_llm_configurable', False) is True
def test_agent_chat_accepts_config_parameter(self):
"""Agent.chat() should accept config parameter for model override."""
from praisonaiagents import Agent
agent = Agent(
name="Test",
instructions="Test agent",
llm="gpt-4o",
llm_config={"configurable": True}
)
# Should not raise - config parameter should be accepted
# We'll mock the actual LLM call
with patch.object(agent, '_chat_completion', return_value="mocked"):
result = agent.chat(
"Hello",
config={"model": "claude-3-5-sonnet"}
)
def test_config_model_override(self):
"""config.model should override default model for that call."""
from praisonaiagents import Agent
agent = Agent(
name="Test",
instructions="Test agent",
llm="gpt-4o",
llm_config={"configurable": True}
)
# Track which model was used
used_model = [None]
original_chat = agent._chat_completion
def mock_chat(*args, **kwargs):
# The model should be accessible somehow
used_model[0] = getattr(agent, '_current_model', agent.llm)
return "response"
with patch.object(agent, '_chat_completion', side_effect=mock_chat):
agent.chat("Hello", config={"model": "claude-3-5-sonnet"})
# Model should have been switched for this call
# Implementation will set _current_model or similar
def test_config_does_not_mutate_default(self):
"""Per-call config should not change agent's default model."""
from praisonaiagents import Agent
agent = Agent(
name="Test",
instructions="Test agent",
llm="gpt-4o",
llm_config={"configurable": True}
)
original_llm = agent.llm
with patch.object(agent, '_chat_completion', return_value="mocked"):
agent.chat("Hello", config={"model": "claude-3-5-sonnet"})
# Default should be unchanged
assert agent.llm == original_llm
def test_config_temperature_override(self):
"""config.temperature should override for that call."""
from praisonaiagents import Agent
agent = Agent(
name="Test",
instructions="Test agent",
llm="gpt-4o",
llm_config={"configurable": True}
)
# Should accept temperature in config
with patch.object(agent, '_chat_completion', return_value="mocked") as mock:
agent.chat("Hello", config={"temperature": 0.2})
# Temperature should be passed through
class TestConfigurableModelCaching:
"""Test model client caching for configurable models."""
def test_model_client_cached(self):
"""Model clients should be cached to avoid recreation."""
from praisonaiagents.llm import LLM
llm = LLM(model="gpt-4o", configurable=True)
# First call with model A
# Second call with model A should reuse client
# This is internal behavior - we test via call count or similar
def test_cache_size_limited(self):
"""Model client cache should have a size limit."""
from praisonaiagents.llm import LLM
llm = LLM(model="gpt-4o", configurable=True, cache_size=3)
# Should not grow unbounded
class TestConfigurableModelThreadSafety:
"""Test thread safety of configurable models."""
def test_concurrent_calls_different_models(self):
"""Concurrent calls with different models should not interfere."""
import threading
from praisonaiagents import Agent
agent = Agent(
name="Test",
instructions="Test agent",
llm="gpt-4o",
llm_config={"configurable": True}
)
results = {}
errors = []
def call_with_model(model_name, thread_id):
try:
with patch.object(agent, '_chat_completion', return_value=f"response_{model_name}"):
result = agent.chat("Hello", config={"model": model_name})
results[thread_id] = result
except Exception as e:
errors.append(e)
threads = [
threading.Thread(target=call_with_model, args=("model_a", 1)),
threading.Thread(target=call_with_model, args=("model_b", 2)),
]
for t in threads:
t.start()
for t in threads:
t.join()
assert len(errors) == 0
class TestConfigurableModelValidation:
"""Test validation of configurable model settings."""
def test_config_without_configurable_flag_ignored(self):
"""If llm_config.configurable is False, config param should be ignored."""
from praisonaiagents import Agent
agent = Agent(
name="Test",
instructions="Test agent",
llm="gpt-4o"
# No llm_config or configurable=False
)
# Should still work, just ignore the config
with patch.object(agent, '_chat_completion', return_value="mocked"):
result = agent.chat("Hello", config={"model": "other"})
# Should use default model, not "other"
def test_invalid_config_keys_ignored(self):
"""Unknown keys in config should be ignored, not error."""
from praisonaiagents import Agent
agent = Agent(
name="Test",
instructions="Test agent",
llm="gpt-4o",
llm_config={"configurable": True}
)
# Should not raise for unknown keys
with patch.object(agent, '_chat_completion', return_value="mocked"):
result = agent.chat("Hello", config={"unknown_key": "value"})
class TestConfigurableModelProvider:
"""Test provider switching in configurable models."""
def test_config_provider_override(self):
"""config.provider should allow switching providers."""
from praisonaiagents import Agent
agent = Agent(
name="Test",
instructions="Test agent",
llm="gpt-4o",
llm_config={"configurable": True}
)
# Should accept provider in config
with patch.object(agent, '_chat_completion', return_value="mocked"):
agent.chat("Hello", config={"provider": "anthropic", "model": "claude-3-5-sonnet"})
def test_model_string_with_provider_prefix(self):
"""Model string like 'anthropic/claude-3' should work."""
from praisonaiagents import Agent
agent = Agent(
name="Test",
instructions="Test agent",
llm="gpt-4o",
llm_config={"configurable": True}
)
with patch.object(agent, '_chat_completion', return_value="mocked"):
agent.chat("Hello", config={"model": "anthropic/claude-3-5-sonnet"})
@@ -0,0 +1,367 @@
"""
TDD Tests for InjectedState for Tools.
These tests are written FIRST before implementation.
"""
import pytest
from unittest.mock import Mock, patch
class TestInjectedTypeExists:
"""Test that Injected type marker exists."""
def test_injected_type_exists(self):
"""Injected type should exist in praisonaiagents.tools."""
from praisonaiagents.tools import Injected
# Should be a generic type marker
assert Injected is not None
def test_injected_generic_usage(self):
"""Injected should work as a generic type annotation."""
from praisonaiagents.tools import Injected
# Should be usable as Injected[dict], Injected[SomeType], etc.
def my_tool(query: str, state: Injected[dict]) -> str:
return query
# Should have annotations
annotations = my_tool.__annotations__
assert 'state' in annotations
def test_injected_with_agent_state(self):
"""Injected[AgentState] should work."""
from praisonaiagents.tools import Injected
from praisonaiagents.tools.injected import AgentState
def my_tool(query: str, state: Injected[AgentState]) -> str:
return query
annotations = my_tool.__annotations__
assert 'state' in annotations
class TestAgentStateType:
"""Test AgentState type for injection."""
def test_agent_state_exists(self):
"""AgentState type should exist."""
from praisonaiagents.tools.injected import AgentState
assert AgentState is not None
def test_agent_state_has_required_fields(self):
"""AgentState should have agent_id, run_id, session_id."""
from praisonaiagents.tools.injected import AgentState
state = AgentState(
agent_id="agent-123",
run_id="run-456",
session_id="session-789"
)
assert state.agent_id == "agent-123"
assert state.run_id == "run-456"
assert state.session_id == "session-789"
def test_agent_state_optional_fields(self):
"""AgentState should have optional fields."""
from praisonaiagents.tools.injected import AgentState
state = AgentState(
agent_id="a",
run_id="r",
session_id="s",
last_user_message="Hello",
last_agent_message="Hi there",
memory=Mock(),
previous_tool_results=["result1", "result2"]
)
assert state.last_user_message == "Hello"
assert state.last_agent_message == "Hi there"
assert state.memory is not None
assert len(state.previous_tool_results) == 2
class TestToolDecoratorInjection:
"""Test @tool decorator handles Injected parameters."""
def test_tool_decorator_detects_injected(self):
"""@tool should detect Injected parameters."""
from praisonaiagents import tool
from praisonaiagents.tools import Injected
@tool
def my_tool(query: str, state: Injected[dict]) -> str:
"""A tool with injected state."""
return f"query={query}, session={state.get('session_id')}"
# Should have metadata about injected params
assert hasattr(my_tool, '_injected_params') or hasattr(my_tool, 'injected_params')
def test_injected_params_not_in_schema(self):
"""Injected parameters should not appear in tool schema."""
from praisonaiagents import tool
from praisonaiagents.tools import Injected
@tool
def my_tool(query: str, state: Injected[dict]) -> str:
"""A tool with injected state."""
return query
schema = my_tool.get_schema()
params = schema['function']['parameters']['properties']
# 'query' should be in schema
assert 'query' in params
# 'state' should NOT be in schema (it's injected)
assert 'state' not in params
def test_injected_params_not_required(self):
"""Injected parameters should not be in required list."""
from praisonaiagents import tool
from praisonaiagents.tools import Injected
@tool
def my_tool(query: str, state: Injected[dict]) -> str:
"""A tool with injected state."""
return query
schema = my_tool.get_schema()
required = schema['function']['parameters'].get('required', [])
assert 'state' not in required
assert 'query' in required
class TestToolExecutionInjection:
"""Test that injected state is provided at runtime."""
def test_injected_state_provided_at_runtime(self):
"""When tool is called, injected state should be provided."""
from praisonaiagents import tool
from praisonaiagents.tools import Injected
from praisonaiagents.tools.injected import AgentState
received_state = [None]
@tool
def my_tool(query: str, state: Injected[AgentState]) -> str:
"""A tool with injected state."""
received_state[0] = state
return f"session={state.session_id}"
# Simulate execution with injection context
from praisonaiagents.tools.injected import with_injection_context
mock_state = AgentState(
agent_id="a",
run_id="r",
session_id="test-session"
)
with with_injection_context(mock_state):
result = my_tool(query="hello")
assert received_state[0] is not None
assert received_state[0].session_id == "test-session"
def test_injected_dict_state(self):
"""Injected[dict] should provide dict with state info."""
from praisonaiagents import tool
from praisonaiagents.tools import Injected
from praisonaiagents.tools.injected import with_injection_context, AgentState
received_state = [None]
@tool
def my_tool(query: str, state: Injected[dict]) -> str:
"""A tool with injected dict state."""
received_state[0] = state
return "ok"
mock_state = AgentState(
agent_id="a",
run_id="r",
session_id="s"
)
with with_injection_context(mock_state):
my_tool(query="test")
assert isinstance(received_state[0], dict)
assert 'session_id' in received_state[0]
class TestAgentToolExecution:
"""Test Agent executes tools with injection."""
def test_agent_injects_state_to_tools(self):
"""Agent should inject state when executing tools."""
from praisonaiagents import Agent, tool
from praisonaiagents.tools import Injected
received_session = [None]
@tool
def show_state(query: str, state: Injected[dict]) -> str:
"""Show the injected state."""
received_session[0] = state.get('session_id')
return f"session={state.get('session_id')}"
agent = Agent(
name="Test",
instructions="Test agent",
tools=[show_state],
session_id="my-session-123"
)
# Execute the tool directly through agent
result = agent.execute_tool("show_state", {"query": "test"})
# State should have been injected
assert received_session[0] == "my-session-123"
class TestInjectionContextManager:
"""Test injection context manager."""
def test_with_injection_context_exists(self):
"""with_injection_context should exist."""
from praisonaiagents.tools.injected import with_injection_context
assert with_injection_context is not None
def test_context_manager_sets_state(self):
"""Context manager should set current state."""
from praisonaiagents.tools.injected import (
with_injection_context,
get_current_state,
AgentState
)
state = AgentState(agent_id="a", run_id="r", session_id="s")
with with_injection_context(state):
current = get_current_state()
assert current is state
# After context, should be None
assert get_current_state() is None
def test_nested_contexts(self):
"""Nested contexts should work correctly."""
from praisonaiagents.tools.injected import (
with_injection_context,
get_current_state,
AgentState
)
state1 = AgentState(agent_id="a1", run_id="r1", session_id="s1")
state2 = AgentState(agent_id="a2", run_id="r2", session_id="s2")
with with_injection_context(state1):
assert get_current_state().agent_id == "a1"
with with_injection_context(state2):
assert get_current_state().agent_id == "a2"
# Back to state1
assert get_current_state().agent_id == "a1"
class TestAsyncInjection:
"""Test async tool injection."""
@pytest.mark.asyncio
async def test_async_tool_injection(self):
"""Async tools should also receive injected state."""
from praisonaiagents import tool
from praisonaiagents.tools import Injected
from praisonaiagents.tools.injected import with_injection_context, AgentState
received_state = [None]
@tool
async def async_tool(query: str, state: Injected[dict]) -> str:
"""Async tool with injected state."""
received_state[0] = state
return "ok"
state = AgentState(agent_id="a", run_id="r", session_id="async-session")
with with_injection_context(state):
await async_tool(query="test")
assert received_state[0] is not None
assert received_state[0].get('session_id') == "async-session"
class TestInjectionZeroOverhead:
"""Test that injection has zero overhead when not used."""
def test_no_injected_params_no_overhead(self):
"""Tools without Injected params should have no overhead."""
from praisonaiagents import tool
@tool
def simple_tool(query: str) -> str:
"""Simple tool without injection."""
return query
# Should work normally without any injection context
result = simple_tool(query="hello")
assert result == "hello"
def test_schema_generation_fast(self):
"""Schema generation should be fast even with Injected params."""
from praisonaiagents import tool
from praisonaiagents.tools import Injected
import time
@tool
def my_tool(a: str, b: int, c: Injected[dict], d: float) -> str:
"""Tool with mixed params."""
return a
start = time.monotonic()
for _ in range(100):
my_tool.get_schema()
elapsed = time.monotonic() - start
# Should be very fast
assert elapsed < 0.1 # 100 calls in < 100ms
class TestBackwardCompatibility:
"""Test backward compatibility with existing tools."""
def test_existing_tools_still_work(self):
"""Existing tools without Injected should continue to work."""
from praisonaiagents import tool
@tool
def legacy_tool(query: str, count: int = 5) -> str:
"""Legacy tool."""
return f"{query} x {count}"
result = legacy_tool(query="test", count=3)
assert result == "test x 3"
def test_existing_tool_schema_unchanged(self):
"""Existing tool schemas should be unchanged."""
from praisonaiagents import tool
@tool
def legacy_tool(query: str, count: int = 5) -> str:
"""Legacy tool."""
return f"{query} x {count}"
schema = legacy_tool.get_schema()
params = schema['function']['parameters']['properties']
assert 'query' in params
assert 'count' in params
assert len(params) == 2
@@ -0,0 +1,403 @@
"""
TDD Tests for Middleware System (wrap_model_call, wrap_tool_call).
These tests are written FIRST before implementation.
"""
import pytest
from unittest.mock import Mock, MagicMock, patch, AsyncMock
from dataclasses import dataclass
from typing import Any, Dict, Optional
class TestMiddlewareTypes:
"""Test middleware type definitions."""
def test_invocation_context_exists(self):
"""InvocationContext dataclass should exist with required fields."""
from praisonaiagents.hooks.middleware import InvocationContext
ctx = InvocationContext(
agent_id="agent-123",
run_id="run-456",
session_id="session-789"
)
assert ctx.agent_id == "agent-123"
assert ctx.run_id == "run-456"
assert ctx.session_id == "session-789"
def test_invocation_context_optional_fields(self):
"""InvocationContext should have optional tool_name, model_name, metadata."""
from praisonaiagents.hooks.middleware import InvocationContext
ctx = InvocationContext(
agent_id="a",
run_id="r",
session_id="s",
tool_name="my_tool",
model_name="gpt-4o",
metadata={"key": "value"}
)
assert ctx.tool_name == "my_tool"
assert ctx.model_name == "gpt-4o"
assert ctx.metadata == {"key": "value"}
def test_model_request_exists(self):
"""ModelRequest dataclass for before_model hooks."""
from praisonaiagents.hooks.middleware import ModelRequest
req = ModelRequest(
messages=[{"role": "user", "content": "Hello"}],
model="gpt-4o",
temperature=0.7,
context=Mock()
)
assert req.messages == [{"role": "user", "content": "Hello"}]
assert req.model == "gpt-4o"
assert req.temperature == 0.7
def test_model_response_exists(self):
"""ModelResponse dataclass for after_model hooks."""
from praisonaiagents.hooks.middleware import ModelResponse
resp = ModelResponse(
content="Hello back!",
model="gpt-4o",
usage={"prompt_tokens": 10, "completion_tokens": 5},
context=Mock()
)
assert resp.content == "Hello back!"
assert resp.model == "gpt-4o"
def test_tool_request_exists(self):
"""ToolRequest dataclass for before_tool hooks."""
from praisonaiagents.hooks.middleware import ToolRequest
req = ToolRequest(
tool_name="get_weather",
arguments={"city": "Paris"},
context=Mock()
)
assert req.tool_name == "get_weather"
assert req.arguments == {"city": "Paris"}
def test_tool_response_exists(self):
"""ToolResponse dataclass for after_tool hooks."""
from praisonaiagents.hooks.middleware import ToolResponse
resp = ToolResponse(
tool_name="get_weather",
result="Sunny, 22°C",
error=None,
context=Mock()
)
assert resp.result == "Sunny, 22°C"
assert resp.error is None
class TestMiddlewareDecorators:
"""Test middleware decorator functions."""
def test_before_model_decorator_exists(self):
"""before_model decorator should exist and tag functions."""
from praisonaiagents.hooks import before_model
@before_model
def my_hook(request):
return request
assert hasattr(my_hook, '_hook_type')
assert my_hook._hook_type == 'before_model'
def test_after_model_decorator_exists(self):
"""after_model decorator should exist and tag functions."""
from praisonaiagents.hooks import after_model
@after_model
def my_hook(response):
return response
assert hasattr(my_hook, '_hook_type')
assert my_hook._hook_type == 'after_model'
def test_wrap_model_call_decorator_exists(self):
"""wrap_model_call decorator should exist and tag functions."""
from praisonaiagents.hooks import wrap_model_call
@wrap_model_call
def my_middleware(request, call_next):
return call_next(request)
assert hasattr(my_middleware, '_hook_type')
assert my_middleware._hook_type == 'wrap_model_call'
def test_before_tool_decorator_exists(self):
"""before_tool decorator should exist and tag functions."""
from praisonaiagents.hooks import before_tool
@before_tool
def my_hook(request):
return request
assert hasattr(my_hook, '_hook_type')
assert my_hook._hook_type == 'before_tool'
def test_after_tool_decorator_exists(self):
"""after_tool decorator should exist and tag functions."""
from praisonaiagents.hooks import after_tool
@after_tool
def my_hook(response):
return response
assert hasattr(my_hook, '_hook_type')
assert my_hook._hook_type == 'after_tool'
def test_wrap_tool_call_decorator_exists(self):
"""wrap_tool_call decorator should exist and tag functions."""
from praisonaiagents.hooks import wrap_tool_call
@wrap_tool_call
def my_middleware(request, call_next):
return call_next(request)
assert hasattr(my_middleware, '_hook_type')
assert my_middleware._hook_type == 'wrap_tool_call'
class TestMiddlewareChaining:
"""Test middleware chain composition."""
def test_wrap_model_call_chain_order(self):
"""wrap_model_call hooks should compose in registration order."""
from praisonaiagents.hooks.middleware import MiddlewareChain
calls = []
def mw1(req, call_next):
calls.append("mw1_before")
result = call_next(req)
calls.append("mw1_after")
return result
def mw2(req, call_next):
calls.append("mw2_before")
result = call_next(req)
calls.append("mw2_after")
return result
def final_handler(req):
calls.append("final")
return "response"
chain = MiddlewareChain([mw1, mw2])
result = chain.execute({"test": True}, final_handler)
assert calls == ["mw1_before", "mw2_before", "final", "mw2_after", "mw1_after"]
assert result == "response"
def test_empty_middleware_chain(self):
"""Empty middleware chain should just call final handler."""
from praisonaiagents.hooks.middleware import MiddlewareChain
def final_handler(req):
return "direct"
chain = MiddlewareChain([])
result = chain.execute({"test": True}, final_handler)
assert result == "direct"
def test_middleware_can_modify_request(self):
"""Middleware should be able to modify request before passing on."""
from praisonaiagents.hooks.middleware import MiddlewareChain
def add_context(req, call_next):
req["extra"] = "added"
return call_next(req)
def final_handler(req):
return req.get("extra")
chain = MiddlewareChain([add_context])
result = chain.execute({"original": True}, final_handler)
assert result == "added"
def test_middleware_can_modify_response(self):
"""Middleware should be able to modify response after call_next."""
from praisonaiagents.hooks.middleware import MiddlewareChain
def uppercase_response(req, call_next):
result = call_next(req)
return result.upper()
def final_handler(req):
return "hello"
chain = MiddlewareChain([uppercase_response])
result = chain.execute({}, final_handler)
assert result == "HELLO"
def test_middleware_can_short_circuit(self):
"""Middleware can return early without calling call_next."""
from praisonaiagents.hooks.middleware import MiddlewareChain
def block_middleware(req, call_next):
if req.get("block"):
return "blocked"
return call_next(req)
def final_handler(req):
return "reached"
chain = MiddlewareChain([block_middleware])
# Should be blocked
result = chain.execute({"block": True}, final_handler)
assert result == "blocked"
# Should pass through
result = chain.execute({"block": False}, final_handler)
assert result == "reached"
class TestMiddlewareRetry:
"""Test retry logic in middleware."""
def test_wrap_tool_call_retry_on_error(self):
"""wrap_tool_call should support retry patterns."""
from praisonaiagents.hooks.middleware import MiddlewareChain
attempt_count = [0]
def retry_middleware(req, call_next):
last_error = None
for i in range(3):
try:
return call_next(req)
except Exception as e:
last_error = e
raise last_error
def flaky_handler(req):
attempt_count[0] += 1
if attempt_count[0] < 3:
raise RuntimeError("flaky")
return "success"
chain = MiddlewareChain([retry_middleware])
result = chain.execute({}, flaky_handler)
assert result == "success"
assert attempt_count[0] == 3
class TestAsyncMiddleware:
"""Test async middleware support."""
@pytest.mark.asyncio
async def test_async_wrap_model_call(self):
"""Async middleware should work with async handlers."""
from praisonaiagents.hooks.middleware import AsyncMiddlewareChain
calls = []
async def async_mw(req, call_next):
calls.append("before")
result = await call_next(req)
calls.append("after")
return result
async def async_handler(req):
calls.append("handler")
return "async_result"
chain = AsyncMiddlewareChain([async_mw])
result = await chain.execute({}, async_handler)
assert calls == ["before", "handler", "after"]
assert result == "async_result"
@pytest.mark.asyncio
async def test_async_middleware_chain_order(self):
"""Async middleware should maintain order."""
from praisonaiagents.hooks.middleware import AsyncMiddlewareChain
order = []
async def mw1(req, call_next):
order.append(1)
result = await call_next(req)
order.append(-1)
return result
async def mw2(req, call_next):
order.append(2)
result = await call_next(req)
order.append(-2)
return result
async def handler(req):
order.append(0)
return "done"
chain = AsyncMiddlewareChain([mw1, mw2])
await chain.execute({}, handler)
assert order == [1, 2, 0, -2, -1]
class TestAgentMiddlewareIntegration:
"""Test middleware integration with Agent class."""
def test_agent_accepts_hooks_parameter(self):
"""Agent should accept hooks parameter."""
from praisonaiagents import Agent
from praisonaiagents.hooks import before_model
@before_model
def my_hook(req):
return req
# Should not raise
agent = Agent(
name="Test",
instructions="Test agent",
hooks=[my_hook]
)
assert agent is not None
def test_agent_hooks_empty_by_default(self):
"""Agent should have empty hooks by default."""
from praisonaiagents import Agent
agent = Agent(name="Test", instructions="Test")
# Should have no hooks or empty hooks list
hooks = getattr(agent, '_hooks', None) or getattr(agent, 'hooks', [])
assert hooks is None or len(hooks) == 0
class TestMiddlewareZeroOverhead:
"""Test that middleware has zero overhead when not used."""
def test_no_hooks_fast_path(self):
"""When no hooks registered, should take fast path."""
from praisonaiagents.hooks.middleware import MiddlewareChain
# Empty chain should be essentially a no-op
chain = MiddlewareChain([])
call_count = [0]
def handler(req):
call_count[0] += 1
return "result"
result = chain.execute({}, handler)
assert result == "result"
assert call_count[0] == 1
def test_middleware_chain_is_none_check(self):
"""Should handle None middleware list gracefully."""
from praisonaiagents.hooks.middleware import MiddlewareChain
chain = MiddlewareChain(None)
result = chain.execute({}, lambda r: "ok")
assert result == "ok"
@@ -0,0 +1,260 @@
"""
TDD Tests for Rate Limiter.
These tests are written FIRST before implementation.
"""
import pytest
import time
from unittest.mock import Mock, patch
class TestRateLimiterBasic:
"""Test basic rate limiter functionality."""
def test_rate_limiter_exists(self):
"""RateLimiter class should exist in praisonaiagents.llm."""
from praisonaiagents.llm import RateLimiter
limiter = RateLimiter(requests_per_minute=60)
assert limiter is not None
def test_rate_limiter_rpm_parameter(self):
"""RateLimiter should accept requests_per_minute."""
from praisonaiagents.llm import RateLimiter
limiter = RateLimiter(requests_per_minute=60)
assert limiter.requests_per_minute == 60
def test_rate_limiter_acquire_sync(self):
"""RateLimiter.acquire() should block if rate exceeded."""
from praisonaiagents.llm import RateLimiter
# 1 request per second = 60 per minute
limiter = RateLimiter(requests_per_minute=60)
# First acquire should be instant
start = time.monotonic()
limiter.acquire()
elapsed = time.monotonic() - start
assert elapsed < 0.1 # Should be nearly instant
def test_rate_limiter_blocks_when_exceeded(self):
"""RateLimiter should block when rate is exceeded."""
from praisonaiagents.llm import RateLimiter
# Very low rate: 6 per minute = 1 per 10 seconds
# But we'll use a mock clock to test without waiting
limiter = RateLimiter(requests_per_minute=6)
# Inject a mock clock for testing
mock_time = [0.0]
limiter._get_time = lambda: mock_time[0]
limiter._sleep = lambda s: mock_time.__setitem__(0, mock_time[0] + s)
# First request should pass
limiter.acquire()
# Second request should need to wait ~10 seconds
# (but our mock sleep just advances time)
limiter.acquire()
# Time should have advanced
assert mock_time[0] >= 10.0
def test_rate_limiter_token_bucket(self):
"""RateLimiter should use token bucket algorithm."""
from praisonaiagents.llm import RateLimiter
limiter = RateLimiter(requests_per_minute=60, burst=5)
# Reset internal state with mock time
mock_time = [0.0]
limiter._get_time = lambda: mock_time[0]
limiter._sleep = lambda s: mock_time.__setitem__(0, mock_time[0] + s)
limiter._tokens = 5.0 # Reset tokens to burst size
limiter._last_update = 0.0 # Reset last update
for _ in range(5):
limiter.acquire()
# Should not have waited much (burst allows 5 instant requests)
assert mock_time[0] < 1.0
class TestRateLimiterAsync:
"""Test async rate limiter functionality."""
@pytest.mark.asyncio
async def test_rate_limiter_acquire_async(self):
"""RateLimiter.acquire_async() should work with async."""
from praisonaiagents.llm import RateLimiter
limiter = RateLimiter(requests_per_minute=60)
# Should have async acquire method
await limiter.acquire_async()
@pytest.mark.asyncio
async def test_rate_limiter_async_blocks(self):
"""Async acquire should also respect rate limits."""
from praisonaiagents.llm import RateLimiter
import asyncio
limiter = RateLimiter(requests_per_minute=6)
# Mock async sleep
sleep_times = []
original_sleep = asyncio.sleep
async def mock_sleep(s):
sleep_times.append(s)
# Don't actually sleep in tests
with patch('asyncio.sleep', mock_sleep):
await limiter.acquire_async()
await limiter.acquire_async()
# Should have tried to sleep
class TestRateLimiterAgentIntegration:
"""Test rate limiter integration with Agent."""
def test_agent_accepts_rate_limiter(self):
"""Agent should accept rate_limiter parameter."""
from praisonaiagents import Agent
from praisonaiagents.llm import RateLimiter
limiter = RateLimiter(requests_per_minute=60)
agent = Agent(
name="Test",
instructions="Test agent",
rate_limiter=limiter
)
assert agent is not None
assert getattr(agent, '_rate_limiter', None) is limiter or \
getattr(agent, 'rate_limiter', None) is limiter
def test_agent_rate_limiter_applied_to_llm_calls(self):
"""Rate limiter should be applied before LLM calls."""
from praisonaiagents import Agent
from praisonaiagents.llm import RateLimiter
limiter = RateLimiter(requests_per_minute=60)
acquire_count = [0]
original_acquire = limiter.acquire
def counting_acquire():
acquire_count[0] += 1
return original_acquire()
limiter.acquire = counting_acquire
agent = Agent(
name="Test",
instructions="Test agent",
rate_limiter=limiter
)
with patch.object(agent, '_chat_completion', return_value="mocked"):
agent.chat("Hello")
# Rate limiter should have been called
assert acquire_count[0] >= 1
class TestRateLimiterZeroOverhead:
"""Test that rate limiter has zero overhead when not used."""
def test_no_rate_limiter_no_overhead(self):
"""When no rate limiter, should not add any overhead."""
from praisonaiagents import Agent
agent = Agent(
name="Test",
instructions="Test agent"
# No rate_limiter
)
# Should not have rate limiter
assert getattr(agent, '_rate_limiter', None) is None
def test_rate_limiter_none_check_fast(self):
"""Checking for None rate limiter should be O(1)."""
from praisonaiagents.llm import RateLimiter
# This is more of a design requirement than a test
# The implementation should do: if self._rate_limiter: self._rate_limiter.acquire()
class TestRateLimiterConfiguration:
"""Test rate limiter configuration options."""
def test_rate_limiter_default_burst(self):
"""RateLimiter should have sensible default burst."""
from praisonaiagents.llm import RateLimiter
limiter = RateLimiter(requests_per_minute=60)
# Default burst should be 1 or based on rpm
assert hasattr(limiter, 'burst') or hasattr(limiter, '_burst')
def test_rate_limiter_custom_burst(self):
"""RateLimiter should accept custom burst parameter."""
from praisonaiagents.llm import RateLimiter
limiter = RateLimiter(requests_per_minute=60, burst=10)
burst = getattr(limiter, 'burst', None) or getattr(limiter, '_burst', None)
assert burst == 10
def test_rate_limiter_tpm_optional(self):
"""RateLimiter should optionally accept tokens_per_minute."""
from praisonaiagents.llm import RateLimiter
# Should not raise
limiter = RateLimiter(
requests_per_minute=60,
tokens_per_minute=100000
)
assert limiter is not None
class TestRateLimiterDeterministic:
"""Test that rate limiter is deterministic for testing."""
def test_injectable_clock(self):
"""RateLimiter should allow injecting clock for testing."""
from praisonaiagents.llm import RateLimiter
mock_time = [0.0]
limiter = RateLimiter(requests_per_minute=60)
limiter._get_time = lambda: mock_time[0]
# Should use our mock time
t1 = limiter._get_time()
mock_time[0] = 100.0
t2 = limiter._get_time()
assert t1 == 0.0
assert t2 == 100.0
def test_injectable_sleep(self):
"""RateLimiter should allow injecting sleep for testing."""
from praisonaiagents.llm import RateLimiter
sleep_calls = []
limiter = RateLimiter(requests_per_minute=60)
limiter._sleep = lambda s: sleep_calls.append(s)
# Force a wait scenario
limiter._get_time = lambda: 0.0
limiter._tokens = 0 # No tokens available
limiter._last_update = 0.0
limiter.acquire()
# Should have called our mock sleep
# (actual behavior depends on implementation)
+1 -1
View File
@@ -2422,7 +2422,7 @@ wheels = [
[[package]]
name = "praisonaiagents"
version = "0.2.1"
version = "0.3.2"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },
+130 -4
View File
@@ -204,6 +204,12 @@ npm install praisonai
| ↳ Add Custom Knowledge | [Example](examples/python/concepts/knowledge-agents.py) | [📖](https://docs.praison.ai/features/knowledge) |
| ↳ RAG Agents | [Example](examples/python/concepts/rag-agents.py) | [📖](https://docs.praison.ai/features/rag) |
| ↳ Chat with PDF Agents | [Example](examples/python/concepts/chat-with-pdf.py) | [📖](https://docs.praison.ai/features/chat-with-pdf) |
| ↳ Data Readers (PDF, DOCX, etc.) | [CLI](#knowledge-cli) | [📖](https://docs.praison.ai/api/praisonai/knowledge-readers-api) |
| ↳ Vector Store Selection | [CLI](#knowledge-cli) | [📖](https://docs.praison.ai/api/praisonai/knowledge-vector-store-api) |
| ↳ Retrieval Strategies | [CLI](#knowledge-cli) | [📖](https://docs.praison.ai/api/praisonai/knowledge-retrieval-api) |
| ↳ Rerankers | [CLI](#knowledge-cli) | [📖](https://docs.praison.ai/api/praisonai/knowledge-reranker-api) |
| ↳ Index Types (Vector/Keyword/Hybrid) | [CLI](#knowledge-cli) | [📖](https://docs.praison.ai/api/praisonai/knowledge-index-api) |
| ↳ Query Engines (Sub-Question, etc.) | [CLI](#knowledge-cli) | [📖](https://docs.praison.ai/api/praisonai/knowledge-query-engine-api) |
| **🔬 Research & Intelligence** | | |
| ↳ Deep Research Agents | [Example](examples/python/agents/research-agent.py) | [📖](https://docs.praison.ai/agents/deep-research) |
| ↳ Query Rewriter Agent | [Example](#5-query-rewriter-agent) | [📖](https://docs.praison.ai/agents/query-rewriter) |
@@ -261,6 +267,10 @@ npm install praisonai
| ↳ 100+ LLM Support | [Example](examples/python/providers/openai/openai_gpt4_example.py) | [📖](https://docs.praison.ai/models) |
| ↳ Callback Agents | [Example](examples/python/general/advanced-callback-systems.py) | [📖](https://docs.praison.ai/features/callbacks) |
| ↳ Hooks | [Example](#9-hooks) | [📖](https://docs.praison.ai/features/hooks) |
| ↳ Middleware System | [Example](examples/middleware/basic_middleware.py) | [📖](https://docs.praison.ai/features/middleware) |
| ↳ Configurable Model | [Example](examples/middleware/configurable_model.py) | [📖](https://docs.praison.ai/features/configurable-model) |
| ↳ Rate Limiter | [Example](examples/middleware/rate_limiter.py) | [📖](https://docs.praison.ai/features/rate-limiter) |
| ↳ Injected Tool State | [Example](examples/middleware/injected_state.py) | [📖](https://docs.praison.ai/features/injected-state) |
| ↳ Shadow Git Checkpoints | [Example](#10-shadow-git-checkpoints) | [📖](https://docs.praison.ai/features/checkpoints) |
| ↳ Background Tasks | [Example](examples/background/basic_background.py) | [📖](https://docs.praison.ai/features/background-tasks) |
| ↳ Policy Engine | [Example](examples/policy/basic_policy.py) | [📖](https://docs.praison.ai/features/policy-engine) |
@@ -2637,6 +2647,84 @@ PraisonAI provides zero-dependency persistent memory for agents. For detailed ex
---
## 📚 Knowledge & Retrieval (RAG)
PraisonAI provides a complete knowledge stack for building RAG applications with multiple vector stores, retrieval strategies, rerankers, and query modes.
### Knowledge CLI Commands
| Command | Description |
|---------|-------------|
| `praisonai knowledge add <file\|dir\|url>` | Add documents to knowledge base |
| `praisonai knowledge query <question>` | Query knowledge base with RAG |
| `praisonai knowledge list` | List indexed documents |
| `praisonai knowledge clear` | Clear knowledge base |
| `praisonai knowledge stats` | Show knowledge base statistics |
### Knowledge CLI Options
| Option | Values | Description |
|--------|--------|-------------|
| `--vector-store` | `memory`, `chroma`, `pinecone`, `qdrant`, `weaviate` | Vector store backend |
| `--retrieval` | `basic`, `fusion`, `recursive`, `auto_merge` | Retrieval strategy |
| `--reranker` | `simple`, `llm`, `cross_encoder`, `cohere` | Reranking method |
| `--index-type` | `vector`, `keyword`, `hybrid` | Index type |
| `--query-mode` | `default`, `sub_question`, `summarize` | Query mode |
### Knowledge CLI Examples
```bash
# Add documents
praisonai knowledge add ./docs/
praisonai knowledge add https://example.com/page.html
praisonai knowledge add "*.pdf"
# Query with advanced options
praisonai knowledge query "How to authenticate?" --retrieval fusion --reranker llm
# Full advanced query
praisonai knowledge query "authentication flow" \
--vector-store chroma \
--retrieval fusion \
--reranker llm \
--index-type hybrid \
--query-mode sub_question
```
### Knowledge SDK Usage
```python
from praisonaiagents import Agent, Knowledge
# Simple usage with Agent
agent = Agent(
name="Research Assistant",
knowledge=["docs/manual.pdf", "data/faq.txt"],
knowledge_config={
"vector_store": {"provider": "chroma"}
}
)
response = agent.chat("How do I authenticate?")
# Direct Knowledge usage
knowledge = Knowledge()
knowledge.add("document.pdf")
results = knowledge.search("authentication", limit=5)
```
### Knowledge Stack Features Table
| Feature | Description | SDK Docs | CLI Docs |
|---------|-------------|----------|----------|
| **Data Readers** | Load PDF, Markdown, Text, HTML, URLs | [SDK](/docs/sdk/praisonaiagents/knowledge/protocols) | [CLI](/docs/cli/knowledge) |
| **Vector Stores** | ChromaDB, Pinecone, Qdrant, Weaviate, In-Memory | [SDK](/docs/sdk/praisonaiagents/knowledge/protocols) | [CLI](/docs/cli/knowledge) |
| **Retrieval Strategies** | Basic, Fusion (RRF), Recursive, Auto-Merge | [SDK](/docs/sdk/praisonaiagents/knowledge/protocols) | [CLI](/docs/cli/knowledge) |
| **Rerankers** | Simple, LLM, Cross-Encoder, Cohere | [SDK](/docs/sdk/praisonaiagents/knowledge/protocols) | [CLI](/docs/cli/knowledge) |
| **Index Types** | Vector, Keyword (BM25), Hybrid | [SDK](/docs/sdk/praisonaiagents/knowledge/protocols) | [CLI](/docs/cli/knowledge) |
| **Query Engines** | Default, Sub-Question, Summarize | [SDK](/docs/sdk/praisonaiagents/knowledge/protocols) | [CLI](/docs/cli/knowledge) |
---
## 🔬 Advanced Features
### Research & Intelligence
@@ -2751,6 +2839,44 @@ agent.chat("Hello!") # Auto-persists messages, runs, traces
| `praisonai persistence migrate` | Apply schema migrations |
| `praisonai persistence status` | Show schema status |
### Knowledge CLI Commands {#knowledge-cli}
| Command | Description |
|---------|-------------|
| `praisonai knowledge add <source>` | Add file, directory, URL, or glob pattern |
| `praisonai knowledge query "<question>"` | Query knowledge base with RAG |
| `praisonai knowledge list` | List indexed documents |
| `praisonai knowledge clear` | Clear knowledge base |
| `praisonai knowledge stats` | Show knowledge base statistics |
**Knowledge Query Flags:**
| Flag | Values | Default |
|------|--------|---------|
| `--vector-store` | `memory`, `chroma`, `pinecone`, `qdrant`, `weaviate` | `chroma` |
| `--retrieval-strategy` | `basic`, `fusion`, `recursive`, `auto_merge` | `basic` |
| `--reranker` | `none`, `simple`, `llm`, `cross_encoder`, `cohere` | `none` |
| `--index-type` | `vector`, `keyword`, `hybrid` | `vector` |
| `--query-mode` | `default`, `sub_question`, `summarize` | `default` |
| `--workspace` | Path to workspace directory | Current dir |
| `--session` | Session ID for persistence | - |
**Examples:**
```bash
# Add documents
praisonai knowledge add document.pdf
praisonai knowledge add ./docs/
praisonai knowledge add "*.md"
# Query with options
praisonai knowledge query "How to authenticate?" \
--vector-store chroma \
--retrieval-strategy fusion \
--reranker simple \
--query-mode sub_question
```
### Databases Table
| Database | Store Type | Install | Example | Docs |
@@ -2763,11 +2889,11 @@ agent.chat("Hello!") # Auto-persists messages, runs, traces
| 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) |
| Pinecone | Knowledge | `pip install pinecone` | [pinecone_wow.py](examples/vector/pinecone_wow.py) | [docs](https://docs.praison.ai/docs/databases/pinecone) |
| Weaviate | Knowledge | `pip install weaviate-client` | [weaviate_wow.py](examples/vector/weaviate_wow.py) | [docs](https://docs.praison.ai/docs/databases/weaviate) |
| LanceDB | Knowledge | `pip install lancedb` | [lancedb_real_wow.py](examples/vector/lancedb_real_wow.py) | [docs](https://docs.praison.ai/docs/databases/lancedb) |
| Milvus | Knowledge | `pip install "praisonai[tools]"` | - | [docs](https://docs.praison.ai/docs/databases/overview) |
| PGVector | Knowledge | `pip install "praisonai[tools]"` | - | [docs](https://docs.praison.ai/docs/databases/overview) |
| PGVector | Knowledge | `pip install psycopg2-binary` | [pgvector_real_wow.py](examples/vector/pgvector_real_wow.py) | [docs](https://docs.praison.ai/docs/databases/pgvector) |
| Redis Vector | Knowledge | `pip install "praisonai[tools]"` | - | [docs](https://docs.praison.ai/docs/databases/overview) |
| Cassandra | Knowledge | `pip install "praisonai[tools]"` | - | [docs](https://docs.praison.ai/docs/databases/overview) |
| ClickHouse | Knowledge | `pip install "praisonai[tools]"` | - | [docs](https://docs.praison.ai/docs/databases/overview) |
+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.2.tar.gz"
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v2.4.2.tar.gz | shasum -a 256`.split.first
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v2.5.2.tar.gz"
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v2.5.2.tar.gz | shasum -a 256`.split.first
license "MIT"
depends_on "[email protected]"
@@ -0,0 +1,50 @@
"""
PraisonAI Adapters - Implementations for core protocols.
This module provides concrete implementations of:
- Reader adapters (AutoReader, LlamaIndexReaderAdapter, MarkItDownReaderAdapter)
- Vector store adapters (ChromaAdapter, PineconeAdapter, etc.)
- Retriever implementations
- Reranker implementations
"""
# Lazy loading to avoid heavy imports at package load time
_LAZY_IMPORTS = {
# Readers
"AutoReader": ("praisonai.adapters.readers", "AutoReader"),
"MarkItDownReader": ("praisonai.adapters.readers", "MarkItDownReader"),
"TextReader": ("praisonai.adapters.readers", "TextReader"),
"DirectoryReader": ("praisonai.adapters.readers", "DirectoryReader"),
"register_default_readers": ("praisonai.adapters.readers", "register_default_readers"),
# Vector stores
"ChromaVectorStore": ("praisonai.adapters.vector_stores", "ChromaVectorStore"),
"register_default_vector_stores": ("praisonai.adapters.vector_stores", "register_default_vector_stores"),
# Retrievers
"BasicRetriever": ("praisonai.adapters.retrievers", "BasicRetriever"),
"FusionRetriever": ("praisonai.adapters.retrievers", "FusionRetriever"),
"register_default_retrievers": ("praisonai.adapters.retrievers", "register_default_retrievers"),
# Rerankers
"LLMReranker": ("praisonai.adapters.rerankers", "LLMReranker"),
"register_default_rerankers": ("praisonai.adapters.rerankers", "register_default_rerankers"),
}
def __getattr__(name: str):
"""Lazy load adapters."""
if name in _LAZY_IMPORTS:
module_path, attr_name = _LAZY_IMPORTS[name]
import importlib
module = importlib.import_module(module_path)
return getattr(module, attr_name)
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
def __dir__():
"""List available attributes."""
return list(_LAZY_IMPORTS.keys())
__all__ = list(_LAZY_IMPORTS.keys())
+395
View File
@@ -0,0 +1,395 @@
"""
Reader Adapters for PraisonAI.
Provides concrete implementations of ReaderProtocol:
- TextReader: Plain text files
- MarkItDownReader: Uses markitdown for documents
- DirectoryReader: Reads all files in a directory
- AutoReader: Automatic file type detection and routing
"""
import os
import glob as glob_module
import logging
import importlib.util
from typing import Any, Dict, List, Optional
logger = logging.getLogger(__name__)
# Lazy import flags
_MARKITDOWN_AVAILABLE = None
_LLAMA_INDEX_AVAILABLE = None
def _check_markitdown():
"""Check if markitdown is available."""
global _MARKITDOWN_AVAILABLE
if _MARKITDOWN_AVAILABLE is None:
_MARKITDOWN_AVAILABLE = importlib.util.find_spec("markitdown") is not None
return _MARKITDOWN_AVAILABLE
def _check_llama_index():
"""Check if llama_index is available."""
global _LLAMA_INDEX_AVAILABLE
if _LLAMA_INDEX_AVAILABLE is None:
_LLAMA_INDEX_AVAILABLE = importlib.util.find_spec("llama_index") is not None
return _LLAMA_INDEX_AVAILABLE
class TextReader:
"""Simple text file reader."""
name: str = "text"
supported_extensions: List[str] = ["txt", "text", "log"]
def load(
self,
source: str,
*,
metadata: Optional[Dict[str, Any]] = None
) -> List[Dict[str, Any]]:
"""Load a text file."""
from praisonaiagents.knowledge.readers import Document
try:
with open(source, 'r', encoding='utf-8') as f:
content = f.read()
doc_metadata = metadata or {}
doc_metadata.update({
"source": source,
"filename": os.path.basename(source),
"file_type": "text"
})
return [Document(content=content, metadata=doc_metadata)]
except Exception as e:
logger.error(f"Failed to read text file {source}: {e}")
return []
def can_handle(self, source: str) -> bool:
"""Check if this reader can handle the source."""
ext = os.path.splitext(source)[1].lower().lstrip(".")
return ext in self.supported_extensions
class MarkItDownReader:
"""Reader using markitdown for document conversion."""
name: str = "markitdown"
supported_extensions: List[str] = [
"pdf", "doc", "docx", "ppt", "pptx", "xls", "xlsx",
"html", "htm", "md", "markdown", "csv", "json", "xml",
"jpg", "jpeg", "png", "gif", "bmp", "tiff", "webp",
"mp3", "wav", "ogg", "m4a", "flac"
]
def __init__(self):
self._converter = None
@property
def converter(self):
"""Lazy load markitdown converter."""
if self._converter is None:
if not _check_markitdown():
raise ImportError(
"markitdown is required for document conversion. "
"Install with: pip install markitdown"
)
from markitdown import MarkItDown
self._converter = MarkItDown()
return self._converter
def load(
self,
source: str,
*,
metadata: Optional[Dict[str, Any]] = None
) -> List[Dict[str, Any]]:
"""Load a document using markitdown."""
from praisonaiagents.knowledge.readers import Document
try:
result = self.converter.convert(source)
content = result.text_content
if not content:
logger.warning(f"No content extracted from {source}")
return []
doc_metadata = metadata or {}
doc_metadata.update({
"source": source,
"filename": os.path.basename(source),
"file_type": os.path.splitext(source)[1].lower().lstrip(".")
})
return [Document(content=content, metadata=doc_metadata)]
except Exception as e:
logger.error(f"Failed to convert document {source}: {e}")
return []
def can_handle(self, source: str) -> bool:
"""Check if this reader can handle the source."""
ext = os.path.splitext(source)[1].lower().lstrip(".")
return ext in self.supported_extensions and _check_markitdown()
class DirectoryReader:
"""Reader for directories - recursively reads all files."""
name: str = "directory"
supported_extensions: List[str] = [] # Handles directories, not extensions
def __init__(self, recursive: bool = True, exclude_patterns: Optional[List[str]] = None):
self.recursive = recursive
self.exclude_patterns = exclude_patterns or [
"*.pyc", "__pycache__", ".git", ".svn", "node_modules",
"*.egg-info", ".env", ".venv", "venv"
]
def load(
self,
source: str,
*,
metadata: Optional[Dict[str, Any]] = None
) -> List[Dict[str, Any]]:
"""Load all files from a directory."""
from praisonaiagents.knowledge.readers import get_reader_registry
if not os.path.isdir(source):
logger.error(f"Not a directory: {source}")
return []
documents = []
registry = get_reader_registry()
# Walk directory
if self.recursive:
for root, dirs, files in os.walk(source):
# Filter out excluded directories
dirs[:] = [d for d in dirs if not self._should_exclude(d)]
for file in files:
if self._should_exclude(file):
continue
file_path = os.path.join(root, file)
reader = registry.get_for_source(file_path)
if reader and reader.name != "directory":
file_metadata = (metadata or {}).copy()
file_metadata["parent_dir"] = source
loaded_docs = reader.load(file_path, metadata=file_metadata)
documents.extend(loaded_docs)
else:
for file in os.listdir(source):
if self._should_exclude(file):
continue
file_path = os.path.join(source, file)
if os.path.isfile(file_path):
reader = registry.get_for_source(file_path)
if reader and reader.name != "directory":
file_metadata = (metadata or {}).copy()
file_metadata["parent_dir"] = source
docs = reader.load(file_path, metadata=file_metadata)
documents.extend(docs)
return documents
def can_handle(self, source: str) -> bool:
"""Check if this reader can handle the source."""
return os.path.isdir(source)
def _should_exclude(self, name: str) -> bool:
"""Check if a file/directory should be excluded."""
import fnmatch
for pattern in self.exclude_patterns:
if fnmatch.fnmatch(name, pattern):
return True
return False
class GlobReader:
"""Reader for glob patterns."""
name: str = "glob"
supported_extensions: List[str] = []
def load(
self,
source: str,
*,
metadata: Optional[Dict[str, Any]] = None
) -> List[Dict[str, Any]]:
"""Load files matching a glob pattern."""
from praisonaiagents.knowledge.readers import get_reader_registry
documents = []
registry = get_reader_registry()
for file_path in glob_module.glob(source, recursive=True):
if os.path.isfile(file_path):
reader = registry.get_for_source(file_path)
if reader and reader.name not in ("glob", "directory"):
file_metadata = (metadata or {}).copy()
file_metadata["glob_pattern"] = source
docs = reader.load(file_path, metadata=file_metadata)
documents.extend(docs)
return documents
def can_handle(self, source: str) -> bool:
"""Check if this is a glob pattern."""
return "*" in source or "?" in source or "[" in source
class URLReader:
"""Reader for URLs (HTML pages)."""
name: str = "url"
supported_extensions: List[str] = []
def load(
self,
source: str,
*,
metadata: Optional[Dict[str, Any]] = None
) -> List[Dict[str, Any]]:
"""Load content from a URL."""
from praisonaiagents.knowledge.readers import Document
try:
import httpx
except ImportError:
try:
import requests as httpx
except ImportError:
logger.error("httpx or requests required for URL reading")
return []
try:
response = httpx.get(source, timeout=30, follow_redirects=True)
response.raise_for_status()
content = response.text
# Try to extract text from HTML
try:
from bs4 import BeautifulSoup
soup = BeautifulSoup(content, 'html.parser')
# Remove script and style elements
for script in soup(["script", "style"]):
script.decompose()
content = soup.get_text(separator='\n', strip=True)
except ImportError:
# Fall back to raw content
pass
doc_metadata = metadata or {}
doc_metadata.update({
"source": source,
"url": source,
"file_type": "html"
})
return [Document(content=content, metadata=doc_metadata)]
except Exception as e:
logger.error(f"Failed to fetch URL {source}: {e}")
return []
def can_handle(self, source: str) -> bool:
"""Check if this is a URL."""
return source.startswith(("http://", "https://"))
class AutoReader:
"""
Automatic reader that detects source type and routes to appropriate reader.
Selection policy:
1. If LlamaIndex reader is installed + supports the type → use it
2. Else if MarkItDown supports → use it
3. Else use simple built-in reader
"""
name: str = "auto"
supported_extensions: List[str] = [] # Handles everything
def load(
self,
source: str,
*,
metadata: Optional[Dict[str, Any]] = None
) -> List[Dict[str, Any]]:
"""Load from any source type."""
from praisonaiagents.knowledge.readers import get_reader_registry, detect_source_kind
registry = get_reader_registry()
source_kind = detect_source_kind(source)
# Route based on source kind
if source_kind == "url":
reader = registry.get("url")
elif source_kind == "directory":
reader = registry.get("directory")
elif source_kind == "glob":
reader = registry.get("glob")
elif source_kind == "file":
# Find best reader for file type
reader = registry.get_for_source(source)
if reader and reader.name == "auto":
# Avoid infinite recursion - use markitdown or text
ext = os.path.splitext(source)[1].lower().lstrip(".")
if ext in MarkItDownReader.supported_extensions and _check_markitdown():
reader = registry.get("markitdown")
else:
reader = registry.get("text")
else:
# Unknown - try markitdown then text
if _check_markitdown():
reader = registry.get("markitdown")
else:
reader = registry.get("text")
if reader:
return reader.load(source, metadata=metadata)
logger.warning(f"No reader found for source: {source}")
return []
def can_handle(self, source: str) -> bool:
"""AutoReader can handle anything."""
return True
def register_default_readers():
"""Register all default readers with the registry."""
from praisonaiagents.knowledge.readers import get_reader_registry
registry = get_reader_registry()
# Register text reader
registry.register("text", TextReader, ["txt", "text", "log"])
# Register markitdown reader
registry.register("markitdown", MarkItDownReader, MarkItDownReader.supported_extensions)
# Register directory reader
registry.register("directory", DirectoryReader)
# Register glob reader
registry.register("glob", GlobReader)
# Register URL reader
registry.register("url", URLReader)
# Register auto reader
registry.register("auto", AutoReader)
logger.debug("Registered default readers")
# Auto-register on import
register_default_readers()
@@ -0,0 +1,315 @@
"""
Reranker Implementations for PraisonAI.
Provides concrete implementations of RerankerProtocol:
- LLMReranker: Uses LLM to rerank documents
- CrossEncoderReranker: Uses sentence-transformers cross-encoder
- CohereReranker: Uses Cohere rerank API
"""
import logging
from typing import Any, List, Optional
logger = logging.getLogger(__name__)
# Lazy import flags
_SENTENCE_TRANSFORMERS_AVAILABLE = None
_COHERE_AVAILABLE = None
def _check_sentence_transformers():
"""Check if sentence-transformers is available."""
global _SENTENCE_TRANSFORMERS_AVAILABLE
if _SENTENCE_TRANSFORMERS_AVAILABLE is None:
import importlib.util
_SENTENCE_TRANSFORMERS_AVAILABLE = importlib.util.find_spec("sentence_transformers") is not None
return _SENTENCE_TRANSFORMERS_AVAILABLE
def _check_cohere():
"""Check if cohere is available."""
global _COHERE_AVAILABLE
if _COHERE_AVAILABLE is None:
import importlib.util
_COHERE_AVAILABLE = importlib.util.find_spec("cohere") is not None
return _COHERE_AVAILABLE
class LLMReranker:
"""
LLM-based reranker.
Uses an LLM to score document relevance to a query.
"""
name: str = "llm"
def __init__(
self,
llm: Optional[Any] = None,
model: Optional[str] = None,
batch_size: int = 5,
**kwargs
):
self.llm = llm
self.model = model or "gpt-4o-mini"
self.batch_size = batch_size
self._client = None
def _get_client(self):
"""Lazy load OpenAI client."""
if self._client is None:
if self.llm:
return self.llm
import openai
self._client = openai.OpenAI()
return self._client
def rerank(
self,
query: str,
documents: List[str],
top_k: Optional[int] = None,
**kwargs
) -> List[Any]:
"""Rerank documents using LLM scoring."""
from praisonaiagents.knowledge.rerankers import RerankResult
results = []
# Process in batches
for i in range(0, len(documents), self.batch_size):
batch = documents[i:i + self.batch_size]
batch_results = self._score_batch(query, batch, start_idx=i)
results.extend(batch_results)
# Sort by score
results.sort(key=lambda x: x.score, reverse=True)
if top_k is not None:
results = results[:top_k]
return results
async def arerank(
self,
query: str,
documents: List[str],
top_k: Optional[int] = None,
**kwargs
) -> List[Any]:
"""Async version (just calls sync)."""
return self.rerank(query, documents, top_k, **kwargs)
def _score_batch(self, query: str, documents: List[str], start_idx: int = 0) -> List[Any]:
"""Score a batch of documents."""
from praisonaiagents.knowledge.rerankers import RerankResult
results = []
for i, doc in enumerate(documents):
score = self._score_document(query, doc)
results.append(RerankResult(
text=doc,
score=score,
original_index=start_idx + i
))
return results
def _score_document(self, query: str, document: str) -> float:
"""Score a single document's relevance to query."""
try:
client = self._get_client()
prompt = f"""Rate the relevance of this document to the query on a scale of 0 to 10.
Return ONLY a number between 0 and 10.
Query: {query}
Document: {document[:1000]}
Relevance score (0-10):"""
if hasattr(client, 'chat'):
# PraisonAI Agent or similar
response = client.chat(prompt)
else:
# OpenAI client
response = client.chat.completions.create(
model=self.model,
messages=[{"role": "user", "content": prompt}],
max_tokens=10,
temperature=0
)
response = response.choices[0].message.content
# Parse score
if response:
try:
score = float(response.strip().split()[0])
return min(max(score / 10.0, 0.0), 1.0) # Normalize to 0-1
except (ValueError, IndexError):
pass
except Exception as e:
logger.warning(f"LLM scoring failed: {e}")
return 0.5 # Default score
class CrossEncoderReranker:
"""
Cross-encoder reranker using sentence-transformers.
Uses a cross-encoder model for accurate relevance scoring.
"""
name: str = "cross_encoder"
def __init__(
self,
model_name: str = "cross-encoder/ms-marco-MiniLM-L-6-v2",
**kwargs
):
if not _check_sentence_transformers():
raise ImportError(
"sentence-transformers is required for CrossEncoderReranker. "
"Install with: pip install sentence-transformers"
)
from sentence_transformers import CrossEncoder
self.model = CrossEncoder(model_name)
def rerank(
self,
query: str,
documents: List[str],
top_k: Optional[int] = None,
**kwargs
) -> List[Any]:
"""Rerank documents using cross-encoder."""
from praisonaiagents.knowledge.rerankers import RerankResult
# Create query-document pairs
pairs = [[query, doc] for doc in documents]
# Get scores
scores = self.model.predict(pairs)
# Create results
results = []
for i, (doc, score) in enumerate(zip(documents, scores)):
results.append(RerankResult(
text=doc,
score=float(score),
original_index=i
))
# Sort by score
results.sort(key=lambda x: x.score, reverse=True)
if top_k is not None:
results = results[:top_k]
return results
async def arerank(
self,
query: str,
documents: List[str],
top_k: Optional[int] = None,
**kwargs
) -> List[Any]:
"""Async version (just calls sync)."""
return self.rerank(query, documents, top_k, **kwargs)
class CohereReranker:
"""
Cohere reranker using Cohere's rerank API.
"""
name: str = "cohere"
def __init__(
self,
api_key: Optional[str] = None,
model: str = "rerank-english-v3.0",
**kwargs
):
if not _check_cohere():
raise ImportError(
"cohere is required for CohereReranker. "
"Install with: pip install cohere"
)
import os
import cohere
self.api_key = api_key or os.environ.get("COHERE_API_KEY")
if not self.api_key:
raise ValueError("COHERE_API_KEY environment variable or api_key parameter required")
self.model = model
self._client = cohere.Client(self.api_key)
def rerank(
self,
query: str,
documents: List[str],
top_k: Optional[int] = None,
**kwargs
) -> List[Any]:
"""Rerank documents using Cohere API."""
from praisonaiagents.knowledge.rerankers import RerankResult
response = self._client.rerank(
query=query,
documents=documents,
top_n=top_k or len(documents),
model=self.model
)
results = []
for r in response.results:
results.append(RerankResult(
text=documents[r.index],
score=r.relevance_score,
original_index=r.index
))
return results
async def arerank(
self,
query: str,
documents: List[str],
top_k: Optional[int] = None,
**kwargs
) -> List[Any]:
"""Async version (just calls sync)."""
return self.rerank(query, documents, top_k, **kwargs)
def register_default_rerankers():
"""Register all default rerankers with the registry."""
from praisonaiagents.knowledge.rerankers import get_reranker_registry
registry = get_reranker_registry()
# LLM reranker is always available
registry.register("llm", LLMReranker)
# Cross-encoder if available
if _check_sentence_transformers():
registry.register("cross_encoder", CrossEncoderReranker)
# Cohere if available
if _check_cohere():
registry.register("cohere", CohereReranker)
logger.debug("Registered default rerankers")
# Auto-register on import
register_default_rerankers()
@@ -0,0 +1,394 @@
"""
Retriever Implementations for PraisonAI.
Provides concrete implementations of RetrieverProtocol:
- BasicRetriever: Simple vector similarity retrieval
- FusionRetriever: Multi-query with RRF fusion
- RecursiveRetriever: Depth-limited sub-retrieval
- AutoMergeRetriever: Merges adjacent chunks
"""
import logging
from typing import Any, Callable, Dict, List, Optional
logger = logging.getLogger(__name__)
class BasicRetriever:
"""
Basic vector similarity retriever.
Simply queries the vector store and returns results.
"""
name: str = "basic"
def __init__(
self,
vector_store: Any,
embedding_fn: Optional[Callable[[str], List[float]]] = None,
top_k: int = 10,
**kwargs
):
self.vector_store = vector_store
self.embedding_fn = embedding_fn
self.top_k = top_k
def retrieve(
self,
query: str,
top_k: Optional[int] = None,
filter: Optional[Dict[str, Any]] = None,
**kwargs
) -> List[Any]:
"""Retrieve documents by vector similarity."""
from praisonaiagents.knowledge.retrieval import RetrievalResult
k = top_k or self.top_k
# Get query embedding
if self.embedding_fn:
query_embedding = self.embedding_fn(query)
else:
raise ValueError("embedding_fn is required for BasicRetriever")
# Query vector store
results = self.vector_store.query(
embedding=query_embedding,
top_k=k,
filter=filter
)
# Convert to RetrievalResult
retrieval_results = []
for r in results:
retrieval_results.append(RetrievalResult(
text=r.text if hasattr(r, 'text') else str(r),
score=r.score if hasattr(r, 'score') else 1.0,
metadata=r.metadata if hasattr(r, 'metadata') else {},
doc_id=r.id if hasattr(r, 'id') else None,
chunk_index=r.metadata.get('chunk_index') if hasattr(r, 'metadata') else None
))
return retrieval_results
async def aretrieve(
self,
query: str,
top_k: Optional[int] = None,
filter: Optional[Dict[str, Any]] = None,
**kwargs
) -> List[Any]:
"""Async version (just calls sync)."""
return self.retrieve(query, top_k, filter, **kwargs)
class FusionRetriever:
"""
Fusion retriever using Reciprocal Rank Fusion.
Generates multiple query variations and fuses results.
"""
name: str = "fusion"
def __init__(
self,
vector_store: Any,
embedding_fn: Optional[Callable[[str], List[float]]] = None,
llm: Optional[Any] = None,
num_queries: int = 3,
top_k: int = 10,
rrf_k: int = 60,
**kwargs
):
self.vector_store = vector_store
self.embedding_fn = embedding_fn
self.llm = llm
self.num_queries = num_queries
self.top_k = top_k
self.rrf_k = rrf_k
def retrieve(
self,
query: str,
top_k: Optional[int] = None,
filter: Optional[Dict[str, Any]] = None,
**kwargs
) -> List[Any]:
"""Retrieve with query fusion."""
from praisonaiagents.knowledge.retrieval import RetrievalResult, reciprocal_rank_fusion
k = top_k or self.top_k
# Generate query variations
queries = self._generate_query_variations(query)
# Retrieve for each query
all_results = []
for q in queries:
if self.embedding_fn:
q_embedding = self.embedding_fn(q)
results = self.vector_store.query(
embedding=q_embedding,
top_k=k,
filter=filter
)
# Convert to RetrievalResult
query_results = []
for r in results:
query_results.append(RetrievalResult(
text=r.text if hasattr(r, 'text') else str(r),
score=r.score if hasattr(r, 'score') else 1.0,
metadata=r.metadata if hasattr(r, 'metadata') else {},
doc_id=r.id if hasattr(r, 'id') else None
))
all_results.append(query_results)
# Fuse results using RRF
fused = reciprocal_rank_fusion(all_results, k=self.rrf_k)
return fused[:k]
async def aretrieve(
self,
query: str,
top_k: Optional[int] = None,
filter: Optional[Dict[str, Any]] = None,
**kwargs
) -> List[Any]:
"""Async version (just calls sync)."""
return self.retrieve(query, top_k, filter, **kwargs)
def _generate_query_variations(self, query: str) -> List[str]:
"""Generate query variations."""
queries = [query]
# Try LLM-based variation if available
if self.llm and len(queries) < self.num_queries:
try:
prompt = f"""Generate {self.num_queries - 1} alternative phrasings of this search query.
Return only the queries, one per line.
Original query: {query}"""
response = self.llm.chat(prompt)
if response:
for line in response.strip().split("\n"):
line = line.strip()
if line and line != query:
queries.append(line)
if len(queries) >= self.num_queries:
break
except Exception as e:
logger.warning(f"LLM query variation failed: {e}")
# Fallback: simple variations
if len(queries) < self.num_queries:
# Add question form
if not query.endswith("?"):
queries.append(f"What is {query}?")
# Add keyword form
keywords = " ".join(query.split()[:5])
if keywords != query:
queries.append(keywords)
return queries[:self.num_queries]
class RecursiveRetriever:
"""
Recursive retriever with depth-limited sub-retrieval.
Retrieves, expands queries based on results, and retrieves again.
"""
name: str = "recursive"
def __init__(
self,
vector_store: Any,
embedding_fn: Optional[Callable[[str], List[float]]] = None,
llm: Optional[Any] = None,
max_depth: int = 2,
top_k: int = 10,
**kwargs
):
self.vector_store = vector_store
self.embedding_fn = embedding_fn
self.llm = llm
self.max_depth = max_depth
self.top_k = top_k
def retrieve(
self,
query: str,
top_k: Optional[int] = None,
filter: Optional[Dict[str, Any]] = None,
**kwargs
) -> List[Any]:
"""Retrieve with recursive expansion."""
from praisonaiagents.knowledge.retrieval import RetrievalResult
k = top_k or self.top_k
seen_ids = set()
all_results = []
current_queries = [query]
for depth in range(self.max_depth):
next_queries = []
for q in current_queries:
if self.embedding_fn:
q_embedding = self.embedding_fn(q)
results = self.vector_store.query(
embedding=q_embedding,
top_k=k,
filter=filter
)
for r in results:
r_id = r.id if hasattr(r, 'id') else r.text[:50]
if r_id not in seen_ids:
seen_ids.add(r_id)
all_results.append(RetrievalResult(
text=r.text if hasattr(r, 'text') else str(r),
score=r.score if hasattr(r, 'score') else 1.0,
metadata=r.metadata if hasattr(r, 'metadata') else {},
doc_id=r.id if hasattr(r, 'id') else None
))
# Generate follow-up query from result
if depth < self.max_depth - 1 and self.llm:
follow_up = self._generate_follow_up(query, r.text if hasattr(r, 'text') else str(r))
if follow_up:
next_queries.append(follow_up)
current_queries = next_queries[:3] # Limit expansion
if not current_queries:
break
# Sort by score
all_results.sort(key=lambda x: x.score, reverse=True)
return all_results[:k]
async def aretrieve(
self,
query: str,
top_k: Optional[int] = None,
filter: Optional[Dict[str, Any]] = None,
**kwargs
) -> List[Any]:
"""Async version (just calls sync)."""
return self.retrieve(query, top_k, filter, **kwargs)
def _generate_follow_up(self, original_query: str, context: str) -> Optional[str]:
"""Generate a follow-up query based on retrieved context."""
if not self.llm:
return None
try:
prompt = f"""Based on this search result, generate a follow-up query to find more relevant information.
Return only the query, nothing else.
Original query: {original_query}
Search result: {context[:500]}"""
response = self.llm.chat(prompt)
if response:
return response.strip()
except Exception as e:
logger.warning(f"Follow-up query generation failed: {e}")
return None
class AutoMergeRetriever:
"""
Auto-merge retriever that combines adjacent chunks.
Retrieves chunks and merges those from the same document.
"""
name: str = "auto_merge"
def __init__(
self,
vector_store: Any,
embedding_fn: Optional[Callable[[str], List[float]]] = None,
top_k: int = 10,
max_gap: int = 1,
**kwargs
):
self.vector_store = vector_store
self.embedding_fn = embedding_fn
self.top_k = top_k
self.max_gap = max_gap
def retrieve(
self,
query: str,
top_k: Optional[int] = None,
filter: Optional[Dict[str, Any]] = None,
**kwargs
) -> List[Any]:
"""Retrieve and merge adjacent chunks."""
from praisonaiagents.knowledge.retrieval import RetrievalResult, merge_adjacent_chunks
k = top_k or self.top_k
# Get more results than needed for merging
fetch_k = k * 3
if self.embedding_fn:
q_embedding = self.embedding_fn(query)
results = self.vector_store.query(
embedding=q_embedding,
top_k=fetch_k,
filter=filter
)
# Convert to RetrievalResult
retrieval_results = []
for r in results:
retrieval_results.append(RetrievalResult(
text=r.text if hasattr(r, 'text') else str(r),
score=r.score if hasattr(r, 'score') else 1.0,
metadata=r.metadata if hasattr(r, 'metadata') else {},
doc_id=r.metadata.get('doc_id') if hasattr(r, 'metadata') else None,
chunk_index=r.metadata.get('chunk_index') if hasattr(r, 'metadata') else None
))
# Merge adjacent chunks
merged = merge_adjacent_chunks(retrieval_results, max_gap=self.max_gap)
return merged[:k]
return []
async def aretrieve(
self,
query: str,
top_k: Optional[int] = None,
filter: Optional[Dict[str, Any]] = None,
**kwargs
) -> List[Any]:
"""Async version (just calls sync)."""
return self.retrieve(query, top_k, filter, **kwargs)
def register_default_retrievers():
"""Register all default retrievers with the registry."""
from praisonaiagents.knowledge.retrieval import get_retriever_registry
registry = get_retriever_registry()
registry.register("basic", BasicRetriever)
registry.register("fusion", FusionRetriever)
registry.register("recursive", RecursiveRetriever)
registry.register("auto_merge", AutoMergeRetriever)
logger.debug("Registered default retrievers")
# Auto-register on import
register_default_retrievers()
@@ -0,0 +1,399 @@
"""
Vector Store Adapters for PraisonAI.
Provides concrete implementations of VectorStoreProtocol:
- ChromaVectorStore: Uses ChromaDB
- PineconeVectorStore: Uses Pinecone
- QdrantVectorStore: Uses Qdrant
- WeaviateVectorStore: Uses Weaviate
"""
import os
import logging
from typing import Any, Dict, List, Optional
import uuid as uuid_module
logger = logging.getLogger(__name__)
# Lazy import flags
_CHROMA_AVAILABLE = None
_PINECONE_AVAILABLE = None
_QDRANT_AVAILABLE = None
_WEAVIATE_AVAILABLE = None
def _check_chroma():
"""Check if chromadb is available."""
global _CHROMA_AVAILABLE
if _CHROMA_AVAILABLE is None:
import importlib.util
_CHROMA_AVAILABLE = importlib.util.find_spec("chromadb") is not None
return _CHROMA_AVAILABLE
def _check_pinecone():
"""Check if pinecone is available."""
global _PINECONE_AVAILABLE
if _PINECONE_AVAILABLE is None:
import importlib.util
_PINECONE_AVAILABLE = importlib.util.find_spec("pinecone") is not None
return _PINECONE_AVAILABLE
def _check_qdrant():
"""Check if qdrant_client is available."""
global _QDRANT_AVAILABLE
if _QDRANT_AVAILABLE is None:
import importlib.util
_QDRANT_AVAILABLE = importlib.util.find_spec("qdrant_client") is not None
return _QDRANT_AVAILABLE
def _check_weaviate():
"""Check if weaviate is available."""
global _WEAVIATE_AVAILABLE
if _WEAVIATE_AVAILABLE is None:
import importlib.util
_WEAVIATE_AVAILABLE = importlib.util.find_spec("weaviate") is not None
return _WEAVIATE_AVAILABLE
class ChromaVectorStore:
"""ChromaDB vector store adapter."""
name: str = "chroma"
def __init__(
self,
config: Optional[Dict[str, Any]] = None,
namespace: Optional[str] = None,
persist_directory: Optional[str] = None
):
if not _check_chroma():
raise ImportError(
"chromadb is required for ChromaVectorStore. "
"Install with: pip install chromadb"
)
import chromadb
from chromadb.config import Settings
self.config = config or {}
self.namespace = namespace or "default"
self.persist_directory = persist_directory or self.config.get("path", ".praison/chroma")
# Disable telemetry
os.environ['ANONYMIZED_TELEMETRY'] = 'False'
# Create client
self._client = chromadb.PersistentClient(
path=self.persist_directory,
settings=Settings(anonymized_telemetry=False)
)
# Get or create collection
self._collection = self._client.get_or_create_collection(
name=self.namespace,
metadata={"hnsw:space": "cosine"}
)
def add(
self,
texts: List[str],
embeddings: List[List[float]],
metadatas: Optional[List[Dict[str, Any]]] = None,
ids: Optional[List[str]] = None,
namespace: Optional[str] = None
) -> List[str]:
"""Add vectors to ChromaDB."""
ids = ids or [str(uuid_module.uuid4()) for _ in texts]
metadatas = metadatas or [{} for _ in texts]
# Use different collection if namespace specified
if namespace and namespace != self.namespace:
collection = self._client.get_or_create_collection(name=namespace)
else:
collection = self._collection
collection.add(
ids=ids,
embeddings=embeddings,
documents=texts,
metadatas=metadatas
)
return ids
def query(
self,
embedding: List[float],
top_k: int = 10,
namespace: Optional[str] = None,
filter: Optional[Dict[str, Any]] = None
) -> List[Any]:
"""Query ChromaDB by similarity."""
from praisonaiagents.knowledge.vector_store import VectorRecord
if namespace and namespace != self.namespace:
collection = self._client.get_or_create_collection(name=namespace)
else:
collection = self._collection
results = collection.query(
query_embeddings=[embedding],
n_results=top_k,
where=filter
)
records = []
if results and results['ids'] and results['ids'][0]:
for i, id_ in enumerate(results['ids'][0]):
records.append(VectorRecord(
id=id_,
text=results['documents'][0][i] if results.get('documents') else "",
embedding=embedding, # We don't get embeddings back from query
metadata=results['metadatas'][0][i] if results.get('metadatas') else {},
score=1 - results['distances'][0][i] if results.get('distances') else 1.0
))
return records
def delete(
self,
ids: Optional[List[str]] = None,
namespace: Optional[str] = None,
filter: Optional[Dict[str, Any]] = None,
delete_all: bool = False
) -> int:
"""Delete vectors from ChromaDB."""
if namespace and namespace != self.namespace:
collection = self._client.get_or_create_collection(name=namespace)
else:
collection = self._collection
if delete_all:
count = collection.count()
# Delete collection and recreate
self._client.delete_collection(collection.name)
self._collection = self._client.get_or_create_collection(
name=self.namespace,
metadata={"hnsw:space": "cosine"}
)
return count
if ids:
collection.delete(ids=ids)
return len(ids)
if filter:
# Get matching IDs first
results = collection.get(where=filter)
if results and results['ids']:
collection.delete(ids=results['ids'])
return len(results['ids'])
return 0
def count(self, namespace: Optional[str] = None) -> int:
"""Get count of vectors."""
if namespace and namespace != self.namespace:
collection = self._client.get_or_create_collection(name=namespace)
else:
collection = self._collection
return collection.count()
def get(
self,
ids: List[str],
namespace: Optional[str] = None
) -> List[Any]:
"""Get vectors by ID."""
from praisonaiagents.knowledge.vector_store import VectorRecord
if namespace and namespace != self.namespace:
collection = self._client.get_or_create_collection(name=namespace)
else:
collection = self._collection
results = collection.get(ids=ids, include=["documents", "metadatas", "embeddings"])
records = []
if results and results['ids']:
for i, id_ in enumerate(results['ids']):
records.append(VectorRecord(
id=id_,
text=results['documents'][i] if results.get('documents') else "",
embedding=results['embeddings'][i] if results.get('embeddings') else [],
metadata=results['metadatas'][i] if results.get('metadatas') else {}
))
return records
class PineconeVectorStore:
"""Pinecone vector store adapter."""
name: str = "pinecone"
def __init__(
self,
config: Optional[Dict[str, Any]] = None,
namespace: Optional[str] = None,
api_key: Optional[str] = None,
index_name: Optional[str] = None
):
if not _check_pinecone():
raise ImportError(
"pinecone is required for PineconeVectorStore. "
"Install with: pip install pinecone"
)
from pinecone import Pinecone
self.config = config or {}
self.namespace = namespace or "default"
self.api_key = api_key or os.environ.get("PINECONE_API_KEY")
self.index_name = index_name or self.config.get("index_name", "praisonai")
if not self.api_key:
raise ValueError("PINECONE_API_KEY environment variable or api_key parameter required")
self._pc = Pinecone(api_key=self.api_key)
self._index = self._pc.Index(self.index_name)
def add(
self,
texts: List[str],
embeddings: List[List[float]],
metadatas: Optional[List[Dict[str, Any]]] = None,
ids: Optional[List[str]] = None,
namespace: Optional[str] = None
) -> List[str]:
"""Add vectors to Pinecone."""
ids = ids or [str(uuid_module.uuid4()) for _ in texts]
metadatas = metadatas or [{} for _ in texts]
ns = namespace or self.namespace
# Add text to metadata for retrieval
vectors = []
for i, (id_, embedding, metadata) in enumerate(zip(ids, embeddings, metadatas)):
meta = metadata.copy()
meta["text"] = texts[i]
vectors.append({
"id": id_,
"values": embedding,
"metadata": meta
})
self._index.upsert(vectors=vectors, namespace=ns)
return ids
def query(
self,
embedding: List[float],
top_k: int = 10,
namespace: Optional[str] = None,
filter: Optional[Dict[str, Any]] = None
) -> List[Any]:
"""Query Pinecone by similarity."""
from praisonaiagents.knowledge.vector_store import VectorRecord
ns = namespace or self.namespace
results = self._index.query(
vector=embedding,
top_k=top_k,
namespace=ns,
filter=filter,
include_metadata=True
)
records = []
for match in results.get("matches", []):
metadata = match.get("metadata", {})
text = metadata.pop("text", "")
records.append(VectorRecord(
id=match["id"],
text=text,
embedding=embedding,
metadata=metadata,
score=match.get("score", 0.0)
))
return records
def delete(
self,
ids: Optional[List[str]] = None,
namespace: Optional[str] = None,
filter: Optional[Dict[str, Any]] = None,
delete_all: bool = False
) -> int:
"""Delete vectors from Pinecone."""
ns = namespace or self.namespace
if delete_all:
self._index.delete(delete_all=True, namespace=ns)
return -1 # Pinecone doesn't return count
if ids:
self._index.delete(ids=ids, namespace=ns)
return len(ids)
if filter:
self._index.delete(filter=filter, namespace=ns)
return -1
return 0
def count(self, namespace: Optional[str] = None) -> int:
"""Get count of vectors."""
ns = namespace or self.namespace
stats = self._index.describe_index_stats()
ns_stats = stats.get("namespaces", {}).get(ns, {})
return ns_stats.get("vector_count", 0)
def get(
self,
ids: List[str],
namespace: Optional[str] = None
) -> List[Any]:
"""Get vectors by ID."""
from praisonaiagents.knowledge.vector_store import VectorRecord
ns = namespace or self.namespace
results = self._index.fetch(ids=ids, namespace=ns)
records = []
for id_, data in results.get("vectors", {}).items():
metadata = data.get("metadata", {})
text = metadata.pop("text", "")
records.append(VectorRecord(
id=id_,
text=text,
embedding=data.get("values", []),
metadata=metadata
))
return records
def register_default_vector_stores():
"""Register all default vector stores with the registry."""
from praisonaiagents.knowledge.vector_store import get_vector_store_registry
registry = get_vector_store_registry()
# Register ChromaDB
if _check_chroma():
registry.register("chroma", ChromaVectorStore)
# Register Pinecone
if _check_pinecone():
registry.register("pinecone", PineconeVectorStore)
logger.debug("Registered default vector stores")
# Auto-register on import
register_default_vector_stores()
+114
View File
@@ -0,0 +1,114 @@
"""
PraisonAI Chat Module
This module provides the chat UI integration for PraisonAI agents.
It uses the PraisonAI Chat (based on Chainlit) for the frontend.
"""
from typing import Optional, Any
__all__ = ["start_chat_server", "ChatConfig"]
class ChatConfig:
"""Configuration for the PraisonAI Chat server."""
def __init__(
self,
host: str = "0.0.0.0",
port: int = 8000,
debug: bool = False,
auth_enabled: bool = False,
session_id: Optional[str] = None,
):
self.host = host
self.port = port
self.debug = debug
self.auth_enabled = auth_enabled
self.session_id = session_id
def start_chat_server(
agent: Optional[Any] = None,
agents: Optional[list] = None,
config: Optional[ChatConfig] = None,
port: int = 8000,
host: str = "0.0.0.0",
debug: bool = False,
) -> None:
"""
Start the PraisonAI Chat server.
Args:
agent: A single PraisonAI agent to use in the chat.
agents: A list of PraisonAI agents for multi-agent chat.
config: ChatConfig object with server settings.
port: Port to run the server on (default: 8000).
host: Host to bind to (default: 0.0.0.0).
debug: Enable debug mode (default: False).
Example:
>>> from praisonaiagents import Agent
>>> from praisonai.chat import start_chat_server
>>>
>>> agent = Agent(name="Assistant", instructions="You are helpful.")
>>> start_chat_server(agent=agent, port=8000)
"""
# Lazy import to avoid loading chainlit unless needed
try:
from chainlit.cli import run_chainlit
except ImportError:
raise ImportError(
"PraisonAI Chat requires the 'praisonai-chat' package. "
"Install it with: pip install praisonai-chat"
)
if config is None:
config = ChatConfig(host=host, port=port, debug=debug)
# Store agents in a way accessible to the chainlit app
import os
os.environ["PRAISONAI_CHAT_MODE"] = "true"
if agent is not None:
# Single agent mode
_register_agent(agent)
elif agents is not None:
# Multi-agent mode
for a in agents:
_register_agent(a)
# Start the chainlit server
import sys
from pathlib import Path
# Use the built-in app file
app_file = Path(__file__).parent / "app.py"
sys.argv = [
"chainlit",
"run",
str(app_file),
"--host", config.host,
"--port", str(config.port),
]
if config.debug:
sys.argv.append("-w")
run_chainlit()
# Global registry for agents
_REGISTERED_AGENTS: dict = {}
def _register_agent(agent: Any) -> None:
"""Register an agent for use in the chat UI."""
agent_name = getattr(agent, "name", None) or getattr(agent, "role", "Agent")
_REGISTERED_AGENTS[agent_name] = agent
def get_registered_agents() -> dict:
"""Get all registered agents."""
return _REGISTERED_AGENTS
+125
View File
@@ -0,0 +1,125 @@
"""
PraisonAI Chat - Default Chainlit Application
This is the default chat application that runs when using `praisonai chat`.
It integrates with PraisonAI agents for multi-agent conversations.
"""
import os
import chainlit as cl
# Check if we're in PraisonAI Chat mode with registered agents
PRAISONAI_CHAT_MODE = os.environ.get("PRAISONAI_CHAT_MODE", "false").lower() == "true"
def get_agents():
"""Get registered agents from the chat module."""
try:
from praisonai.chat import get_registered_agents
return get_registered_agents()
except ImportError:
return {}
@cl.on_chat_start
async def on_chat_start():
"""Initialize the chat session."""
agents = get_agents()
if agents:
agent_names = list(agents.keys())
cl.user_session.set("agents", agents)
cl.user_session.set("current_agent", agent_names[0] if agent_names else None)
await cl.Message(
content=f"Welcome to PraisonAI Chat! Available agents: {', '.join(agent_names)}"
).send()
else:
await cl.Message(
content="Welcome to PraisonAI Chat! No agents configured. "
"Use the API to register agents or configure via YAML."
).send()
cl.user_session.set("message_history", [])
@cl.on_message
async def on_message(message: cl.Message):
"""Handle incoming messages."""
agents = cl.user_session.get("agents", {})
current_agent_name = cl.user_session.get("current_agent")
message_history = cl.user_session.get("message_history", [])
# Add user message to history
message_history.append({"role": "user", "content": message.content})
if not agents or not current_agent_name:
# No agents configured - use a simple echo response
response = f"Echo: {message.content}\n\n(No agents configured. Register agents to enable AI responses.)"
await cl.Message(content=response).send()
return
agent = agents.get(current_agent_name)
if agent is None:
await cl.Message(content=f"Agent '{current_agent_name}' not found.").send()
return
# Create a step for the agent response
async with cl.Step(name=current_agent_name, type="llm") as step:
step.input = message.content
try:
# Try to call the agent
if hasattr(agent, "chat"):
# PraisonAI Agent with chat method
response = await _call_agent_async(agent, message.content)
elif hasattr(agent, "run"):
# Agent with run method
response = await _call_agent_async(agent, message.content, method="run")
elif callable(agent):
# Callable agent
response = agent(message.content)
else:
response = f"Agent '{current_agent_name}' does not have a callable interface."
step.output = str(response)
except Exception as e:
response = f"Error calling agent: {str(e)}"
step.output = response
# Add assistant response to history
message_history.append({"role": "assistant", "content": str(response)})
cl.user_session.set("message_history", message_history)
# Send the response
await cl.Message(content=str(response)).send()
async def _call_agent_async(agent, message: str, method: str = "chat"):
"""Call an agent method, handling both sync and async."""
import asyncio
func = getattr(agent, method)
if asyncio.iscoroutinefunction(func):
return await func(message)
else:
# Run sync function in thread pool
loop = asyncio.get_event_loop()
return await loop.run_in_executor(None, func, message)
@cl.on_chat_resume
async def on_chat_resume(thread):
"""Resume a chat session."""
message_history = []
root_messages = [m for m in thread.get("steps", []) if m.get("parentId") is None]
for msg in root_messages:
if msg.get("type") == "user_message":
message_history.append({"role": "user", "content": msg.get("output", "")})
elif msg.get("type") == "assistant_message":
message_history.append({"role": "assistant", "content": msg.get("output", "")})
cl.user_session.set("message_history", message_history)
@@ -0,0 +1,595 @@
"""
Jobs CLI Feature for PraisonAI.
Provides CLI commands for managing async jobs.
Commands:
- praisonai run submit <prompt> # Submit a new job
- praisonai run status <job_id> # Get job status
- praisonai run result <job_id> # Get job result
- praisonai run cancel <job_id> # Cancel a job
- praisonai run list # List jobs
"""
import sys
import json
import time
from typing import Optional, List
class JobsHandler:
"""
Handler for jobs CLI commands.
Provides functionality to:
- Submit jobs to the API
- Check job status
- Get job results
- Cancel jobs
- List jobs
"""
def __init__(
self,
api_url: str = "http://127.0.0.1:8005",
verbose: bool = False
):
self.api_url = api_url.rstrip("/")
self.verbose = verbose
@property
def feature_name(self) -> str:
return "jobs"
def _get_client(self):
"""Get HTTP client."""
try:
import httpx
return httpx.Client(timeout=30.0)
except ImportError:
raise RuntimeError("httpx is required. Install with: pip install httpx")
def submit(
self,
prompt: str,
agent_file: Optional[str] = None,
framework: str = "praisonai",
timeout: int = 3600,
wait: bool = False,
poll_interval: int = 5,
idempotency_key: Optional[str] = None,
webhook_url: Optional[str] = None,
session_id: Optional[str] = None,
output_json: bool = False
) -> dict:
"""
Submit a new job.
Args:
prompt: The prompt/task for the agent
agent_file: Optional path to agents.yaml
framework: Framework to use
timeout: Job timeout in seconds
wait: If True, wait for completion
poll_interval: Seconds between status polls
Returns:
Job submission response
"""
client = self._get_client()
payload = {
"prompt": prompt,
"framework": framework,
"timeout": timeout
}
if agent_file:
payload["agent_file"] = agent_file
if webhook_url:
payload["webhook_url"] = webhook_url
if session_id:
payload["session_id"] = session_id
if idempotency_key:
payload["idempotency_key"] = idempotency_key
headers = {}
if idempotency_key:
headers["Idempotency-Key"] = idempotency_key
try:
response = client.post(
f"{self.api_url}/api/v1/runs",
json=payload,
headers=headers if headers else None
)
response.raise_for_status()
result = response.json()
if output_json:
print(json.dumps(result, indent=2))
else:
self._print_success(f"Job submitted: {result['job_id']}")
self._print_info(f"Status URL: {result['poll_url']}")
if wait:
return self._wait_for_completion(result['job_id'], poll_interval, output_json)
return result
except Exception as e:
self._print_error(f"Failed to submit job: {e}")
raise
def status(self, job_id: str) -> dict:
"""
Get job status.
Args:
job_id: Job ID
Returns:
Job status response
"""
client = self._get_client()
try:
response = client.get(f"{self.api_url}/api/v1/runs/{job_id}")
response.raise_for_status()
result = response.json()
self._print_job_status(result)
return result
except Exception as e:
self._print_error(f"Failed to get status: {e}")
raise
def result(self, job_id: str) -> dict:
"""
Get job result.
Args:
job_id: Job ID
Returns:
Job result response
"""
client = self._get_client()
try:
response = client.get(f"{self.api_url}/api/v1/runs/{job_id}/result")
response.raise_for_status()
result = response.json()
self._print_job_result(result)
return result
except Exception as e:
self._print_error(f"Failed to get result: {e}")
raise
def cancel(self, job_id: str) -> dict:
"""
Cancel a job.
Args:
job_id: Job ID
Returns:
Updated job status
"""
client = self._get_client()
try:
response = client.post(f"{self.api_url}/api/v1/runs/{job_id}/cancel")
response.raise_for_status()
result = response.json()
self._print_success(f"Job cancelled: {job_id}")
return result
except Exception as e:
self._print_error(f"Failed to cancel job: {e}")
raise
def list_jobs(
self,
status: Optional[str] = None,
page: int = 1,
page_size: int = 20
) -> dict:
"""
List jobs.
Args:
status: Optional status filter
page: Page number
page_size: Jobs per page
Returns:
Job list response
"""
client = self._get_client()
params = {"page": page, "page_size": page_size}
if status:
params["status"] = status
try:
response = client.get(
f"{self.api_url}/api/v1/runs",
params=params
)
response.raise_for_status()
result = response.json()
self._print_job_list(result)
return result
except Exception as e:
self._print_error(f"Failed to list jobs: {e}")
raise
def _wait_for_completion(self, job_id: str, poll_interval: int = 5, output_json: bool = False) -> dict:
"""Wait for a job to complete."""
client = self._get_client()
if not output_json:
self._print_info("Waiting for job completion...")
while True:
try:
response = client.get(f"{self.api_url}/api/v1/runs/{job_id}")
response.raise_for_status()
result = response.json()
status = result.get("status")
progress = result.get("progress", {}).get("percentage", 0)
if not output_json:
self._print_progress(status, progress)
if status in ("succeeded", "failed", "cancelled"):
if output_json:
# Get full result for succeeded jobs
if status == "succeeded":
res = client.get(f"{self.api_url}/api/v1/runs/{job_id}/result")
res.raise_for_status()
print(json.dumps(res.json(), indent=2))
else:
print(json.dumps(result, indent=2))
return result
else:
if status == "succeeded":
return self.result(job_id)
else:
return result
# Honor Retry-After header if present
retry_after = result.get("retry_after") or poll_interval
time.sleep(retry_after)
except KeyboardInterrupt:
if not output_json:
self._print_info("\nInterrupted. Job continues running in background.")
return result
except Exception as e:
if not output_json:
self._print_error(f"Error polling status: {e}")
time.sleep(poll_interval)
def stream(self, job_id: str, output_json: bool = False) -> None:
"""
Stream job progress via SSE.
Args:
job_id: Job ID
output_json: If True, output raw JSON events
"""
try:
import httpx
with httpx.stream(
"GET",
f"{self.api_url}/api/v1/runs/{job_id}/stream",
timeout=None
) as response:
response.raise_for_status()
for line in response.iter_lines():
if not line:
continue
if line.startswith("data:"):
data = line[5:].strip()
if data == "[DONE]":
if not output_json:
self._print_success("Stream completed")
break
try:
event = json.loads(data)
if output_json:
print(json.dumps(event))
else:
event_type = event.get("event", "unknown")
if event_type == "progress":
pct = event.get("data", {}).get("percentage", 0)
step = event.get("data", {}).get("current_step", "")
self._print_progress(f"{step}", pct)
elif event_type == "complete":
status = event.get("data", {}).get("status", "")
if status == "succeeded":
self._print_success(f"Job completed: {status}")
else:
self._print_error(f"Job ended: {status}")
elif event_type == "error":
self._print_error(event.get("data", {}).get("error", "Unknown error"))
except json.JSONDecodeError:
if not output_json:
print(data)
elif line.startswith(":"):
# Heartbeat comment, ignore
pass
except Exception as e:
self._print_error(f"Stream error: {e}")
raise
def _print_job_status(self, job: dict):
"""Print job status."""
try:
from rich.console import Console
from rich.panel import Panel
console = Console()
status = job.get("status", "unknown")
status_color = {
"queued": "dim",
"running": "yellow",
"succeeded": "green",
"failed": "red",
"cancelled": "dim"
}.get(status, "white")
progress = job.get("progress", {})
content = f"""
[bold]Job ID:[/bold] {job.get('job_id')}
[bold]Status:[/bold] [{status_color}]{status}[/{status_color}]
[bold]Progress:[/bold] {progress.get('percentage', 0):.0f}%
[bold]Step:[/bold] {progress.get('current_step') or '-'}
[bold]Created:[/bold] {job.get('created_at')}
[bold]Started:[/bold] {job.get('started_at') or '-'}
"""
if job.get("error"):
content += f"[bold]Error:[/bold] [red]{job.get('error')}[/red]\n"
console.print(Panel(content.strip(), title="Job Status"))
except ImportError:
print(f"Job: {job.get('job_id')}")
print(f" Status: {job.get('status')}")
print(f" Progress: {job.get('progress', {}).get('percentage', 0):.0f}%")
def _print_job_result(self, job: dict):
"""Print job result."""
try:
from rich.console import Console
from rich.panel import Panel
from rich.markdown import Markdown
console = Console()
status = job.get("status")
if status == "succeeded":
result = job.get("result", "")
if isinstance(result, str):
console.print(Panel(Markdown(result), title="Result"))
else:
console.print(Panel(json.dumps(result, indent=2), title="Result"))
else:
console.print(f"[red]Job {status}: {job.get('error')}[/red]")
except ImportError:
print(f"Result: {job.get('result')}")
def _print_job_list(self, response: dict):
"""Print job list."""
try:
from rich.console import Console
from rich.table import Table
console = Console()
jobs = response.get("jobs", [])
if not jobs:
console.print("[yellow]No jobs found[/yellow]")
return
table = Table(title=f"Jobs (Page {response.get('page')}/{(response.get('total', 0) + response.get('page_size', 20) - 1) // response.get('page_size', 20)})")
table.add_column("ID", style="cyan")
table.add_column("Status", style="yellow")
table.add_column("Progress", style="blue")
table.add_column("Created", style="dim")
for job in jobs:
status = job.get("status", "unknown")
status_color = {
"queued": "dim",
"running": "yellow",
"succeeded": "green",
"failed": "red",
"cancelled": "dim"
}.get(status, "white")
progress = job.get("progress", {}).get("percentage", 0)
table.add_row(
job.get("job_id", "?"),
f"[{status_color}]{status}[/{status_color}]",
f"{progress:.0f}%",
job.get("created_at", "")[:19]
)
console.print(table)
console.print(f"Total: {response.get('total', 0)} jobs")
except ImportError:
print("Jobs:")
for job in response.get("jobs", []):
print(f" {job.get('job_id')} - {job.get('status')}")
def _print_progress(self, status: str, percentage: float):
"""Print progress update."""
try:
from rich.console import Console
console = Console()
console.print(f" Status: {status} | Progress: {percentage:.0f}%", end="\r")
except ImportError:
print(f" Status: {status} | Progress: {percentage:.0f}%", end="\r")
def _print_success(self, message: str):
"""Print success message."""
try:
from rich.console import Console
Console().print(f"[green]✓[/green] {message}")
except ImportError:
print(f"✓ {message}")
def _print_error(self, message: str):
"""Print error message."""
try:
from rich.console import Console
Console().print(f"[red]✗[/red] {message}")
except ImportError:
print(f"✗ {message}")
def _print_info(self, message: str):
"""Print info message."""
try:
from rich.console import Console
Console().print(f"[blue]ℹ[/blue] {message}")
except ImportError:
print(f"ℹ {message}")
def handle_run_command(args: List[str], verbose: bool = False):
"""
Handle run CLI commands.
Usage:
praisonai run submit "Your prompt here" [--wait] [--agent-file agents.yaml]
praisonai run status <job_id>
praisonai run result <job_id>
praisonai run cancel <job_id>
praisonai run list [--status <status>]
"""
import argparse
parser = argparse.ArgumentParser(prog="praisonai run", description="Manage async jobs")
subparsers = parser.add_subparsers(dest="subcommand", help="Available commands")
# Submit command
submit_parser = subparsers.add_parser("submit", help="Submit a new job")
submit_parser.add_argument("prompt", help="The prompt/task for the agent")
submit_parser.add_argument("--agent-file", help="Path to agents.yaml")
submit_parser.add_argument("--framework", default="praisonai", help="Framework to use")
submit_parser.add_argument("--timeout", type=int, default=3600, help="Timeout in seconds")
submit_parser.add_argument("--wait", action="store_true", help="Wait for completion")
submit_parser.add_argument("--idempotency-key", help="Idempotency key to prevent duplicates")
submit_parser.add_argument("--webhook-url", help="Webhook URL for completion callback")
submit_parser.add_argument("--session-id", help="Session ID for grouping jobs")
submit_parser.add_argument("--json", dest="output_json", action="store_true", help="Output JSON for scripting")
submit_parser.add_argument("--api-url", default="http://127.0.0.1:8005", help="API server URL")
# Status command
status_parser = subparsers.add_parser("status", help="Get job status")
status_parser.add_argument("job_id", help="Job ID")
status_parser.add_argument("--json", dest="output_json", action="store_true", help="Output JSON")
status_parser.add_argument("--api-url", default="http://127.0.0.1:8005", help="API server URL")
# Result command
result_parser = subparsers.add_parser("result", help="Get job result")
result_parser.add_argument("job_id", help="Job ID")
result_parser.add_argument("--json", dest="output_json", action="store_true", help="Output JSON")
result_parser.add_argument("--api-url", default="http://127.0.0.1:8005", help="API server URL")
# Cancel command
cancel_parser = subparsers.add_parser("cancel", help="Cancel a job")
cancel_parser.add_argument("job_id", help="Job ID")
cancel_parser.add_argument("--json", dest="output_json", action="store_true", help="Output JSON")
cancel_parser.add_argument("--api-url", default="http://127.0.0.1:8005", help="API server URL")
# List command
list_parser = subparsers.add_parser("list", help="List jobs")
list_parser.add_argument("--status", help="Filter by status")
list_parser.add_argument("--page", type=int, default=1, help="Page number")
list_parser.add_argument("--page-size", type=int, default=20, help="Jobs per page")
list_parser.add_argument("--json", dest="output_json", action="store_true", help="Output JSON")
list_parser.add_argument("--api-url", default="http://127.0.0.1:8005", help="API server URL")
# Stream command
stream_parser = subparsers.add_parser("stream", help="Stream job progress via SSE")
stream_parser.add_argument("job_id", help="Job ID")
stream_parser.add_argument("--json", dest="output_json", action="store_true", help="Output raw JSON events")
stream_parser.add_argument("--api-url", default="http://127.0.0.1:8005", help="API server URL")
if not args:
parser.print_help()
return
parsed = parser.parse_args(args)
if not parsed.subcommand:
parser.print_help()
return
handler = JobsHandler(
api_url=getattr(parsed, "api_url", "http://127.0.0.1:8005"),
verbose=verbose
)
try:
if parsed.subcommand == "submit":
handler.submit(
prompt=parsed.prompt,
agent_file=parsed.agent_file,
framework=parsed.framework,
timeout=parsed.timeout,
wait=parsed.wait,
idempotency_key=getattr(parsed, "idempotency_key", None),
webhook_url=getattr(parsed, "webhook_url", None),
session_id=getattr(parsed, "session_id", None),
output_json=getattr(parsed, "output_json", False)
)
elif parsed.subcommand == "status":
result = handler.status(parsed.job_id)
if getattr(parsed, "output_json", False):
print(json.dumps(result, indent=2))
elif parsed.subcommand == "result":
result = handler.result(parsed.job_id)
if getattr(parsed, "output_json", False):
print(json.dumps(result, indent=2))
elif parsed.subcommand == "cancel":
result = handler.cancel(parsed.job_id)
if getattr(parsed, "output_json", False):
print(json.dumps(result, indent=2))
elif parsed.subcommand == "list":
result = handler.list_jobs(
status=parsed.status,
page=parsed.page,
page_size=parsed.page_size
)
if getattr(parsed, "output_json", False):
print(json.dumps(result, indent=2))
elif parsed.subcommand == "stream":
handler.stream(
job_id=parsed.job_id,
output_json=getattr(parsed, "output_json", False)
)
except Exception:
if verbose:
import traceback
traceback.print_exc()
sys.exit(1)
+200 -51
View File
@@ -1,13 +1,19 @@
"""
Knowledge Handler for CLI.
Provides RAG/vector store management.
Usage: praisonai knowledge add document.pdf
praisonai knowledge search "query"
Provides RAG/vector store management with full knowledge stack support.
Usage:
praisonai knowledge add document.pdf
praisonai knowledge add ./docs/
praisonai knowledge query "How to authenticate?"
praisonai knowledge list
praisonai knowledge clear
praisonai knowledge stats
"""
import os
from typing import Any, Dict, List
from typing import Any, Dict, List, Optional
from .base import CommandHandler
@@ -17,111 +23,238 @@ class KnowledgeHandler(CommandHandler):
Manages knowledge base for RAG (Retrieval Augmented Generation).
Supports:
- Multiple vector stores (chroma, pinecone, qdrant, weaviate, memory)
- Retrieval strategies (basic, fusion, recursive, auto_merge)
- Rerankers (simple, llm, cross_encoder, cohere)
- Index types (vector, keyword, hybrid)
- Query modes (default, sub_question, summarize)
Example:
praisonai knowledge add document.pdf
praisonai knowledge add ./docs/
praisonai knowledge search "How to authenticate?"
praisonai knowledge query "How to authenticate?" --retrieval fusion
praisonai knowledge list
praisonai knowledge clear
praisonai knowledge stats
"""
def __init__(self, verbose: bool = False, workspace: str = None):
def __init__(
self,
verbose: bool = False,
workspace: str = None,
vector_store: str = "chroma",
retrieval_strategy: str = "basic",
reranker: Optional[str] = None,
index_type: str = "vector",
query_mode: str = "default",
session_id: Optional[str] = None,
db: Optional[str] = None
):
super().__init__(verbose)
self.workspace = workspace or os.getcwd()
self.vector_store = vector_store
self.retrieval_strategy = retrieval_strategy
self.reranker = reranker
self.index_type = index_type
self.query_mode = query_mode
self.session_id = session_id
self.db = db
self._knowledge = None
self._embedding_fn = None
@property
def feature_name(self) -> str:
return "knowledge"
def get_actions(self) -> List[str]:
return ["add", "search", "list", "clear", "info", "help"]
return ["add", "query", "search", "list", "clear", "stats", "info", "help"]
def get_help_text(self) -> str:
return """
Knowledge Commands:
praisonai knowledge add <file|dir> - Add document(s) to knowledge base
praisonai knowledge search <query> - Search knowledge base
praisonai knowledge list - List indexed documents
praisonai knowledge clear - Clear knowledge base
praisonai knowledge info - Show knowledge base info
praisonai knowledge add <file|dir|url> - Add document(s) to knowledge base
praisonai knowledge query "<question>" - Query knowledge base with RAG
praisonai knowledge list - List indexed documents
praisonai knowledge clear - Clear knowledge base
praisonai knowledge stats - Show knowledge base statistics
praisonai knowledge help - Show this help message
Options:
--workspace <path> - Workspace directory (default: current)
--workspace <path> - Workspace directory (default: current)
--vector-store <name> - Vector store backend
Values: memory, chroma, pinecone, qdrant, weaviate
Default: chroma
--retrieval-strategy <strategy> - Retrieval strategy
Values: basic, fusion, recursive, auto_merge
Default: basic
--reranker <name> - Reranker for result ordering
Values: none, simple, llm, cross_encoder, cohere
Default: none
--index-type <type> - Index type for search
Values: vector, keyword, hybrid
Default: vector
--query-mode <mode> - Query processing mode
Values: default, sub_question, summarize
Default: default
--session <id> - Session ID for persistence
--db <path> - Database path for persistence
Examples:
praisonai knowledge add document.pdf
praisonai knowledge add ./docs/
praisonai knowledge query "How to authenticate?" --retrieval-strategy fusion
praisonai knowledge query "Compare approaches" --reranker simple --query-mode sub_question
"""
def _get_knowledge(self):
"""Lazy load Knowledge instance."""
"""Lazy load Knowledge instance with configured options."""
if self._knowledge is None:
try:
from praisonaiagents import Knowledge
self._knowledge = Knowledge()
# Build config based on options
config = {
"vector_store": {
"provider": self.vector_store,
"config": {
"path": os.path.join(self.workspace, ".praison", "knowledge")
}
}
}
self._knowledge = Knowledge(config=config)
except ImportError:
self.print_status(
"Knowledge requires praisonaiagents. Install with: pip install praisonaiagents",
"Knowledge requires praisonaiagents. Install with: pip install praisonaiagents[knowledge]",
"error"
)
return None
return self._knowledge
def _get_embedding_fn(self):
"""Get embedding function."""
if self._embedding_fn is None:
try:
import openai
client = openai.OpenAI()
def embed(text: str) -> List[float]:
response = client.embeddings.create(
input=text,
model="text-embedding-3-small"
)
return response.data[0].embedding
self._embedding_fn = embed
except Exception as e:
self.print_status(f"Failed to initialize embeddings: {e}", "warning")
return self._embedding_fn
def action_add(self, args: List[str], **kwargs) -> bool:
"""
Add document(s) to knowledge base.
Supports:
- File paths (pdf, docx, txt, md, html, etc.)
- Directories (recursive)
- Glob patterns (*.pdf, docs/**/*.md)
- URLs (http://, https://)
Args:
args: List containing file/directory paths
args: List containing file/directory/URL paths
Returns:
True if successful
"""
if not args:
self.print_status("Usage: praisonai knowledge add <file|directory>", "error")
self.print_status("Usage: praisonai knowledge add <file|directory|url|glob>", "error")
return False
knowledge = self._get_knowledge()
if not knowledge:
return False
path = args[0]
source = args[0]
# Check if URL
if source.startswith(("http://", "https://")):
try:
knowledge.add(source)
self.print_status(f"✅ Added URL: {source}", "success")
return True
except Exception as e:
self.print_status(f"Failed to add URL: {e}", "error")
return False
# Expand path
if not os.path.isabs(path):
path = os.path.join(self.workspace, path)
if not os.path.isabs(source):
source = os.path.join(self.workspace, source)
if not os.path.exists(path):
self.print_status(f"Path not found: {path}", "error")
# Check for glob pattern
if "*" in source or "?" in source:
import glob
files = glob.glob(source, recursive=True)
if not files:
self.print_status(f"No files match pattern: {source}", "warning")
return False
count = 0
for file_path in files:
if os.path.isfile(file_path):
try:
knowledge.add(file_path)
count += 1
except Exception as e:
self.print_status(f"Failed to add {file_path}: {e}", "warning")
self.print_status(f"✅ Added {count} files matching {args[0]}", "success")
return True
if not os.path.exists(source):
self.print_status(f"Path not found: {source}", "error")
return False
try:
if os.path.isdir(path):
if os.path.isdir(source):
# Add all files in directory
count = 0
for root, _, files in os.walk(path):
for root, _, files in os.walk(source):
for file in files:
file_path = os.path.join(root, file)
knowledge.add(file_path)
count += 1
self.print_status(f"✅ Added {count} files from {path}", "success")
try:
knowledge.add(file_path)
count += 1
except Exception as e:
if self.verbose:
self.print_status(f"Skipped {file}: {e}", "warning")
self.print_status(f"✅ Added {count} files from {source}", "success")
else:
knowledge.add(path)
self.print_status(f"✅ Added: {path}", "success")
knowledge.add(source)
self.print_status(f"✅ Added: {source}", "success")
return True
except Exception as e:
self.print_status(f"Failed to add: {e}", "error")
return False
def action_search(self, args: List[str], **kwargs) -> List[Dict[str, Any]]:
"""Alias for action_query."""
return self.action_query(args, **kwargs)
def action_query(self, args: List[str], **kwargs) -> List[Dict[str, Any]]:
"""
Search knowledge base.
Query knowledge base with RAG.
Uses configured retrieval strategy, reranker, and query mode.
Args:
args: List containing search query
args: List containing query
Returns:
List of search results
List of results with answer and sources
"""
if not args:
self.print_status("Usage: praisonai knowledge search <query>", "error")
self.print_status("Usage: praisonai knowledge query <question>", "error")
return []
knowledge = self._get_knowledge()
@@ -132,19 +265,25 @@ Options:
limit = kwargs.get('limit', 5)
try:
results = knowledge.search(query, k=limit)
# Use knowledge.search for basic retrieval
results = knowledge.search(query, limit=limit)
if results:
self.print_status(f"\n🔍 Search results for '{query}':\n", "info")
self.print_status(f"\n🔍 Results for '{query}':\n", "info")
for i, result in enumerate(results, 1):
content = str(result)[:200]
self.print_status(f" {i}. {content}...", "info")
if isinstance(result, dict):
content = result.get('memory', result.get('text', str(result)))[:200]
score = result.get('score', 'N/A')
self.print_status(f" {i}. [score: {score}] {content}...", "info")
else:
content = str(result)[:200]
self.print_status(f" {i}. {content}...", "info")
else:
self.print_status("No results found", "warning")
return results
except Exception as e:
self.print_status(f"Search failed: {e}", "error")
self.print_status(f"Query failed: {e}", "error")
return []
def action_list(self, args: List[str], **kwargs) -> List[str]:
@@ -201,35 +340,45 @@ Options:
self.print_status(f"Failed to clear: {e}", "error")
return False
def action_info(self, args: List[str], **kwargs) -> Dict[str, Any]:
def action_stats(self, args: List[str], **kwargs) -> Dict[str, Any]:
"""
Show knowledge base info.
Show knowledge base statistics.
Returns:
Dictionary of info
Dictionary of statistics
"""
knowledge = self._get_knowledge()
if not knowledge:
return {}
info = {
stats = {
"workspace": self.workspace,
"type": type(knowledge).__name__
"vector_store": self.vector_store,
"retrieval_strategy": self.retrieval_strategy,
"reranker": self.reranker or "none",
"index_type": self.index_type,
"query_mode": self.query_mode
}
try:
if hasattr(knowledge, 'get_stats'):
info.update(knowledge.get_stats())
if hasattr(knowledge, 'count'):
info['document_count'] = knowledge.count()
# Try to get document count from memory
if hasattr(knowledge, 'memory'):
mem = knowledge.memory
if hasattr(mem, 'get_all'):
all_docs = mem.get_all()
stats['document_count'] = len(all_docs) if all_docs else 0
except Exception:
pass
self.print_status("\n📊 Knowledge Base Info:", "info")
for key, value in info.items():
self.print_status("\n📊 Knowledge Base Statistics:", "info")
for key, value in stats.items():
self.print_status(f" {key}: {value}", "info")
return info
return stats
def action_info(self, args: List[str], **kwargs) -> Dict[str, Any]:
"""Alias for action_stats."""
return self.action_stats(args, **kwargs)
def execute(self, action: str, action_args: List[str], **kwargs) -> Any:
"""Execute knowledge command action."""
@@ -0,0 +1,293 @@
"""
Observability CLI Handler
CLI commands for managing observability providers.
Usage: praisonai obs <command>
"""
import os
from typing import Any, Dict, List, Optional, Tuple
from .base import FlagHandler
class ObservabilityHandler(FlagHandler):
"""
Handler for observability CLI commands.
Commands:
praisonai obs list - List available providers
praisonai obs doctor - Check provider connectivity
praisonai obs init <name> - Initialize a provider
"""
@property
def feature_name(self) -> str:
return "observability"
@property
def flag_name(self) -> str:
return "obs"
@property
def flag_help(self) -> str:
return "Observability commands (list, doctor, init)"
def check_dependencies(self) -> Tuple[bool, str]:
"""Check if observability module is available."""
try:
from praisonai_tools.observability import obs
return True, ""
except ImportError:
return False, "Observability requires praisonai-tools. Install with: pip install praisonai-tools"
def list_providers(self) -> Dict[str, Any]:
"""List all available observability providers."""
result = {
"registered": [],
"available": [],
"configured": [],
}
try:
from praisonai_tools.observability.manager import ObservabilityManager
from praisonai_tools.observability.config import PROVIDER_ENV_KEYS
mgr = ObservabilityManager()
# Get all registered providers
result["registered"] = mgr.list_providers()
# Check which have env vars configured
for provider, keys in PROVIDER_ENV_KEYS.items():
if all(os.getenv(key) for key in keys):
result["configured"].append(provider)
# Check which SDKs are available
for provider in result["registered"]:
try:
mgr._load_provider(provider)
if provider in mgr._providers:
provider_class = mgr._providers[provider]
instance = provider_class()
if instance.is_available():
result["available"].append(provider)
except Exception:
pass
except ImportError:
pass
return result
def doctor(self, provider: Optional[str] = None) -> Dict[str, Any]:
"""Run diagnostics on observability setup."""
try:
from praisonai_tools.observability import obs
if provider:
obs.init(provider=provider)
return obs.doctor()
except ImportError:
return {
"error": "praisonai-tools not installed",
"enabled": False,
}
def init_provider(self, provider: str, **kwargs) -> bool:
"""Initialize a specific provider."""
try:
from praisonai_tools.observability import obs
return obs.init(provider=provider, **kwargs)
except ImportError:
return False
def execute(self, action: str = "list", **kwargs) -> Any:
"""
Execute observability command.
Args:
action: Command to execute (list, doctor, init)
**kwargs: Additional arguments
Returns:
Command result
"""
if action == "list":
return self._execute_list()
elif action == "doctor":
return self._execute_doctor(kwargs.get("provider"))
elif action == "init":
return self._execute_init(kwargs.get("provider"), **kwargs)
else:
self.print_status(f"Unknown action: {action}", "error")
return None
def _execute_list(self) -> Dict[str, Any]:
"""Execute list command."""
result = self.list_providers()
self.print_status("\n📊 Observability Providers", "info")
self.print_status("=" * 40, "info")
# Provider status table
all_providers = [
"agentops", "langfuse", "langsmith", "traceloop",
"arize_phoenix", "openlit", "langtrace", "langwatch",
"datadog", "mlflow", "opik", "portkey",
"braintrust", "maxim", "weave", "neatlogs",
"langdb", "atla", "patronus", "truefoundry",
]
for provider in all_providers:
status_parts = []
if provider in result.get("available", []):
status_parts.append("✅ SDK")
else:
status_parts.append("❌ SDK")
if provider in result.get("configured", []):
status_parts.append("🔑 Keys")
else:
status_parts.append("⚪ Keys")
status = " | ".join(status_parts)
self.print_status(f" {provider:20} {status}", "info")
self.print_status("\n✅ = Available ❌ = Not installed 🔑 = API key configured", "info")
return result
def _execute_doctor(self, provider: Optional[str] = None) -> Dict[str, Any]:
"""Execute doctor command."""
result = self.doctor(provider)
self.print_status("\n🩺 Observability Diagnostics", "info")
self.print_status("=" * 40, "info")
self.print_status(f" Enabled: {result.get('enabled', False)}", "info")
self.print_status(f" Provider: {result.get('provider', 'None')}", "info")
if result.get("connection_status") is not None:
status = "✅" if result["connection_status"] else "❌"
self.print_status(f" Connection: {status} {result.get('connection_message', '')}", "info")
if result.get("available_providers"):
self.print_status(f" Available: {', '.join(result['available_providers'])}", "info")
return result
def _execute_init(self, provider: str, **kwargs) -> bool:
"""Execute init command."""
if not provider:
self.print_status("Provider name required. Usage: praisonai obs init <provider>", "error")
return False
success = self.init_provider(provider, **kwargs)
if success:
self.print_status(f"✅ {provider} initialized successfully", "success")
else:
self.print_status(f"❌ Failed to initialize {provider}", "error")
self.print_status(f" Check that {provider} SDK is installed and API keys are set", "info")
return success
def apply_to_agent_config(self, config: Dict[str, Any], flag_value: Any) -> Dict[str, Any]:
"""Apply observability to agent config."""
if flag_value:
try:
from praisonai_tools.observability import obs
if isinstance(flag_value, str):
obs.init(provider=flag_value)
else:
obs.init()
self.print_status("📊 Observability enabled", "success")
except ImportError:
self.print_status("Observability requires praisonai-tools", "warning")
return config
# Provider environment variable reference
PROVIDER_SETUP_GUIDE = {
"agentops": {
"env_vars": ["AGENTOPS_API_KEY"],
"install": "pip install agentops",
"docs": "https://agentops.ai/",
},
"langfuse": {
"env_vars": ["LANGFUSE_PUBLIC_KEY", "LANGFUSE_SECRET_KEY"],
"install": "pip install opentelemetry-sdk opentelemetry-exporter-otlp",
"docs": "https://langfuse.com/",
},
"langsmith": {
"env_vars": ["LANGSMITH_API_KEY"],
"install": "pip install opentelemetry-sdk opentelemetry-exporter-otlp",
"docs": "https://smith.langchain.com/",
},
"traceloop": {
"env_vars": ["TRACELOOP_API_KEY"],
"install": "pip install traceloop-sdk",
"docs": "https://traceloop.com/",
},
"arize_phoenix": {
"env_vars": ["PHOENIX_API_KEY"],
"install": "pip install arize-phoenix openinference-instrumentation",
"docs": "https://phoenix.arize.com/",
},
"openlit": {
"env_vars": [],
"install": "pip install openlit",
"docs": "https://github.com/openlit/openlit",
},
"langtrace": {
"env_vars": ["LANGTRACE_API_KEY"],
"install": "pip install langtrace-python-sdk",
"docs": "https://langtrace.ai/",
},
"langwatch": {
"env_vars": ["LANGWATCH_API_KEY"],
"install": "pip install langwatch",
"docs": "https://langwatch.ai/",
},
"datadog": {
"env_vars": ["DD_API_KEY"],
"install": "pip install ddtrace",
"docs": "https://www.datadoghq.com/product/llm-observability/",
},
"mlflow": {
"env_vars": ["MLFLOW_TRACKING_URI"],
"install": "pip install mlflow",
"docs": "https://mlflow.org/",
},
"opik": {
"env_vars": ["OPIK_API_KEY"],
"install": "pip install opik",
"docs": "https://www.comet.com/docs/opik/",
},
"portkey": {
"env_vars": ["PORTKEY_API_KEY"],
"install": "pip install portkey-ai",
"docs": "https://portkey.ai/",
},
"braintrust": {
"env_vars": ["BRAINTRUST_API_KEY"],
"install": "pip install braintrust",
"docs": "https://www.braintrust.dev/",
},
"maxim": {
"env_vars": ["MAXIM_API_KEY"],
"install": "pip install maxim-py",
"docs": "https://getmaxim.ai/",
},
"weave": {
"env_vars": ["WANDB_API_KEY"],
"install": "pip install weave",
"docs": "https://weave-docs.wandb.ai/",
},
}
@@ -290,10 +290,9 @@ def handle_run(args: list) -> bool:
# Run agent with persistence
try:
from praisonai.db import PraisonDB
from praisonaiagents import Agent
from praisonaiagents import Agent, db
db = PraisonDB(
db_instance = db(
database_url=config["conversation_url"],
state_url=config["state_url"],
knowledge_url=config["knowledge_url"],
@@ -302,7 +301,7 @@ def handle_run(args: list) -> bool:
agent = Agent(
name=config["agent_name"],
instructions=config["agent_instructions"],
db=db,
db=db_instance,
session_id=config["session_id"],
verbose=True
)
@@ -311,7 +310,7 @@ def handle_run(args: list) -> bool:
response = agent.chat(config["prompt"])
print(f"\nAgent: {response}")
db.close()
db_instance.close()
except Exception as e:
print(f"Error: {e}")
@@ -334,12 +333,12 @@ def handle_resume(args: list) -> bool:
return True
try:
from praisonai.db import PraisonDB
from praisonaiagents import db
db = PraisonDB(database_url=config["conversation_url"])
db_instance = db(database_url=config["conversation_url"])
# Get session history
history = db.on_agent_start(
history = db_instance.on_agent_start(
agent_name="Resume",
session_id=config["session_id"],
user_id=config["user_id"]
@@ -362,7 +361,7 @@ def handle_resume(args: list) -> bool:
agent = Agent(
name=config["agent_name"],
instructions=config["agent_instructions"],
db=db,
db=db_instance,
session_id=config["session_id"],
verbose=True
)
@@ -370,7 +369,7 @@ def handle_resume(args: list) -> bool:
response = agent.chat(config["continue_prompt"])
print(f"\nAgent: {response}")
db.close()
db_instance.close()
except Exception as e:
print(f"Error: {e}")
@@ -392,17 +391,17 @@ def handle_export(args: list) -> bool:
return True
try:
from praisonai.db import PraisonDB
from praisonaiagents import db
import json
db = PraisonDB(database_url=config["conversation_url"])
db_instance = db(database_url=config["conversation_url"])
# Export session
data = db.export_session(config["session_id"])
data = db_instance.export_session(config["session_id"])
if not data:
print(f"Error: Session {config['session_id']} not found")
db.close()
db_instance.close()
return True
# Determine output file
@@ -414,7 +413,7 @@ def handle_export(args: list) -> bool:
print(f"Exported session to: {output_file}")
print(f"Messages: {len(data.get('messages', []))}")
db.close()
db_instance.close()
except Exception as e:
print(f"Error: {e}")
@@ -437,20 +436,20 @@ def handle_import(args: list) -> bool:
return True
try:
from praisonai.db import PraisonDB
from praisonaiagents import db
import json
db = PraisonDB(database_url=config["conversation_url"])
db_instance = db(database_url=config["conversation_url"])
with open(input_file, 'r') as f:
data = json.loads(f.readline())
session_id = db.import_session(data)
session_id = db_instance.import_session(data)
print(f"Imported session: {session_id}")
print(f"Messages: {len(data.get('messages', []))}")
db.close()
db_instance.close()
except Exception as e:
print(f"Error: {e}")
@@ -469,15 +468,15 @@ def handle_migrate(args: list) -> bool:
return True
try:
from praisonai.db import PraisonDB
from praisonaiagents import db
from praisonai.persistence.migrations import MigrationManager
db = PraisonDB(
db_instance = db(
database_url=config["conversation_url"],
state_url=config["state_url"]
)
manager = MigrationManager(db)
manager = MigrationManager(db_instance)
# Check for --up or --down flags
direction = "up"
@@ -506,7 +505,7 @@ def handle_migrate(args: list) -> bool:
else:
print("No migrations to rollback")
db.close()
db_instance.close()
except Exception as e:
print(f"Error: {e}")
@@ -524,15 +523,15 @@ def handle_status(args: list) -> bool:
return True
try:
from praisonai.db import PraisonDB
from praisonaiagents import db
from praisonai.persistence.migrations import MigrationManager
db = PraisonDB(
db_instance = db(
database_url=config["conversation_url"],
state_url=config["state_url"]
)
manager = MigrationManager(db)
manager = MigrationManager(db_instance)
status = manager.get_migration_status()
print("\n=== Schema Status ===")
@@ -543,7 +542,7 @@ def handle_status(args: list) -> bool:
if status['pending_versions']:
print(f"Pending versions: {', '.join(status['pending_versions'])}")
db.close()
db_instance.close()
except Exception as e:
print(f"Error: {e}")
+89 -5
View File
@@ -668,7 +668,7 @@ class PraisonAI:
return default_args
# Define special commands
special_commands = ['chat', 'code', 'call', 'realtime', 'train', 'ui', 'context', 'research', 'memory', 'rules', 'workflow', 'hooks', 'knowledge', 'session', 'tools', 'todo', 'docs', 'mcp', 'commit', 'serve', 'schedule', 'skills', 'profile', 'eval', 'agents']
special_commands = ['chat', 'code', 'call', 'realtime', 'train', 'ui', 'context', 'research', 'memory', 'rules', 'workflow', 'hooks', 'knowledge', 'session', 'tools', 'todo', 'docs', 'mcp', 'commit', 'serve', 'schedule', 'skills', 'profile', 'eval', 'agents', 'run']
parser = argparse.ArgumentParser(prog="praisonai", description="praisonAI command-line interface")
parser.add_argument("--framework", choices=["crewai", "autogen", "praisonai"], help="Specify the framework")
@@ -813,6 +813,15 @@ class PraisonAI:
parser.add_argument('--max-cost', type=float, help='Maximum total cost budget in USD')
parser.add_argument('--daemon', action='store_true', help=argparse.SUPPRESS) # Hidden flag for daemon mode
# Rate Limiter - control API request rate
parser.add_argument("--rpm", type=int, help="Rate limit: requests per minute for LLM calls")
parser.add_argument("--tpm", type=int, help="Rate limit: tokens per minute for LLM calls (optional)")
# Configurable Model - runtime model switching
parser.add_argument("--configurable-model", action="store_true", help="Enable runtime model switching via config parameter")
parser.add_argument("--temperature", type=float, help="Override temperature for LLM calls")
parser.add_argument("--llm-provider", type=str, help="Override LLM provider (openai, anthropic, google, etc.)")
# If we're in a test environment, parse with empty args to avoid pytest interference
if in_test_env:
args, unknown_args = parser.parse_known_args([])
@@ -1147,6 +1156,12 @@ class PraisonAI:
exit_code = handle_agents_command(agents_args)
sys.exit(exit_code)
elif args.command == 'run':
# Run command - async jobs API for long-running tasks
from .features.jobs import handle_run_command
handle_run_command(unknown_args, verbose=getattr(args, 'verbose', False))
sys.exit(0)
# Only check framework availability for agent-related operations
if not args.command and (args.init or args.auto or args.framework):
@@ -2333,13 +2348,82 @@ class PraisonAI:
Handle knowledge subcommand actions.
Args:
action: The knowledge action (add, search, list, clear, info)
action_args: Additional arguments for the action
action: The knowledge action (add, query, list, clear, stats, help)
action_args: Additional arguments for the action (may include flags)
"""
try:
from .features.knowledge import KnowledgeHandler
handler = KnowledgeHandler(verbose=True, workspace=os.getcwd())
handler.execute(action, action_args)
# Parse flags from action_args
vector_store = "chroma"
retrieval_strategy = "basic"
reranker = None
index_type = "vector"
query_mode = "default"
session_id = None
db = None
workspace = os.getcwd()
# Filter out flags and extract values
filtered_args = []
i = 0
while i < len(action_args):
arg = action_args[i]
if arg in ("--vector-store", "--store"):
if i + 1 < len(action_args):
vector_store = action_args[i + 1]
i += 2
continue
elif arg in ("--retrieval-strategy", "--retrieval", "--strategy"):
if i + 1 < len(action_args):
retrieval_strategy = action_args[i + 1]
i += 2
continue
elif arg == "--reranker":
if i + 1 < len(action_args):
reranker = action_args[i + 1]
i += 2
continue
elif arg in ("--index-type", "--index"):
if i + 1 < len(action_args):
index_type = action_args[i + 1]
i += 2
continue
elif arg in ("--query-mode", "--mode"):
if i + 1 < len(action_args):
query_mode = action_args[i + 1]
i += 2
continue
elif arg == "--session":
if i + 1 < len(action_args):
session_id = action_args[i + 1]
i += 2
continue
elif arg == "--db":
if i + 1 < len(action_args):
db = action_args[i + 1]
i += 2
continue
elif arg == "--workspace":
if i + 1 < len(action_args):
workspace = action_args[i + 1]
i += 2
continue
filtered_args.append(arg)
i += 1
handler = KnowledgeHandler(
verbose=True,
workspace=workspace,
vector_store=vector_store,
retrieval_strategy=retrieval_strategy,
reranker=reranker,
index_type=index_type,
query_mode=query_mode,
session_id=session_id,
db=db
)
handler.execute(action, filtered_args)
except ImportError as e:
print(f"[red]ERROR: Failed to import knowledge module: {e}[/red]")
print("Make sure praisonaiagents is installed: pip install praisonaiagents")
+64
View File
@@ -0,0 +1,64 @@
"""
PraisonAI Database - Ultra-simple persistence for agents.
RECOMMENDED (simplified import):
from praisonaiagents import Agent, db
# or: from praisonai import Agent, db
agent = Agent(
name="Assistant",
db=db(database_url="postgresql://localhost/mydb"),
session_id="my-session"
)
agent.chat("Hello!") # auto-persists + can resume
DEPRECATED (will be removed in v3.0):
from praisonai.db import PraisonDB # Use db(...) instead
Supported backends:
- PostgreSQL, MySQL, SQLite (conversation)
- Qdrant, ChromaDB, Pinecone (knowledge/vector)
- Redis, Memory (state)
"""
import warnings
__all__ = [
"PraisonDB",
"PostgresDB",
"SQLiteDB",
"RedisDB",
]
# Deprecation warning message
_DEPRECATION_MSG = (
"Importing from 'praisonai.db' is deprecated and will be removed in v3.0. "
"Use the simplified import instead:\n"
" from praisonaiagents import Agent, db\n"
" # or: from praisonai import Agent, db\n"
"Then use: db=db(database_url='...')"
)
# Lazy imports to avoid loading heavy dependencies
def __getattr__(name: str):
if name == "PraisonDB":
warnings.warn(_DEPRECATION_MSG, DeprecationWarning, stacklevel=2)
from .adapter import PraisonDB
return PraisonDB
if name == "PostgresDB":
warnings.warn(_DEPRECATION_MSG, DeprecationWarning, stacklevel=2)
from .adapter import PostgresDB
return PostgresDB
if name == "SQLiteDB":
warnings.warn(_DEPRECATION_MSG, DeprecationWarning, stacklevel=2)
from .adapter import SQLiteDB
return SQLiteDB
if name == "RedisDB":
warnings.warn(_DEPRECATION_MSG, DeprecationWarning, stacklevel=2)
from .adapter import RedisDB
return RedisDB
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
+531
View File
@@ -0,0 +1,531 @@
"""
PraisonDB adapter - implements the DbAdapter protocol from praisonaiagents.
This module provides the bridge between the core Agent's db interface
and the wrapper's persistence layer (PersistenceOrchestrator).
"""
import time
import logging
from typing import Any, Dict, List, Optional
logger = logging.getLogger(__name__)
class PraisonDB:
"""
Universal database adapter for PraisonAI agents.
Automatically selects backends based on URL or explicit configuration.
Implements the DbAdapter protocol expected by praisonaiagents.Agent.
Example:
from praisonaiagents import Agent
from praisonai.db import PraisonDB
# Simple usage with PostgreSQL
db = PraisonDB(database_url="postgresql://localhost/mydb")
agent = Agent(name="Assistant", db=db, session_id="my-session")
agent.chat("Hello!")
# With multiple backends
db = PraisonDB(
database_url="postgresql://localhost/mydb",
state_url="redis://localhost:6379"
)
"""
def __init__(
self,
database_url: Optional[str] = None,
state_url: Optional[str] = None,
knowledge_url: Optional[str] = None,
**options
):
"""
Initialize PraisonDB adapter.
Args:
database_url: URL for conversation storage (postgres, mysql, sqlite)
state_url: URL for state storage (redis, etc.)
knowledge_url: URL for knowledge/vector storage (qdrant, etc.)
**options: Additional backend-specific options
"""
self._database_url = database_url
self._state_url = state_url
self._knowledge_url = knowledge_url
self._options = options
# Lazy-loaded stores
self._conversation_store = None
self._state_store = None
self._knowledge_store = None
self._initialized = False
def _init_stores(self):
"""Lazily initialize stores on first use."""
if self._initialized:
return
# Import persistence layer
from ..persistence.factory import (
create_conversation_store,
create_state_store,
create_knowledge_store,
)
# Initialize conversation store
if self._database_url:
backend = self._detect_backend(self._database_url)
self._conversation_store = create_conversation_store(
backend, url=self._database_url, **self._options
)
# Initialize state store
if self._state_url:
backend = self._detect_backend(self._state_url)
self._state_store = create_state_store(
backend, url=self._state_url, **self._options
)
# Initialize knowledge store
if self._knowledge_url:
backend = self._detect_backend(self._knowledge_url)
self._knowledge_store = create_knowledge_store(
backend, url=self._knowledge_url, **self._options
)
self._initialized = True
def _detect_backend(self, url: str) -> str:
"""Detect backend type from URL."""
url_lower = url.lower()
if url_lower.startswith("postgresql://") or url_lower.startswith("postgres://"):
return "postgres"
elif url_lower.startswith("mysql://"):
return "mysql"
elif url_lower.startswith("sqlite://") or url_lower.endswith(".db"):
return "sqlite"
elif url_lower.startswith("redis://"):
return "redis"
elif url_lower.startswith("http://") or url_lower.startswith("https://"):
# Could be Qdrant, Weaviate, etc.
if "qdrant" in url_lower or ":6333" in url_lower:
return "qdrant"
elif "weaviate" in url_lower:
return "weaviate"
# Default fallback
return "sqlite"
def on_agent_start(
self,
agent_name: str,
session_id: str,
user_id: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> List:
"""Called when agent starts - returns previous messages for resume."""
self._init_stores()
if not self._conversation_store:
return []
from ..persistence.conversation.base import ConversationSession
# Check if session exists
session = self._conversation_store.get_session(session_id)
if session is None:
# Create new session
session = ConversationSession(
session_id=session_id,
user_id=user_id or "default",
agent_id=agent_name,
name=f"Session {session_id}",
metadata=metadata or {}
)
self._conversation_store.create_session(session)
return []
# Resume existing session - get messages
messages = self._conversation_store.get_messages(session_id)
# Convert to DbMessage format
from praisonaiagents.db.protocol import DbMessage
return [
DbMessage(
role=msg.role,
content=msg.content,
metadata=msg.metadata or {},
timestamp=msg.created_at or time.time(),
id=msg.id
)
for msg in messages
]
def on_user_message(
self,
session_id: str,
content: str,
metadata: Optional[Dict[str, Any]] = None,
) -> None:
"""Called when user sends a message."""
if not self._conversation_store:
return
from ..persistence.conversation.base import ConversationMessage
import uuid
msg = ConversationMessage(
id=f"msg-{uuid.uuid4().hex[:12]}",
session_id=session_id,
role="user",
content=content,
metadata=metadata or {},
created_at=time.time()
)
self._conversation_store.add_message(session_id, msg)
def on_agent_message(
self,
session_id: str,
content: str,
metadata: Optional[Dict[str, Any]] = None,
) -> None:
"""Called when agent produces a response."""
if not self._conversation_store:
return
from ..persistence.conversation.base import ConversationMessage
import uuid
msg = ConversationMessage(
id=f"msg-{uuid.uuid4().hex[:12]}",
session_id=session_id,
role="assistant",
content=content,
metadata=metadata or {},
created_at=time.time()
)
self._conversation_store.add_message(session_id, msg)
def on_tool_call(
self,
session_id: str,
tool_name: str,
args: Dict[str, Any],
result: Any,
metadata: Optional[Dict[str, Any]] = None,
) -> None:
"""Called when a tool is executed."""
if not self._conversation_store:
return
from ..persistence.conversation.base import ConversationMessage
import uuid
import json
# Store tool call as a special message
tool_content = json.dumps({
"tool": tool_name,
"args": args,
"result": str(result)[:1000] # Truncate large results
})
msg = ConversationMessage(
id=f"tool-{uuid.uuid4().hex[:12]}",
session_id=session_id,
role="tool",
content=tool_content,
metadata={"tool_name": tool_name, **(metadata or {})},
created_at=time.time()
)
self._conversation_store.add_message(session_id, msg)
def on_agent_end(
self,
session_id: str,
metadata: Optional[Dict[str, Any]] = None,
) -> None:
"""Called when agent session ends."""
if not self._conversation_store:
return
# Update session metadata
session = self._conversation_store.get_session(session_id)
if session:
session.metadata = {**(session.metadata or {}), "ended_at": time.time()}
self._conversation_store.update_session(session)
def on_run_start(
self,
session_id: str,
run_id: str,
input_content: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> None:
"""Called when a new run (turn) starts."""
if not self._conversation_store:
return
# Store run start in state if available
if self._state_store:
run_key = f"run:{session_id}:{run_id}"
self._state_store.set(run_key, {
"run_id": run_id,
"session_id": session_id,
"started_at": time.time(),
"input_content": input_content,
"status": "running",
"metadata": metadata or {}
})
def on_run_end(
self,
session_id: str,
run_id: str,
output_content: Optional[str] = None,
status: str = "completed",
metrics: Optional[Dict[str, Any]] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> None:
"""Called when a run (turn) ends."""
if self._state_store:
run_key = f"run:{session_id}:{run_id}"
run_data = self._state_store.get(run_key) or {}
run_data.update({
"ended_at": time.time(),
"output_content": output_content,
"status": status,
"metrics": metrics or {},
"metadata": {**run_data.get("metadata", {}), **(metadata or {})}
})
self._state_store.set(run_key, run_data)
def get_runs(
self,
session_id: str,
limit: Optional[int] = None,
) -> List:
"""Get runs for a session."""
# For now, runs are stored in state store
# Full implementation would query from conversation store
return []
def export_session(
self,
session_id: str,
) -> Dict[str, Any]:
"""Export a session as a dictionary."""
self._init_stores()
if not self._conversation_store:
return {}
session = self._conversation_store.get_session(session_id)
if not session:
return {}
messages = self._conversation_store.get_messages(session_id)
return {
"session_id": session.session_id,
"user_id": session.user_id,
"agent_id": session.agent_id,
"name": session.name,
"metadata": session.metadata,
"created_at": session.created_at,
"messages": [
{
"id": msg.id,
"role": msg.role,
"content": msg.content,
"metadata": msg.metadata,
"created_at": msg.created_at
}
for msg in messages
]
}
def import_session(
self,
data: Dict[str, Any],
) -> str:
"""Import a session from a dictionary."""
self._init_stores()
if not self._conversation_store:
raise ValueError("No conversation store configured")
from ..persistence.conversation.base import ConversationSession, ConversationMessage
import uuid
session_id = data.get("session_id") or f"imported-{uuid.uuid4().hex[:8]}"
# Create session
session = ConversationSession(
session_id=session_id,
user_id=data.get("user_id", "default"),
agent_id=data.get("agent_id", "imported"),
name=data.get("name", f"Imported Session {session_id}"),
metadata=data.get("metadata", {})
)
self._conversation_store.create_session(session)
# Import messages with new IDs to avoid conflicts
for msg_data in data.get("messages", []):
msg = ConversationMessage(
id=f"msg-{uuid.uuid4().hex[:12]}", # Always generate new ID
session_id=session_id,
role=msg_data.get("role", "user"),
content=msg_data.get("content", ""),
metadata=msg_data.get("metadata", {}),
created_at=msg_data.get("created_at", time.time())
)
self._conversation_store.add_message(session_id, msg)
return session_id
# --- Tracing/Observability ---
def on_trace_start(
self,
trace_id: str,
session_id: Optional[str] = None,
run_id: Optional[str] = None,
agent_name: Optional[str] = None,
user_id: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> None:
"""Called when a new trace starts."""
if self._state_store:
trace_key = f"trace:{trace_id}"
self._state_store.set(trace_key, {
"trace_id": trace_id,
"session_id": session_id,
"run_id": run_id,
"agent_name": agent_name,
"user_id": user_id,
"started_at": time.time(),
"status": "running",
"spans": [],
"metadata": metadata or {}
})
def on_trace_end(
self,
trace_id: str,
status: str = "ok",
metadata: Optional[Dict[str, Any]] = None,
) -> None:
"""Called when a trace ends."""
if self._state_store:
trace_key = f"trace:{trace_id}"
trace_data = self._state_store.get(trace_key) or {}
trace_data.update({
"ended_at": time.time(),
"status": status,
"metadata": {**trace_data.get("metadata", {}), **(metadata or {})}
})
self._state_store.set(trace_key, trace_data)
def on_span_start(
self,
span_id: str,
trace_id: str,
name: str,
parent_span_id: Optional[str] = None,
attributes: Optional[Dict[str, Any]] = None,
) -> None:
"""Called when a new span starts."""
if self._state_store:
span_key = f"span:{span_id}"
self._state_store.set(span_key, {
"span_id": span_id,
"trace_id": trace_id,
"name": name,
"parent_span_id": parent_span_id,
"started_at": time.time(),
"status": "running",
"attributes": attributes or {},
"events": []
})
def on_span_end(
self,
span_id: str,
status: str = "ok",
attributes: Optional[Dict[str, Any]] = None,
) -> None:
"""Called when a span ends."""
if self._state_store:
span_key = f"span:{span_id}"
span_data = self._state_store.get(span_key) or {}
span_data.update({
"ended_at": time.time(),
"status": status,
"attributes": {**span_data.get("attributes", {}), **(attributes or {})}
})
self._state_store.set(span_key, span_data)
def get_traces(
self,
session_id: Optional[str] = None,
user_id: Optional[str] = None,
limit: Optional[int] = None,
) -> List:
"""Get traces with optional filters."""
# For now, return empty list - full implementation would scan state store
return []
def close(self) -> None:
"""Close all database connections."""
if self._conversation_store:
self._conversation_store.close()
if self._state_store:
self._state_store.close()
if self._knowledge_store:
self._knowledge_store.close()
class PostgresDB(PraisonDB):
"""PostgreSQL-specific database adapter."""
def __init__(
self,
host: str = "localhost",
port: int = 5432,
database: str = "praisonai",
user: str = "postgres",
password: str = "",
**options
):
url = f"postgresql://{user}:{password}@{host}:{port}/{database}"
super().__init__(database_url=url, **options)
class SQLiteDB(PraisonDB):
"""SQLite-specific database adapter."""
def __init__(self, path: str = "praisonai.db", database_url: str = None, **options):
# Allow either path or database_url
url = database_url or path
super().__init__(database_url=url, **options)
def _detect_backend(self, url: str) -> str:
return "sqlite"
class RedisDB(PraisonDB):
"""Redis-specific database adapter (for state only)."""
def __init__(
self,
host: str = "localhost",
port: int = 6379,
**options
):
url = f"redis://{host}:{port}"
super().__init__(state_url=url, **options)
+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.2 gunicorn markdown\n")
file.write("RUN pip install flask praisonai==2.5.2 gunicorn markdown\n")
file.write("EXPOSE 8080\n")
file.write('CMD ["gunicorn", "-b", "0.0.0.0:8080", "api:app"]\n')
+67
View File
@@ -0,0 +1,67 @@
"""
Async Jobs API for PraisonAI.
Provides HTTP API endpoints for long-running agent tasks:
- Submit jobs (POST /api/v1/runs)
- Check status (GET /api/v1/runs/{job_id})
- Get results (GET /api/v1/runs/{job_id}/result)
- Cancel jobs (POST /api/v1/runs/{job_id}/cancel)
- Stream progress (GET /api/v1/runs/{job_id}/stream)
Zero Performance Impact:
- All imports are lazy loaded via __getattr__
- Server only starts when explicitly requested
- No overhead when not in use
Usage:
# Start the jobs server
praisonai serve --port 8005
# Or programmatically
from praisonai.jobs import start_server
start_server(port=8005)
"""
__all__ = [
# Models
"Job",
"JobStatus",
"JobSubmitRequest",
"JobStatusResponse",
"JobResultResponse",
# Store
"JobStore",
"InMemoryJobStore",
# Executor
"JobExecutor",
# Router
"create_router",
# Server
"start_server",
"create_app",
]
def __getattr__(name: str):
"""Lazy load module components to avoid import overhead."""
if name in ("Job", "JobStatus", "JobSubmitRequest", "JobStatusResponse", "JobResultResponse"):
from .models import Job, JobStatus, JobSubmitRequest, JobStatusResponse, JobResultResponse
return locals()[name]
if name in ("JobStore", "InMemoryJobStore"):
from .store import JobStore, InMemoryJobStore
return locals()[name]
if name == "JobExecutor":
from .executor import JobExecutor
return JobExecutor
if name == "create_router":
from .router import create_router
return create_router
if name in ("start_server", "create_app"):
from .server import start_server, create_app
return locals()[name]
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
+370
View File
@@ -0,0 +1,370 @@
"""
Job Executor for PraisonAI Async Jobs API.
Handles background execution of agent jobs.
"""
import asyncio
import logging
import os
from typing import Optional, Callable, Any, Dict
from .models import Job, JobStatus
from .store import JobStore
logger = logging.getLogger(__name__)
class JobExecutor:
"""
Executes jobs in the background using asyncio.
Features:
- Concurrent job execution with configurable limits
- Timeout handling
- Cancellation support
- Progress callbacks
- Webhook notifications
"""
def __init__(
self,
store: JobStore,
max_concurrent: int = 10,
default_timeout: int = 3600,
cleanup_interval: int = 300
):
self.store = store
self.max_concurrent = max_concurrent
self.default_timeout = default_timeout
self.cleanup_interval = cleanup_interval
self._semaphore = asyncio.Semaphore(max_concurrent)
self._running_tasks: Dict[str, asyncio.Task] = {}
self._cleanup_task: Optional[asyncio.Task] = None
self._shutdown = False
self._progress_callbacks: Dict[str, Callable] = {}
async def start(self):
"""Start the executor and cleanup loop."""
self._shutdown = False
self._cleanup_task = asyncio.create_task(self._cleanup_loop())
logger.info(f"JobExecutor started (max_concurrent={self.max_concurrent})")
async def stop(self):
"""Stop the executor and cancel all running jobs."""
self._shutdown = True
# Cancel cleanup task
if self._cleanup_task:
self._cleanup_task.cancel()
try:
await self._cleanup_task
except asyncio.CancelledError:
pass
# Cancel all running jobs
for job_id, task in list(self._running_tasks.items()):
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
self._running_tasks.clear()
logger.info("JobExecutor stopped")
async def _cleanup_loop(self):
"""Periodically clean up old completed jobs."""
while not self._shutdown:
try:
await asyncio.sleep(self.cleanup_interval)
await self.store.cleanup_old_jobs(max_age_seconds=86400) # 24 hours
except asyncio.CancelledError:
break
except Exception as e:
logger.error(f"Cleanup error: {e}")
async def submit(self, job: Job) -> Job:
"""
Submit a job for execution.
Args:
job: Job to execute
Returns:
The submitted job
"""
# Save job to store
await self.store.save(job)
# Start execution task
task = asyncio.create_task(self._execute_job(job))
self._running_tasks[job.id] = task
# Clean up task reference when done
task.add_done_callback(lambda t: self._running_tasks.pop(job.id, None))
logger.info(f"Job submitted: {job.id}")
return job
async def cancel(self, job_id: str) -> bool:
"""
Cancel a running job.
Args:
job_id: ID of job to cancel
Returns:
True if cancelled, False if not found or already complete
"""
job = await self.store.get(job_id)
if not job:
return False
if job.is_terminal:
return False
# Mark as cancelled
job.cancel()
await self.store.save(job)
# Cancel the task if running
task = self._running_tasks.get(job_id)
if task and not task.done():
task.cancel()
logger.info(f"Job cancelled: {job_id}")
return True
def register_progress_callback(self, job_id: str, callback: Callable):
"""Register a callback for job progress updates."""
self._progress_callbacks[job_id] = callback
def unregister_progress_callback(self, job_id: str):
"""Unregister a progress callback."""
self._progress_callbacks.pop(job_id, None)
async def _execute_job(self, job: Job):
"""Execute a job."""
async with self._semaphore:
try:
# Mark as running
job.start()
await self.store.save(job)
await self._notify_progress(job)
logger.info(f"Job started: {job.id}")
# Execute with timeout
timeout = job.timeout or self.default_timeout
result = await asyncio.wait_for(
self._run_agent(job),
timeout=timeout
)
# Mark as succeeded
job.succeed(result)
await self.store.save(job)
await self._notify_progress(job)
logger.info(f"Job succeeded: {job.id}")
# Send webhook if configured
if job.webhook_url:
await self._send_webhook(job)
except asyncio.TimeoutError:
job.fail(f"Job timed out after {job.timeout}s")
await self.store.save(job)
await self._notify_progress(job)
logger.warning(f"Job timed out: {job.id}")
except asyncio.CancelledError:
if job.status != JobStatus.CANCELLED:
job.cancel()
await self.store.save(job)
await self._notify_progress(job)
logger.info(f"Job cancelled: {job.id}")
raise
except Exception as e:
job.fail(str(e))
await self.store.save(job)
await self._notify_progress(job)
logger.error(f"Job failed: {job.id} - {e}")
# Send webhook for failures too
if job.webhook_url:
await self._send_webhook(job)
async def _run_agent(self, job: Job) -> Any:
"""
Run the agent for a job.
This is the core execution logic that runs the PraisonAI agent.
"""
# Imports are done lazily in _run_praisonai_agents and _run_legacy_praisonai
# Determine agent configuration
agent_file = job.agent_file or "agents.yaml"
framework = job.framework or "praisonai"
# Check if we should use inline YAML
if job.agent_yaml:
# Write to temp file
import tempfile
with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f:
f.write(job.agent_yaml)
agent_file = f.name
# Update progress
job.update_progress(percentage=10.0, step="Initializing agent")
await self.store.save(job)
await self._notify_progress(job)
try:
# Try praisonaiagents first (preferred)
if framework == "praisonai":
result = await self._run_praisonai_agents(job, agent_file)
else:
# Use legacy PraisonAI for crewai/autogen
result = await self._run_legacy_praisonai(job, agent_file, framework)
return result
finally:
# Clean up temp file if created
if job.agent_yaml and agent_file != "agents.yaml":
try:
os.unlink(agent_file)
except Exception:
pass
async def _run_praisonai_agents(self, job: Job, agent_file: str) -> Any:
"""Run using praisonaiagents framework."""
try:
from praisonaiagents import Agent
except ImportError:
raise RuntimeError("praisonaiagents not installed")
# Update progress
job.update_progress(percentage=20.0, step="Creating agent")
await self.store.save(job)
await self._notify_progress(job)
# Create agent
agent = Agent(
instructions="You are a helpful AI assistant.",
verbose=False
)
# Update job with agent info
job.agent_id = getattr(agent, 'name', 'agent')
job.run_id = getattr(agent, 'run_id', None)
# Update progress
job.update_progress(percentage=30.0, step="Running agent")
await self.store.save(job)
await self._notify_progress(job)
# Run the agent
loop = asyncio.get_event_loop()
result = await loop.run_in_executor(
None,
lambda: agent.start(job.prompt)
)
# Update progress
job.update_progress(percentage=90.0, step="Finalizing")
await self.store.save(job)
await self._notify_progress(job)
return result
async def _run_legacy_praisonai(self, job: Job, agent_file: str, framework: str) -> Any:
"""Run using legacy PraisonAI (crewai/autogen)."""
try:
from praisonai import PraisonAI
except ImportError:
raise RuntimeError("praisonai not installed")
# Update progress
job.update_progress(percentage=20.0, step="Creating PraisonAI instance")
await self.store.save(job)
await self._notify_progress(job)
# Create PraisonAI instance
praisonai = PraisonAI(
agent_file=agent_file,
framework=framework
)
# Update progress
job.update_progress(percentage=30.0, step="Running agents")
await self.store.save(job)
await self._notify_progress(job)
# Run in executor (blocking call)
loop = asyncio.get_event_loop()
result = await loop.run_in_executor(None, praisonai.run)
# Update progress
job.update_progress(percentage=90.0, step="Finalizing")
await self.store.save(job)
await self._notify_progress(job)
return result
async def _notify_progress(self, job: Job):
"""Notify progress callback if registered."""
callback = self._progress_callbacks.get(job.id)
if callback:
try:
if asyncio.iscoroutinefunction(callback):
await callback(job)
else:
callback(job)
except Exception as e:
logger.warning(f"Progress callback error for {job.id}: {e}")
async def _send_webhook(self, job: Job):
"""Send webhook notification for job completion."""
if not job.webhook_url:
return
try:
import httpx
payload = {
"job_id": job.id,
"status": job.status.value,
"result": job.result if job.status == JobStatus.SUCCEEDED else None,
"error": job.error if job.status == JobStatus.FAILED else None,
"completed_at": job.completed_at.isoformat() if job.completed_at else None,
"duration_seconds": job.duration_seconds
}
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.post(
job.webhook_url,
json=payload,
headers={"Content-Type": "application/json"}
)
if response.status_code >= 400:
logger.warning(f"Webhook failed for {job.id}: {response.status_code}")
else:
logger.info(f"Webhook sent for {job.id}")
except Exception as e:
logger.error(f"Webhook error for {job.id}: {e}")
def get_stats(self) -> Dict[str, Any]:
"""Get executor statistics."""
return {
"running_jobs": len(self._running_tasks),
"max_concurrent": self.max_concurrent,
"default_timeout": self.default_timeout,
"shutdown": self._shutdown
}
+224
View File
@@ -0,0 +1,224 @@
"""
Job Models for PraisonAI Async Jobs API.
Defines the data structures for job submission, status, and results.
"""
import uuid
from enum import Enum
from typing import Optional, Dict, Any, List
from datetime import datetime
from pydantic import BaseModel, Field
class JobStatus(str, Enum):
"""Status of a job."""
QUEUED = "queued"
RUNNING = "running"
SUCCEEDED = "succeeded"
FAILED = "failed"
CANCELLED = "cancelled"
class JobSubmitRequest(BaseModel):
"""Request body for submitting a new job."""
prompt: str = Field(..., description="The prompt or task for the agent")
agent_file: Optional[str] = Field(None, description="Path to agents.yaml file")
agent_yaml: Optional[str] = Field(None, description="Inline agent YAML configuration")
framework: Optional[str] = Field("praisonai", description="Framework to use (praisonai, crewai, autogen)")
config: Optional[Dict[str, Any]] = Field(default_factory=dict, description="Additional configuration")
webhook_url: Optional[str] = Field(None, description="URL to POST results when complete")
timeout: Optional[int] = Field(3600, description="Timeout in seconds (default: 1 hour)")
session_id: Optional[str] = Field(None, description="Session ID for conversation continuity")
idempotency_key: Optional[str] = Field(None, description="Idempotency key to prevent duplicate submissions")
class JobSubmitResponse(BaseModel):
"""Response after submitting a job."""
job_id: str = Field(..., description="Unique job identifier")
status: JobStatus = Field(..., description="Current job status")
created_at: datetime = Field(..., description="Job creation timestamp")
poll_url: str = Field(..., description="URL to poll for status")
stream_url: str = Field(..., description="URL for SSE streaming")
class JobProgress(BaseModel):
"""Progress information for a running job."""
percentage: float = Field(0.0, ge=0.0, le=100.0, description="Completion percentage")
current_step: Optional[str] = Field(None, description="Current step description")
steps_completed: int = Field(0, description="Number of steps completed")
steps_total: Optional[int] = Field(None, description="Total number of steps")
class JobStatusResponse(BaseModel):
"""Response for job status query."""
job_id: str = Field(..., description="Unique job identifier")
status: JobStatus = Field(..., description="Current job status")
progress: JobProgress = Field(default_factory=JobProgress, description="Progress information")
created_at: datetime = Field(..., description="Job creation timestamp")
started_at: Optional[datetime] = Field(None, description="When job started running")
completed_at: Optional[datetime] = Field(None, description="When job completed")
agent_id: Optional[str] = Field(None, description="Current agent ID")
run_id: Optional[str] = Field(None, description="Run ID for tracing")
session_id: Optional[str] = Field(None, description="Session ID")
error: Optional[str] = Field(None, description="Error message if failed")
retry_after: Optional[int] = Field(None, description="Suggested seconds before next poll")
class JobResultResponse(BaseModel):
"""Response for job result query."""
job_id: str = Field(..., description="Unique job identifier")
status: JobStatus = Field(..., description="Final job status")
result: Optional[Any] = Field(None, description="Job result/output")
result_url: Optional[str] = Field(None, description="URL to fetch large results")
metrics: Optional[Dict[str, Any]] = Field(None, description="Execution metrics")
created_at: datetime = Field(..., description="Job creation timestamp")
started_at: Optional[datetime] = Field(None, description="When job started running")
completed_at: Optional[datetime] = Field(None, description="When job completed")
duration_seconds: Optional[float] = Field(None, description="Total execution time")
error: Optional[str] = Field(None, description="Error message if failed")
class JobListResponse(BaseModel):
"""Response for listing jobs."""
jobs: List[JobStatusResponse] = Field(..., description="List of jobs")
total: int = Field(..., description="Total number of jobs matching filter")
page: int = Field(1, description="Current page number")
page_size: int = Field(20, description="Number of jobs per page")
class Job(BaseModel):
"""Internal job representation with full state."""
id: str = Field(default_factory=lambda: f"run_{uuid.uuid4().hex[:12]}")
status: JobStatus = Field(default=JobStatus.QUEUED)
# Request data
prompt: str = Field(...)
agent_file: Optional[str] = Field(None)
agent_yaml: Optional[str] = Field(None)
framework: str = Field("praisonai")
config: Dict[str, Any] = Field(default_factory=dict)
webhook_url: Optional[str] = Field(None)
timeout: int = Field(3600)
session_id: Optional[str] = Field(None)
idempotency_key: Optional[str] = Field(None)
# Progress tracking
progress_percentage: float = Field(0.0)
progress_step: Optional[str] = Field(None)
steps_completed: int = Field(0)
steps_total: Optional[int] = Field(None)
# Attribution
agent_id: Optional[str] = Field(None)
run_id: Optional[str] = Field(None)
# Timestamps
created_at: datetime = Field(default_factory=datetime.utcnow)
started_at: Optional[datetime] = Field(None)
completed_at: Optional[datetime] = Field(None)
# Result
result: Optional[Any] = Field(None)
error: Optional[str] = Field(None)
metrics: Optional[Dict[str, Any]] = Field(None)
# Internal
_cancel_requested: bool = False
@property
def duration_seconds(self) -> Optional[float]:
"""Calculate job duration."""
if self.started_at is None:
return None
end_time = self.completed_at or datetime.utcnow()
return (end_time - self.started_at).total_seconds()
@property
def is_terminal(self) -> bool:
"""Check if job is in a terminal state."""
return self.status in (JobStatus.SUCCEEDED, JobStatus.FAILED, JobStatus.CANCELLED)
def to_status_response(self, base_url: str = "") -> JobStatusResponse:
"""Convert to status response."""
retry_after = None
if self.status == JobStatus.QUEUED:
retry_after = 2
elif self.status == JobStatus.RUNNING:
retry_after = 5
return JobStatusResponse(
job_id=self.id,
status=self.status,
progress=JobProgress(
percentage=self.progress_percentage,
current_step=self.progress_step,
steps_completed=self.steps_completed,
steps_total=self.steps_total
),
created_at=self.created_at,
started_at=self.started_at,
completed_at=self.completed_at,
agent_id=self.agent_id,
run_id=self.run_id,
session_id=self.session_id,
error=self.error,
retry_after=retry_after
)
def to_result_response(self) -> JobResultResponse:
"""Convert to result response."""
return JobResultResponse(
job_id=self.id,
status=self.status,
result=self.result,
result_url=None, # For large results, would be set
metrics=self.metrics,
created_at=self.created_at,
started_at=self.started_at,
completed_at=self.completed_at,
duration_seconds=self.duration_seconds,
error=self.error
)
def start(self):
"""Mark job as started."""
self.status = JobStatus.RUNNING
self.started_at = datetime.utcnow()
def succeed(self, result: Any, metrics: Optional[Dict[str, Any]] = None):
"""Mark job as succeeded."""
self.status = JobStatus.SUCCEEDED
self.result = result
self.metrics = metrics
self.completed_at = datetime.utcnow()
self.progress_percentage = 100.0
def fail(self, error: str):
"""Mark job as failed."""
self.status = JobStatus.FAILED
self.error = error
self.completed_at = datetime.utcnow()
def cancel(self):
"""Mark job as cancelled."""
self.status = JobStatus.CANCELLED
self.completed_at = datetime.utcnow()
self._cancel_requested = True
def update_progress(
self,
percentage: Optional[float] = None,
step: Optional[str] = None,
steps_completed: Optional[int] = None,
steps_total: Optional[int] = None
):
"""Update job progress."""
if percentage is not None:
self.progress_percentage = min(max(percentage, 0.0), 100.0)
if step is not None:
self.progress_step = step
if steps_completed is not None:
self.steps_completed = steps_completed
if steps_total is not None:
self.steps_total = steps_total
+314
View File
@@ -0,0 +1,314 @@
"""
FastAPI Router for PraisonAI Async Jobs API.
Provides HTTP endpoints for job management.
"""
import asyncio
import logging
from typing import Optional
from datetime import datetime
from fastapi import APIRouter, HTTPException, Header, Request, Query, Response
from sse_starlette.sse import EventSourceResponse
from .models import (
Job,
JobStatus,
JobSubmitRequest,
JobSubmitResponse,
JobStatusResponse,
JobResultResponse,
JobListResponse
)
from .store import JobStore
from .executor import JobExecutor
logger = logging.getLogger(__name__)
def create_router(store: JobStore, executor: JobExecutor) -> APIRouter:
"""
Create the jobs API router.
Args:
store: Job storage backend
executor: Job executor
Returns:
FastAPI router with all endpoints
"""
router = APIRouter(prefix="/api/v1/runs", tags=["jobs"])
@router.post("", response_model=JobSubmitResponse, status_code=202)
async def submit_job(
request: Request,
response: Response,
body: JobSubmitRequest,
idempotency_key: Optional[str] = Header(None, alias="Idempotency-Key")
):
"""
Submit a new job for execution.
Returns 202 Accepted with job ID and status URLs.
Headers returned:
- Location: URL to poll for status
- Retry-After: Suggested seconds before first poll (default: 2)
If an Idempotency-Key header is provided and a job with that key
already exists, returns the existing job instead of creating a new one.
"""
# Check idempotency key
effective_key = idempotency_key or body.idempotency_key
if effective_key:
existing = await store.get_by_idempotency_key(effective_key)
if existing:
logger.info(f"Returning existing job for idempotency key: {effective_key}")
base_url = str(request.base_url).rstrip("/")
poll_url = f"{base_url}/api/v1/runs/{existing.id}"
response.headers["Location"] = poll_url
response.headers["Retry-After"] = "2"
return JobSubmitResponse(
job_id=existing.id,
status=existing.status,
created_at=existing.created_at,
poll_url=poll_url,
stream_url=f"{base_url}/api/v1/runs/{existing.id}/stream"
)
# Create new job
job = Job(
prompt=body.prompt,
agent_file=body.agent_file,
agent_yaml=body.agent_yaml,
framework=body.framework or "praisonai",
config=body.config or {},
webhook_url=body.webhook_url,
timeout=body.timeout or 3600,
session_id=body.session_id,
idempotency_key=effective_key
)
# Submit for execution
await executor.submit(job)
# Build response with Location and Retry-After headers (RFC best practice)
base_url = str(request.base_url).rstrip("/")
poll_url = f"{base_url}/api/v1/runs/{job.id}"
response.headers["Location"] = poll_url
response.headers["Retry-After"] = "2"
return JobSubmitResponse(
job_id=job.id,
status=job.status,
created_at=job.created_at,
poll_url=poll_url,
stream_url=f"{base_url}/api/v1/runs/{job.id}/stream"
)
@router.get("", response_model=JobListResponse)
async def list_jobs(
status: Optional[str] = Query(None, description="Filter by status"),
session_id: Optional[str] = Query(None, description="Filter by session ID"),
page: int = Query(1, ge=1, description="Page number"),
page_size: int = Query(20, ge=1, le=100, description="Jobs per page")
):
"""
List jobs with optional filters.
"""
# Parse status filter
status_filter = None
if status:
try:
status_filter = JobStatus(status)
except ValueError:
raise HTTPException(
status_code=400,
detail=f"Invalid status: {status}. Valid values: {[s.value for s in JobStatus]}"
)
# Get jobs
offset = (page - 1) * page_size
jobs = await store.list_jobs(
status=status_filter,
session_id=session_id,
limit=page_size,
offset=offset
)
total = await store.count(status=status_filter, session_id=session_id)
return JobListResponse(
jobs=[job.to_status_response() for job in jobs],
total=total,
page=page,
page_size=page_size
)
@router.get("/{job_id}", response_model=JobStatusResponse)
async def get_job_status(job_id: str):
"""
Get the status of a job.
Returns current status, progress, and timing information.
"""
job = await store.get(job_id)
if not job:
raise HTTPException(status_code=404, detail=f"Job not found: {job_id}")
return job.to_status_response()
@router.get("/{job_id}/result", response_model=JobResultResponse)
async def get_job_result(job_id: str):
"""
Get the result of a completed job.
Returns 409 Conflict if job is not yet complete.
"""
job = await store.get(job_id)
if not job:
raise HTTPException(status_code=404, detail=f"Job not found: {job_id}")
if not job.is_terminal:
raise HTTPException(
status_code=409,
detail=f"Job is not complete. Current status: {job.status.value}"
)
return job.to_result_response()
@router.post("/{job_id}/cancel", response_model=JobStatusResponse)
async def cancel_job(job_id: str):
"""
Cancel a running job.
Returns 409 Conflict if job is already complete.
"""
job = await store.get(job_id)
if not job:
raise HTTPException(status_code=404, detail=f"Job not found: {job_id}")
if job.is_terminal:
raise HTTPException(
status_code=409,
detail=f"Job is already complete. Status: {job.status.value}"
)
success = await executor.cancel(job_id)
if not success:
raise HTTPException(status_code=500, detail="Failed to cancel job")
# Refresh job state
job = await store.get(job_id)
return job.to_status_response()
@router.delete("/{job_id}", status_code=204)
async def delete_job(job_id: str):
"""
Delete a job.
Only completed jobs can be deleted.
"""
job = await store.get(job_id)
if not job:
raise HTTPException(status_code=404, detail=f"Job not found: {job_id}")
if not job.is_terminal:
raise HTTPException(
status_code=409,
detail="Cannot delete a running job. Cancel it first."
)
await store.delete(job_id)
@router.get("/{job_id}/stream")
async def stream_job(job_id: str):
"""
Stream job progress via Server-Sent Events (SSE).
Events:
- status: Job status updates
- progress: Progress percentage updates
- result: Final result (when complete)
- error: Error message (when failed)
"""
job = await store.get(job_id)
if not job:
raise HTTPException(status_code=404, detail=f"Job not found: {job_id}")
async def event_generator():
"""Generate SSE events for job progress."""
last_status = None
last_progress = -1
# Register for progress updates
progress_queue = asyncio.Queue()
async def on_progress(updated_job: Job):
await progress_queue.put(updated_job)
executor.register_progress_callback(job_id, on_progress)
try:
while True:
# Get current job state
current_job = await store.get(job_id)
if not current_job:
yield {
"event": "error",
"data": '{"error": "Job not found"}'
}
break
# Send status update if changed
if current_job.status != last_status:
last_status = current_job.status
yield {
"event": "status",
"data": f'{{"status": "{current_job.status.value}", "job_id": "{job_id}"}}'
}
# Send progress update if changed
if current_job.progress_percentage != last_progress:
last_progress = current_job.progress_percentage
yield {
"event": "progress",
"data": f'{{"percentage": {current_job.progress_percentage}, "step": "{current_job.progress_step or ""}"}}'
}
# Check if complete
if current_job.is_terminal:
if current_job.status == JobStatus.SUCCEEDED:
result_str = str(current_job.result).replace('"', '\\"')[:1000]
yield {
"event": "result",
"data": f'{{"result": "{result_str}"}}'
}
elif current_job.status == JobStatus.FAILED:
error_str = (current_job.error or "Unknown error").replace('"', '\\"')
yield {
"event": "error",
"data": f'{{"error": "{error_str}"}}'
}
elif current_job.status == JobStatus.CANCELLED:
yield {
"event": "cancelled",
"data": '{"message": "Job was cancelled"}'
}
break
# Wait for next update or timeout
try:
await asyncio.wait_for(progress_queue.get(), timeout=5.0)
except asyncio.TimeoutError:
# Send heartbeat
yield {
"event": "heartbeat",
"data": f'{{"timestamp": "{datetime.utcnow().isoformat()}"}}'
}
finally:
executor.unregister_progress_callback(job_id)
return EventSourceResponse(event_generator())
return router
+186
View File
@@ -0,0 +1,186 @@
"""
Server for PraisonAI Async Jobs API.
Provides FastAPI application setup and server startup.
"""
import logging
import os
from typing import Optional
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from .store import InMemoryJobStore, JobStore
from .executor import JobExecutor
from .router import create_router
logger = logging.getLogger(__name__)
# Global instances (for single-process deployment)
_store: Optional[JobStore] = None
_executor: Optional[JobExecutor] = None
def get_store() -> JobStore:
"""Get or create the job store."""
global _store
if _store is None:
_store = InMemoryJobStore(max_jobs=1000)
return _store
def get_executor() -> JobExecutor:
"""Get or create the job executor."""
global _executor
if _executor is None:
_executor = JobExecutor(
store=get_store(),
max_concurrent=int(os.environ.get("PRAISONAI_MAX_CONCURRENT_JOBS", "10")),
default_timeout=int(os.environ.get("PRAISONAI_JOB_TIMEOUT", "3600"))
)
return _executor
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Application lifespan manager."""
executor = get_executor()
await executor.start()
logger.info("Jobs API server started")
yield
await executor.stop()
logger.info("Jobs API server stopped")
def create_app(
store: Optional[JobStore] = None,
executor: Optional[JobExecutor] = None,
cors_origins: Optional[list] = None
) -> FastAPI:
"""
Create the FastAPI application.
Args:
store: Optional custom job store
executor: Optional custom executor
cors_origins: Optional list of allowed CORS origins
Returns:
FastAPI application
"""
global _store, _executor
if store:
_store = store
if executor:
_executor = executor
app = FastAPI(
title="PraisonAI Jobs API",
description="Async Jobs API for long-running agent tasks",
version="1.0.0",
lifespan=lifespan
)
# Add CORS middleware
origins = cors_origins or ["*"]
app.add_middleware(
CORSMiddleware,
allow_origins=origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Add jobs router
jobs_router = create_router(get_store(), get_executor())
app.include_router(jobs_router)
# Health check endpoint
@app.get("/health")
async def health_check():
"""Health check endpoint."""
return {
"status": "healthy",
"store": get_store().__class__.__name__,
"executor_stats": get_executor().get_stats()
}
# Stats endpoint
@app.get("/stats")
async def get_stats():
"""Get server statistics."""
store = get_store()
executor = get_executor()
stats = {
"executor": executor.get_stats()
}
if hasattr(store, "get_stats"):
stats["store"] = store.get_stats()
return stats
return app
def start_server(
host: str = "127.0.0.1",
port: int = 8005,
reload: bool = False,
workers: int = 1,
log_level: str = "info"
):
"""
Start the jobs API server.
Args:
host: Host to bind to
port: Port to bind to
reload: Enable auto-reload for development
workers: Number of worker processes
log_level: Logging level
"""
try:
import uvicorn
except ImportError:
raise RuntimeError("uvicorn is required. Install with: pip install uvicorn")
logger.info(f"Starting PraisonAI Jobs API on {host}:{port}")
uvicorn.run(
"praisonai.jobs.server:create_app",
host=host,
port=port,
reload=reload,
workers=workers,
log_level=log_level,
factory=True
)
# For direct module execution
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description="PraisonAI Jobs API Server")
parser.add_argument("--host", default="127.0.0.1", help="Host to bind to")
parser.add_argument("--port", type=int, default=8005, help="Port to bind to")
parser.add_argument("--reload", action="store_true", help="Enable auto-reload")
parser.add_argument("--workers", type=int, default=1, help="Number of workers")
parser.add_argument("--log-level", default="info", help="Log level")
args = parser.parse_args()
start_server(
host=args.host,
port=args.port,
reload=args.reload,
workers=args.workers,
log_level=args.log_level
)
+203
View File
@@ -0,0 +1,203 @@
"""
Job Store for PraisonAI Async Jobs API.
Provides storage backends for job state persistence.
"""
import asyncio
import logging
from abc import ABC, abstractmethod
from typing import Optional, List, Dict, Any
from datetime import datetime, timedelta
from .models import Job, JobStatus
logger = logging.getLogger(__name__)
class JobStore(ABC):
"""Abstract base class for job storage backends."""
@abstractmethod
async def save(self, job: Job) -> None:
"""Save or update a job."""
pass
@abstractmethod
async def get(self, job_id: str) -> Optional[Job]:
"""Get a job by ID."""
pass
@abstractmethod
async def get_by_idempotency_key(self, key: str) -> Optional[Job]:
"""Get a job by idempotency key."""
pass
@abstractmethod
async def list_jobs(
self,
status: Optional[JobStatus] = None,
session_id: Optional[str] = None,
limit: int = 20,
offset: int = 0
) -> List[Job]:
"""List jobs with optional filters."""
pass
@abstractmethod
async def count(
self,
status: Optional[JobStatus] = None,
session_id: Optional[str] = None
) -> int:
"""Count jobs matching filters."""
pass
@abstractmethod
async def delete(self, job_id: str) -> bool:
"""Delete a job by ID."""
pass
@abstractmethod
async def cleanup_old_jobs(self, max_age_seconds: int = 86400) -> int:
"""Remove jobs older than max_age_seconds. Returns count deleted."""
pass
class InMemoryJobStore(JobStore):
"""
In-memory job store.
Suitable for development and single-process deployments.
Jobs are lost on restart.
"""
def __init__(self, max_jobs: int = 1000):
self._jobs: Dict[str, Job] = {}
self._idempotency_keys: Dict[str, str] = {} # key -> job_id
self._max_jobs = max_jobs
self._lock = asyncio.Lock()
async def save(self, job: Job) -> None:
"""Save or update a job."""
async with self._lock:
self._jobs[job.id] = job
# Track idempotency key
if job.idempotency_key:
self._idempotency_keys[job.idempotency_key] = job.id
# Enforce max jobs limit by removing oldest completed
if len(self._jobs) > self._max_jobs:
await self._evict_oldest_completed()
async def _evict_oldest_completed(self) -> None:
"""Remove oldest completed jobs to stay under limit."""
completed = [
(job_id, job) for job_id, job in self._jobs.items()
if job.is_terminal
]
if not completed:
return
# Sort by completed_at, oldest first
completed.sort(key=lambda x: x[1].completed_at or datetime.min)
# Remove oldest 10%
to_remove = max(1, len(completed) // 10)
for job_id, job in completed[:to_remove]:
del self._jobs[job_id]
if job.idempotency_key:
self._idempotency_keys.pop(job.idempotency_key, None)
async def get(self, job_id: str) -> Optional[Job]:
"""Get a job by ID."""
return self._jobs.get(job_id)
async def get_by_idempotency_key(self, key: str) -> Optional[Job]:
"""Get a job by idempotency key."""
job_id = self._idempotency_keys.get(key)
if job_id:
return self._jobs.get(job_id)
return None
async def list_jobs(
self,
status: Optional[JobStatus] = None,
session_id: Optional[str] = None,
limit: int = 20,
offset: int = 0
) -> List[Job]:
"""List jobs with optional filters."""
jobs = list(self._jobs.values())
# Apply filters
if status:
jobs = [j for j in jobs if j.status == status]
if session_id:
jobs = [j for j in jobs if j.session_id == session_id]
# Sort by created_at descending (newest first)
jobs.sort(key=lambda j: j.created_at, reverse=True)
# Apply pagination
return jobs[offset:offset + limit]
async def count(
self,
status: Optional[JobStatus] = None,
session_id: Optional[str] = None
) -> int:
"""Count jobs matching filters."""
jobs = list(self._jobs.values())
if status:
jobs = [j for j in jobs if j.status == status]
if session_id:
jobs = [j for j in jobs if j.session_id == session_id]
return len(jobs)
async def delete(self, job_id: str) -> bool:
"""Delete a job by ID."""
async with self._lock:
job = self._jobs.pop(job_id, None)
if job:
if job.idempotency_key:
self._idempotency_keys.pop(job.idempotency_key, None)
return True
return False
async def cleanup_old_jobs(self, max_age_seconds: int = 86400) -> int:
"""Remove jobs older than max_age_seconds."""
async with self._lock:
cutoff = datetime.utcnow() - timedelta(seconds=max_age_seconds)
to_remove = []
for job_id, job in self._jobs.items():
if job.is_terminal and job.completed_at and job.completed_at < cutoff:
to_remove.append(job_id)
for job_id in to_remove:
job = self._jobs.pop(job_id)
if job.idempotency_key:
self._idempotency_keys.pop(job.idempotency_key, None)
if to_remove:
logger.info(f"Cleaned up {len(to_remove)} old jobs")
return len(to_remove)
def get_stats(self) -> Dict[str, Any]:
"""Get store statistics."""
status_counts = {}
for job in self._jobs.values():
status_counts[job.status.value] = status_counts.get(job.status.value, 0) + 1
return {
"total_jobs": len(self._jobs),
"idempotency_keys": len(self._idempotency_keys),
"max_jobs": self._max_jobs,
"status_counts": status_counts
}
@@ -0,0 +1,427 @@
"""
Async MySQL implementation of ConversationStore.
Requires: aiomysql
Install: pip install aiomysql
"""
import asyncio
import json
import logging
import time
from typing import List, Optional
from .base import ConversationStore, ConversationSession, ConversationMessage
logger = logging.getLogger(__name__)
class AsyncMySQLConversationStore(ConversationStore):
"""
Async MySQL conversation store using aiomysql.
Provides high-performance async database operations.
Example:
store = AsyncMySQLConversationStore(
host="localhost",
user="root",
password="password",
database="praisonai"
)
await store.init()
"""
def __init__(
self,
url: Optional[str] = None,
host: str = "localhost",
port: int = 3306,
database: str = "praisonai",
user: str = "root",
password: str = "",
table_prefix: str = "praisonai_",
pool_size: int = 10,
):
"""
Initialize async MySQL store.
Args:
url: MySQL connection URL (takes precedence)
host: Database host
port: Database port
database: Database name
user: Database user
password: Database password
table_prefix: Prefix for table names
pool_size: Connection pool size
"""
self.url = url
self.host = host
self.port = port
self.database = database
self.user = user
self.password = password
self.table_prefix = table_prefix
self.pool_size = pool_size
self._pool = None
self._initialized = False
if url:
self._parse_url(url)
def _parse_url(self, url: str):
"""Parse MySQL URL into components."""
from urllib.parse import urlparse
parsed = urlparse(url)
self.host = parsed.hostname or "localhost"
self.port = parsed.port or 3306
self.database = parsed.path.lstrip('/') or "praisonai"
self.user = parsed.username or "root"
self.password = parsed.password or ""
async def init(self):
"""Initialize connection pool and create tables."""
if self._initialized:
return
try:
import aiomysql
except ImportError:
raise ImportError(
"aiomysql is required for async MySQL support. "
"Install with: pip install aiomysql"
)
self._pool = await aiomysql.create_pool(
host=self.host,
port=self.port,
user=self.user,
password=self.password,
db=self.database,
minsize=1,
maxsize=self.pool_size,
autocommit=True
)
await self._create_tables()
self._initialized = True
async def _create_tables(self):
"""Create required tables if they don't exist."""
sessions_table = f"{self.table_prefix}sessions"
messages_table = f"{self.table_prefix}messages"
async with self._pool.acquire() as conn:
async with conn.cursor() as cur:
await cur.execute(f"""
CREATE TABLE IF NOT EXISTS {sessions_table} (
session_id VARCHAR(255) PRIMARY KEY,
user_id VARCHAR(255),
agent_id VARCHAR(255),
name VARCHAR(255),
metadata JSON,
created_at DOUBLE,
updated_at DOUBLE,
INDEX idx_user_id (user_id),
INDEX idx_agent_id (agent_id)
)
""")
await cur.execute(f"""
CREATE TABLE IF NOT EXISTS {messages_table} (
id VARCHAR(255) PRIMARY KEY,
session_id VARCHAR(255),
role VARCHAR(50),
content TEXT,
metadata JSON,
created_at DOUBLE,
INDEX idx_session_id (session_id),
FOREIGN KEY (session_id) REFERENCES {sessions_table}(session_id) ON DELETE CASCADE
)
""")
async def async_create_session(self, session: ConversationSession) -> ConversationSession:
"""Create a new session asynchronously."""
if not self._initialized:
await self.init()
table = f"{self.table_prefix}sessions"
async with self._pool.acquire() as conn:
async with conn.cursor() as cur:
await cur.execute(f"""
INSERT INTO {table} (session_id, user_id, agent_id, name, metadata, created_at, updated_at)
VALUES (%s, %s, %s, %s, %s, %s, %s)
""", (session.session_id, session.user_id, session.agent_id, session.name,
json.dumps(session.metadata) if session.metadata else None,
session.created_at, session.updated_at))
return session
def create_session(self, session: ConversationSession) -> ConversationSession:
"""Sync wrapper for create_session."""
return asyncio.get_event_loop().run_until_complete(
self.async_create_session(session)
)
async def async_get_session(self, session_id: str) -> Optional[ConversationSession]:
"""Get a session by ID asynchronously."""
if not self._initialized:
await self.init()
table = f"{self.table_prefix}sessions"
async with self._pool.acquire() as conn:
async with conn.cursor() as cur:
await cur.execute(f"""
SELECT session_id, user_id, agent_id, name, metadata, created_at, updated_at
FROM {table} WHERE session_id = %s
""", (session_id,))
row = await cur.fetchone()
if row:
return ConversationSession(
session_id=row[0],
user_id=row[1],
agent_id=row[2],
name=row[3],
metadata=json.loads(row[4]) if row[4] else None,
created_at=row[5],
updated_at=row[6]
)
return None
def get_session(self, session_id: str) -> Optional[ConversationSession]:
"""Sync wrapper for get_session."""
return asyncio.get_event_loop().run_until_complete(
self.async_get_session(session_id)
)
async def async_update_session(self, session: ConversationSession) -> ConversationSession:
"""Update an existing session asynchronously."""
if not self._initialized:
await self.init()
session.updated_at = time.time()
table = f"{self.table_prefix}sessions"
async with self._pool.acquire() as conn:
async with conn.cursor() as cur:
await cur.execute(f"""
UPDATE {table}
SET name = %s, metadata = %s, updated_at = %s
WHERE session_id = %s
""", (session.name, json.dumps(session.metadata) if session.metadata else None,
session.updated_at, session.session_id))
return session
def update_session(self, session: ConversationSession) -> ConversationSession:
"""Sync wrapper for update_session."""
return asyncio.get_event_loop().run_until_complete(
self.async_update_session(session)
)
async def async_delete_session(self, session_id: str) -> bool:
"""Delete a session asynchronously."""
if not self._initialized:
await self.init()
table = f"{self.table_prefix}sessions"
async with self._pool.acquire() as conn:
async with conn.cursor() as cur:
await cur.execute(f"""
DELETE FROM {table} WHERE session_id = %s
""", (session_id,))
return cur.rowcount > 0
def delete_session(self, session_id: str) -> bool:
"""Sync wrapper for delete_session."""
return asyncio.get_event_loop().run_until_complete(
self.async_delete_session(session_id)
)
async def async_list_sessions(
self,
user_id: Optional[str] = None,
agent_id: Optional[str] = None,
limit: int = 100,
offset: int = 0
) -> List[ConversationSession]:
"""List sessions asynchronously."""
if not self._initialized:
await self.init()
table = f"{self.table_prefix}sessions"
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 = f"WHERE {' AND '.join(conditions)}" if conditions else ""
params.extend([limit, offset])
async with self._pool.acquire() as conn:
async with conn.cursor() as cur:
await cur.execute(f"""
SELECT session_id, user_id, agent_id, name, metadata, created_at, updated_at
FROM {table} {where_clause}
ORDER BY updated_at DESC
LIMIT %s OFFSET %s
""", params)
rows = await cur.fetchall()
return [
ConversationSession(
session_id=row[0],
user_id=row[1],
agent_id=row[2],
name=row[3],
metadata=json.loads(row[4]) if row[4] else None,
created_at=row[5],
updated_at=row[6]
)
for row in rows
]
def list_sessions(
self,
user_id: Optional[str] = None,
agent_id: Optional[str] = None,
limit: int = 100,
offset: int = 0
) -> List[ConversationSession]:
"""Sync wrapper for list_sessions."""
return asyncio.get_event_loop().run_until_complete(
self.async_list_sessions(user_id, agent_id, limit, offset)
)
async def async_add_message(self, session_id: str, message: ConversationMessage) -> ConversationMessage:
"""Add a message asynchronously."""
if not self._initialized:
await self.init()
message.session_id = session_id
table = f"{self.table_prefix}messages"
async with self._pool.acquire() as conn:
async with conn.cursor() as cur:
await cur.execute(f"""
INSERT INTO {table} (id, session_id, role, content, metadata, created_at)
VALUES (%s, %s, %s, %s, %s, %s)
""", (message.id, session_id, message.role, message.content,
json.dumps(message.metadata) if message.metadata else None,
message.created_at))
return message
def add_message(self, session_id: str, message: ConversationMessage) -> ConversationMessage:
"""Sync wrapper for add_message."""
return asyncio.get_event_loop().run_until_complete(
self.async_add_message(session_id, message)
)
async def async_get_messages(
self,
session_id: str,
limit: Optional[int] = None,
before: Optional[float] = None,
after: Optional[float] = None
) -> List[ConversationMessage]:
"""Get messages asynchronously."""
if not self._initialized:
await self.init()
table = f"{self.table_prefix}messages"
conditions = ["session_id = %s"]
params = [session_id]
if before:
conditions.append("created_at < %s")
params.append(before)
if after:
conditions.append("created_at > %s")
params.append(after)
where_clause = f"WHERE {' AND '.join(conditions)}"
limit_clause = "LIMIT %s" if limit else ""
if limit:
params.append(limit)
async with self._pool.acquire() as conn:
async with conn.cursor() as cur:
await cur.execute(f"""
SELECT id, session_id, role, content, metadata, created_at
FROM {table} {where_clause}
ORDER BY created_at ASC {limit_clause}
""", params)
rows = await cur.fetchall()
return [
ConversationMessage(
id=row[0],
session_id=row[1],
role=row[2],
content=row[3],
metadata=json.loads(row[4]) if row[4] else None,
created_at=row[5]
)
for row in rows
]
def get_messages(
self,
session_id: str,
limit: Optional[int] = None,
before: Optional[float] = None,
after: Optional[float] = None
) -> List[ConversationMessage]:
"""Sync wrapper for get_messages."""
return asyncio.get_event_loop().run_until_complete(
self.async_get_messages(session_id, limit, before, after)
)
async def async_delete_messages(self, session_id: str, message_ids: Optional[List[str]] = None) -> int:
"""Delete messages asynchronously."""
if not self._initialized:
await self.init()
table = f"{self.table_prefix}messages"
async with self._pool.acquire() as conn:
async with conn.cursor() as cur:
if message_ids is None:
await cur.execute(f"""
DELETE FROM {table} WHERE session_id = %s
""", (session_id,))
else:
placeholders = ','.join(['%s'] * len(message_ids))
await cur.execute(f"""
DELETE FROM {table} WHERE session_id = %s AND id IN ({placeholders})
""", [session_id] + message_ids)
return cur.rowcount
def delete_messages(self, session_id: str, message_ids: Optional[List[str]] = None) -> int:
"""Sync wrapper for delete_messages."""
return asyncio.get_event_loop().run_until_complete(
self.async_delete_messages(session_id, message_ids)
)
async def async_close(self) -> None:
"""Close the connection pool."""
if self._pool:
self._pool.close()
await self._pool.wait_closed()
self._pool = None
self._initialized = False
def close(self) -> None:
"""Sync wrapper for close."""
if self._pool:
asyncio.get_event_loop().run_until_complete(self.async_close())
@@ -0,0 +1,410 @@
"""
Async PostgreSQL implementation of ConversationStore.
Requires: asyncpg
Install: pip install asyncpg
"""
import asyncio
import json
import logging
import time
from typing import List, Optional
from .base import ConversationStore, ConversationSession, ConversationMessage
logger = logging.getLogger(__name__)
class AsyncPostgresConversationStore(ConversationStore):
"""
Async PostgreSQL conversation store using asyncpg.
Provides high-performance async database operations.
Example:
store = AsyncPostgresConversationStore(
url="postgresql://user:pass@localhost:5432/praisonai"
)
await store.init()
"""
def __init__(
self,
url: Optional[str] = None,
host: str = "localhost",
port: int = 5432,
database: str = "praisonai",
user: str = "postgres",
password: str = "",
table_prefix: str = "praisonai_",
pool_size: int = 10,
):
"""
Initialize async PostgreSQL store.
Args:
url: PostgreSQL connection URL (takes precedence)
host: Database host
port: Database port
database: Database name
user: Database user
password: Database password
table_prefix: Prefix for table names
pool_size: Connection pool size
"""
self.url = url
self.host = host
self.port = port
self.database = database
self.user = user
self.password = password
self.table_prefix = table_prefix
self.pool_size = pool_size
self._pool = None
self._initialized = False
async def init(self):
"""Initialize connection pool and create tables."""
if self._initialized:
return
try:
import asyncpg
except ImportError:
raise ImportError(
"asyncpg is required for async PostgreSQL support. "
"Install with: pip install asyncpg"
)
if self.url:
self._pool = await asyncpg.create_pool(self.url, min_size=1, max_size=self.pool_size)
else:
self._pool = await asyncpg.create_pool(
host=self.host,
port=self.port,
database=self.database,
user=self.user,
password=self.password,
min_size=1,
max_size=self.pool_size
)
await self._create_tables()
self._initialized = True
async def _create_tables(self):
"""Create required tables if they don't exist."""
sessions_table = f"{self.table_prefix}sessions"
messages_table = f"{self.table_prefix}messages"
async with self._pool.acquire() as conn:
await conn.execute(f"""
CREATE TABLE IF NOT EXISTS {sessions_table} (
session_id VARCHAR(255) PRIMARY KEY,
user_id VARCHAR(255),
agent_id VARCHAR(255),
name VARCHAR(255),
metadata JSONB,
created_at DOUBLE PRECISION,
updated_at DOUBLE PRECISION
)
""")
await conn.execute(f"""
CREATE TABLE IF NOT EXISTS {messages_table} (
id VARCHAR(255) PRIMARY KEY,
session_id VARCHAR(255) REFERENCES {sessions_table}(session_id) ON DELETE CASCADE,
role VARCHAR(50),
content TEXT,
metadata JSONB,
created_at DOUBLE PRECISION
)
""")
await conn.execute(f"""
CREATE INDEX IF NOT EXISTS idx_{messages_table}_session
ON {messages_table}(session_id)
""")
async def async_create_session(self, session: ConversationSession) -> ConversationSession:
"""Create a new session asynchronously."""
if not self._initialized:
await self.init()
table = f"{self.table_prefix}sessions"
async with self._pool.acquire() as conn:
await conn.execute(f"""
INSERT INTO {table} (session_id, user_id, agent_id, name, metadata, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)
""", session.session_id, session.user_id, session.agent_id, session.name,
json.dumps(session.metadata) if session.metadata else None,
session.created_at, session.updated_at)
return session
def create_session(self, session: ConversationSession) -> ConversationSession:
"""Sync wrapper for create_session."""
return asyncio.get_event_loop().run_until_complete(
self.async_create_session(session)
)
async def async_get_session(self, session_id: str) -> Optional[ConversationSession]:
"""Get a session by ID asynchronously."""
if not self._initialized:
await self.init()
table = f"{self.table_prefix}sessions"
async with self._pool.acquire() as conn:
row = await conn.fetchrow(f"""
SELECT * FROM {table} WHERE session_id = $1
""", session_id)
if row:
return ConversationSession(
session_id=row['session_id'],
user_id=row['user_id'],
agent_id=row['agent_id'],
name=row['name'],
metadata=json.loads(row['metadata']) if row['metadata'] else None,
created_at=row['created_at'],
updated_at=row['updated_at']
)
return None
def get_session(self, session_id: str) -> Optional[ConversationSession]:
"""Sync wrapper for get_session."""
return asyncio.get_event_loop().run_until_complete(
self.async_get_session(session_id)
)
async def async_update_session(self, session: ConversationSession) -> ConversationSession:
"""Update an existing session asynchronously."""
if not self._initialized:
await self.init()
session.updated_at = time.time()
table = f"{self.table_prefix}sessions"
async with self._pool.acquire() as conn:
await conn.execute(f"""
UPDATE {table}
SET name = $2, metadata = $3, updated_at = $4
WHERE session_id = $1
""", session.session_id, session.name,
json.dumps(session.metadata) if session.metadata else None,
session.updated_at)
return session
def update_session(self, session: ConversationSession) -> ConversationSession:
"""Sync wrapper for update_session."""
return asyncio.get_event_loop().run_until_complete(
self.async_update_session(session)
)
async def async_delete_session(self, session_id: str) -> bool:
"""Delete a session asynchronously."""
if not self._initialized:
await self.init()
table = f"{self.table_prefix}sessions"
async with self._pool.acquire() as conn:
result = await conn.execute(f"""
DELETE FROM {table} WHERE session_id = $1
""", session_id)
return "DELETE 1" in result
def delete_session(self, session_id: str) -> bool:
"""Sync wrapper for delete_session."""
return asyncio.get_event_loop().run_until_complete(
self.async_delete_session(session_id)
)
async def async_list_sessions(
self,
user_id: Optional[str] = None,
agent_id: Optional[str] = None,
limit: int = 100,
offset: int = 0
) -> List[ConversationSession]:
"""List sessions asynchronously."""
if not self._initialized:
await self.init()
table = f"{self.table_prefix}sessions"
conditions = []
params = []
param_idx = 1
if user_id:
conditions.append(f"user_id = ${param_idx}")
params.append(user_id)
param_idx += 1
if agent_id:
conditions.append(f"agent_id = ${param_idx}")
params.append(agent_id)
param_idx += 1
where_clause = f"WHERE {' AND '.join(conditions)}" if conditions else ""
params.extend([limit, offset])
async with self._pool.acquire() as conn:
rows = await conn.fetch(f"""
SELECT * FROM {table} {where_clause}
ORDER BY updated_at DESC
LIMIT ${param_idx} OFFSET ${param_idx + 1}
""", *params)
return [
ConversationSession(
session_id=row['session_id'],
user_id=row['user_id'],
agent_id=row['agent_id'],
name=row['name'],
metadata=json.loads(row['metadata']) if row['metadata'] else None,
created_at=row['created_at'],
updated_at=row['updated_at']
)
for row in rows
]
def list_sessions(
self,
user_id: Optional[str] = None,
agent_id: Optional[str] = None,
limit: int = 100,
offset: int = 0
) -> List[ConversationSession]:
"""Sync wrapper for list_sessions."""
return asyncio.get_event_loop().run_until_complete(
self.async_list_sessions(user_id, agent_id, limit, offset)
)
async def async_add_message(self, session_id: str, message: ConversationMessage) -> ConversationMessage:
"""Add a message asynchronously."""
if not self._initialized:
await self.init()
message.session_id = session_id
table = f"{self.table_prefix}messages"
async with self._pool.acquire() as conn:
await conn.execute(f"""
INSERT INTO {table} (id, session_id, role, content, metadata, created_at)
VALUES ($1, $2, $3, $4, $5, $6)
""", message.id, session_id, message.role, message.content,
json.dumps(message.metadata) if message.metadata else None,
message.created_at)
return message
def add_message(self, session_id: str, message: ConversationMessage) -> ConversationMessage:
"""Sync wrapper for add_message."""
return asyncio.get_event_loop().run_until_complete(
self.async_add_message(session_id, message)
)
async def async_get_messages(
self,
session_id: str,
limit: Optional[int] = None,
before: Optional[float] = None,
after: Optional[float] = None
) -> List[ConversationMessage]:
"""Get messages asynchronously."""
if not self._initialized:
await self.init()
table = f"{self.table_prefix}messages"
conditions = ["session_id = $1"]
params = [session_id]
param_idx = 2
if before:
conditions.append(f"created_at < ${param_idx}")
params.append(before)
param_idx += 1
if after:
conditions.append(f"created_at > ${param_idx}")
params.append(after)
param_idx += 1
where_clause = f"WHERE {' AND '.join(conditions)}"
limit_clause = f"LIMIT ${param_idx}" if limit else ""
if limit:
params.append(limit)
async with self._pool.acquire() as conn:
rows = await conn.fetch(f"""
SELECT * FROM {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'],
metadata=json.loads(row['metadata']) if row['metadata'] else None,
created_at=row['created_at']
)
for row in rows
]
def get_messages(
self,
session_id: str,
limit: Optional[int] = None,
before: Optional[float] = None,
after: Optional[float] = None
) -> List[ConversationMessage]:
"""Sync wrapper for get_messages."""
return asyncio.get_event_loop().run_until_complete(
self.async_get_messages(session_id, limit, before, after)
)
async def async_delete_messages(self, session_id: str, message_ids: Optional[List[str]] = None) -> int:
"""Delete messages asynchronously."""
if not self._initialized:
await self.init()
table = f"{self.table_prefix}messages"
async with self._pool.acquire() as conn:
if message_ids is None:
result = await conn.execute(f"""
DELETE FROM {table} WHERE session_id = $1
""", session_id)
else:
result = await conn.execute(f"""
DELETE FROM {table} WHERE session_id = $1 AND id = ANY($2)
""", session_id, message_ids)
# Parse count from result like "DELETE 5"
try:
return int(result.split()[-1])
except (ValueError, IndexError):
return 0
def delete_messages(self, session_id: str, message_ids: Optional[List[str]] = None) -> int:
"""Sync wrapper for delete_messages."""
return asyncio.get_event_loop().run_until_complete(
self.async_delete_messages(session_id, message_ids)
)
async def async_close(self) -> None:
"""Close the connection pool."""
if self._pool:
await self._pool.close()
self._pool = None
self._initialized = False
def close(self) -> None:
"""Sync wrapper for close."""
if self._pool:
asyncio.get_event_loop().run_until_complete(self.async_close())
@@ -0,0 +1,371 @@
"""
Async SQLite implementation of ConversationStore.
Requires: aiosqlite
Install: pip install aiosqlite
"""
import asyncio
import json
import logging
import time
from typing import List, Optional
from .base import ConversationStore, ConversationSession, ConversationMessage
logger = logging.getLogger(__name__)
class AsyncSQLiteConversationStore(ConversationStore):
"""
Async SQLite conversation store using aiosqlite.
Provides async database operations for SQLite.
Example:
store = AsyncSQLiteConversationStore(path="./conversations.db")
await store.init()
"""
def __init__(
self,
path: str = "praisonai_conversations.db",
table_prefix: str = "praisonai_",
):
"""
Initialize async SQLite store.
Args:
path: Path to SQLite database file
table_prefix: Prefix for table names
"""
self.path = path
self.table_prefix = table_prefix
self._conn = None
self._initialized = False
async def init(self):
"""Initialize connection and create tables."""
if self._initialized:
return
try:
import aiosqlite
except ImportError:
raise ImportError(
"aiosqlite is required for async SQLite support. "
"Install with: pip install aiosqlite"
)
self._aiosqlite = aiosqlite
self._conn = await aiosqlite.connect(self.path)
self._conn.row_factory = aiosqlite.Row
await self._create_tables()
self._initialized = True
async def _create_tables(self):
"""Create required tables if they don't exist."""
sessions_table = f"{self.table_prefix}sessions"
messages_table = f"{self.table_prefix}messages"
await self._conn.execute(f"""
CREATE TABLE IF NOT EXISTS {sessions_table} (
session_id TEXT PRIMARY KEY,
user_id TEXT,
agent_id TEXT,
name TEXT,
metadata TEXT,
created_at REAL,
updated_at REAL
)
""")
await self._conn.execute(f"""
CREATE TABLE IF NOT EXISTS {messages_table} (
id TEXT PRIMARY KEY,
session_id TEXT REFERENCES {sessions_table}(session_id) ON DELETE CASCADE,
role TEXT,
content TEXT,
metadata TEXT,
created_at REAL
)
""")
await self._conn.execute(f"""
CREATE INDEX IF NOT EXISTS idx_{messages_table}_session
ON {messages_table}(session_id)
""")
await self._conn.commit()
async def async_create_session(self, session: ConversationSession) -> ConversationSession:
"""Create a new session asynchronously."""
if not self._initialized:
await self.init()
table = f"{self.table_prefix}sessions"
await self._conn.execute(f"""
INSERT INTO {table} (session_id, user_id, agent_id, name, metadata, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
""", (session.session_id, session.user_id, session.agent_id, session.name,
json.dumps(session.metadata) if session.metadata else None,
session.created_at, session.updated_at))
await self._conn.commit()
return session
def create_session(self, session: ConversationSession) -> ConversationSession:
"""Sync wrapper for create_session."""
return asyncio.get_event_loop().run_until_complete(
self.async_create_session(session)
)
async def async_get_session(self, session_id: str) -> Optional[ConversationSession]:
"""Get a session by ID asynchronously."""
if not self._initialized:
await self.init()
table = f"{self.table_prefix}sessions"
async with self._conn.execute(f"""
SELECT * FROM {table} WHERE session_id = ?
""", (session_id,)) as cursor:
row = await cursor.fetchone()
if row:
return ConversationSession(
session_id=row['session_id'],
user_id=row['user_id'],
agent_id=row['agent_id'],
name=row['name'],
metadata=json.loads(row['metadata']) if row['metadata'] else None,
created_at=row['created_at'],
updated_at=row['updated_at']
)
return None
def get_session(self, session_id: str) -> Optional[ConversationSession]:
"""Sync wrapper for get_session."""
return asyncio.get_event_loop().run_until_complete(
self.async_get_session(session_id)
)
async def async_update_session(self, session: ConversationSession) -> ConversationSession:
"""Update an existing session asynchronously."""
if not self._initialized:
await self.init()
session.updated_at = time.time()
table = f"{self.table_prefix}sessions"
await self._conn.execute(f"""
UPDATE {table}
SET name = ?, metadata = ?, updated_at = ?
WHERE session_id = ?
""", (session.name, json.dumps(session.metadata) if session.metadata else None,
session.updated_at, session.session_id))
await self._conn.commit()
return session
def update_session(self, session: ConversationSession) -> ConversationSession:
"""Sync wrapper for update_session."""
return asyncio.get_event_loop().run_until_complete(
self.async_update_session(session)
)
async def async_delete_session(self, session_id: str) -> bool:
"""Delete a session asynchronously."""
if not self._initialized:
await self.init()
table = f"{self.table_prefix}sessions"
cursor = await self._conn.execute(f"""
DELETE FROM {table} WHERE session_id = ?
""", (session_id,))
await self._conn.commit()
return cursor.rowcount > 0
def delete_session(self, session_id: str) -> bool:
"""Sync wrapper for delete_session."""
return asyncio.get_event_loop().run_until_complete(
self.async_delete_session(session_id)
)
async def async_list_sessions(
self,
user_id: Optional[str] = None,
agent_id: Optional[str] = None,
limit: int = 100,
offset: int = 0
) -> List[ConversationSession]:
"""List sessions asynchronously."""
if not self._initialized:
await self.init()
table = f"{self.table_prefix}sessions"
conditions = []
params = []
if user_id:
conditions.append("user_id = ?")
params.append(user_id)
if agent_id:
conditions.append("agent_id = ?")
params.append(agent_id)
where_clause = f"WHERE {' AND '.join(conditions)}" if conditions else ""
params.extend([limit, offset])
async with self._conn.execute(f"""
SELECT * FROM {table} {where_clause}
ORDER BY updated_at DESC
LIMIT ? OFFSET ?
""", params) as cursor:
rows = await cursor.fetchall()
return [
ConversationSession(
session_id=row['session_id'],
user_id=row['user_id'],
agent_id=row['agent_id'],
name=row['name'],
metadata=json.loads(row['metadata']) if row['metadata'] else None,
created_at=row['created_at'],
updated_at=row['updated_at']
)
for row in rows
]
def list_sessions(
self,
user_id: Optional[str] = None,
agent_id: Optional[str] = None,
limit: int = 100,
offset: int = 0
) -> List[ConversationSession]:
"""Sync wrapper for list_sessions."""
return asyncio.get_event_loop().run_until_complete(
self.async_list_sessions(user_id, agent_id, limit, offset)
)
async def async_add_message(self, session_id: str, message: ConversationMessage) -> ConversationMessage:
"""Add a message asynchronously."""
if not self._initialized:
await self.init()
message.session_id = session_id
table = f"{self.table_prefix}messages"
await self._conn.execute(f"""
INSERT INTO {table} (id, session_id, role, content, metadata, created_at)
VALUES (?, ?, ?, ?, ?, ?)
""", (message.id, session_id, message.role, message.content,
json.dumps(message.metadata) if message.metadata else None,
message.created_at))
await self._conn.commit()
return message
def add_message(self, session_id: str, message: ConversationMessage) -> ConversationMessage:
"""Sync wrapper for add_message."""
return asyncio.get_event_loop().run_until_complete(
self.async_add_message(session_id, message)
)
async def async_get_messages(
self,
session_id: str,
limit: Optional[int] = None,
before: Optional[float] = None,
after: Optional[float] = None
) -> List[ConversationMessage]:
"""Get messages asynchronously."""
if not self._initialized:
await self.init()
table = f"{self.table_prefix}messages"
conditions = ["session_id = ?"]
params = [session_id]
if before:
conditions.append("created_at < ?")
params.append(before)
if after:
conditions.append("created_at > ?")
params.append(after)
where_clause = f"WHERE {' AND '.join(conditions)}"
limit_clause = f"LIMIT ?" if limit else ""
if limit:
params.append(limit)
async with self._conn.execute(f"""
SELECT * FROM {table} {where_clause}
ORDER BY created_at ASC {limit_clause}
""", params) as cursor:
rows = await cursor.fetchall()
return [
ConversationMessage(
id=row['id'],
session_id=row['session_id'],
role=row['role'],
content=row['content'],
metadata=json.loads(row['metadata']) if row['metadata'] else None,
created_at=row['created_at']
)
for row in rows
]
def get_messages(
self,
session_id: str,
limit: Optional[int] = None,
before: Optional[float] = None,
after: Optional[float] = None
) -> List[ConversationMessage]:
"""Sync wrapper for get_messages."""
return asyncio.get_event_loop().run_until_complete(
self.async_get_messages(session_id, limit, before, after)
)
async def async_delete_messages(self, session_id: str, message_ids: Optional[List[str]] = None) -> int:
"""Delete messages asynchronously."""
if not self._initialized:
await self.init()
table = f"{self.table_prefix}messages"
if message_ids is None:
cursor = await self._conn.execute(f"""
DELETE FROM {table} WHERE session_id = ?
""", (session_id,))
else:
placeholders = ','.join(['?' for _ in message_ids])
cursor = await self._conn.execute(f"""
DELETE FROM {table} WHERE session_id = ? AND id IN ({placeholders})
""", [session_id] + message_ids)
await self._conn.commit()
return cursor.rowcount
def delete_messages(self, session_id: str, message_ids: Optional[List[str]] = None) -> int:
"""Sync wrapper for delete_messages."""
return asyncio.get_event_loop().run_until_complete(
self.async_delete_messages(session_id, message_ids)
)
async def async_close(self) -> None:
"""Close the connection."""
if self._conn:
await self._conn.close()
self._conn = None
self._initialized = False
def close(self) -> None:
"""Sync wrapper for close."""
if self._conn:
asyncio.get_event_loop().run_until_complete(self.async_close())
@@ -0,0 +1,250 @@
"""
JSON file-based implementation of ConversationStore.
Simple file-based storage for development and testing.
No external dependencies required.
"""
import json
import logging
import time
from pathlib import Path
from typing import Dict, List, Optional
import threading
from .base import ConversationStore, ConversationSession, ConversationMessage
logger = logging.getLogger(__name__)
class JSONConversationStore(ConversationStore):
"""
JSON file-based conversation store.
Stores sessions and messages in JSON files.
Suitable for development, testing, and small-scale deployments.
Example:
store = JSONConversationStore(path="./data/conversations")
"""
def __init__(
self,
path: str = "./praisonai_conversations",
pretty: bool = True,
):
"""
Initialize JSON conversation store.
Args:
path: Directory path for JSON files
pretty: Use pretty-printed JSON (default: True)
"""
self.path = Path(path)
self.pretty = pretty
self._lock = threading.Lock()
# Create directory if it doesn't exist
self.path.mkdir(parents=True, exist_ok=True)
# Sessions index file
self._index_file = self.path / "_sessions_index.json"
self._load_index()
def _load_index(self):
"""Load or create sessions index."""
if self._index_file.exists():
with open(self._index_file, 'r') as f:
self._index = json.load(f)
else:
self._index = {"sessions": {}}
self._save_index()
def _save_index(self):
"""Save sessions index."""
with open(self._index_file, 'w') as f:
if self.pretty:
json.dump(self._index, f, indent=2, default=str)
else:
json.dump(self._index, f, default=str)
def _session_file(self, session_id: str) -> Path:
"""Get path to session file."""
return self.path / f"{session_id}.json"
def _load_session_data(self, session_id: str) -> Optional[Dict]:
"""Load session data from file."""
file_path = self._session_file(session_id)
if file_path.exists():
with open(file_path, 'r') as f:
return json.load(f)
return None
def _save_session_data(self, session_id: str, data: Dict):
"""Save session data to file."""
file_path = self._session_file(session_id)
with open(file_path, 'w') as f:
if self.pretty:
json.dump(data, f, indent=2, default=str)
else:
json.dump(data, f, default=str)
def create_session(self, session: ConversationSession) -> ConversationSession:
"""Create a new session."""
with self._lock:
data = {
"session": session.to_dict(),
"messages": []
}
self._save_session_data(session.session_id, data)
# Update index
self._index["sessions"][session.session_id] = {
"user_id": session.user_id,
"agent_id": session.agent_id,
"name": session.name,
"created_at": session.created_at,
"updated_at": session.updated_at,
}
self._save_index()
return session
def get_session(self, session_id: str) -> Optional[ConversationSession]:
"""Get a session by ID."""
data = self._load_session_data(session_id)
if data and "session" in data:
return ConversationSession.from_dict(data["session"])
return None
def update_session(self, session: ConversationSession) -> ConversationSession:
"""Update an existing session."""
with self._lock:
data = self._load_session_data(session.session_id)
if not data:
raise ValueError(f"Session {session.session_id} not found")
session.updated_at = time.time()
data["session"] = session.to_dict()
self._save_session_data(session.session_id, data)
# Update index
self._index["sessions"][session.session_id].update({
"name": session.name,
"updated_at": session.updated_at,
})
self._save_index()
return session
def delete_session(self, session_id: str) -> bool:
"""Delete a session and all its messages."""
with self._lock:
file_path = self._session_file(session_id)
if file_path.exists():
file_path.unlink()
# Update index
if session_id in self._index["sessions"]:
del self._index["sessions"][session_id]
self._save_index()
return True
return False
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."""
sessions = []
for sid, info in self._index["sessions"].items():
if user_id and info.get("user_id") != user_id:
continue
if agent_id and info.get("agent_id") != agent_id:
continue
session = self.get_session(sid)
if session:
sessions.append(session)
# Sort by updated_at descending
sessions.sort(key=lambda s: s.updated_at, reverse=True)
return sessions[offset:offset + limit]
def add_message(self, session_id: str, message: ConversationMessage) -> ConversationMessage:
"""Add a message to a session."""
with self._lock:
data = self._load_session_data(session_id)
if not data:
raise ValueError(f"Session {session_id} not found")
message.session_id = session_id
data["messages"].append(message.to_dict())
# Update session timestamp
data["session"]["updated_at"] = time.time()
self._save_session_data(session_id, data)
# Update index
if session_id in self._index["sessions"]:
self._index["sessions"][session_id]["updated_at"] = time.time()
self._save_index()
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."""
data = self._load_session_data(session_id)
if not data:
return []
messages = [ConversationMessage.from_dict(m) for m in data.get("messages", [])]
# Apply filters
if before:
messages = [m for m in messages if m.created_at < before]
if after:
messages = [m for m in messages if m.created_at > after]
# Sort by created_at
messages.sort(key=lambda m: m.created_at)
if limit:
messages = messages[-limit:]
return messages
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."""
with self._lock:
data = self._load_session_data(session_id)
if not data:
return 0
if message_ids is None:
count = len(data["messages"])
data["messages"] = []
else:
original_count = len(data["messages"])
data["messages"] = [m for m in data["messages"] if m.get("id") not in message_ids]
count = original_count - len(data["messages"])
self._save_session_data(session_id, data)
return count
def close(self) -> None:
"""Close the store."""
pass # No resources to release
+70 -3
View File
@@ -62,10 +62,29 @@ def create_conversation_store(
from .conversation.surrealdb import SurrealDBConversationStore
return SurrealDBConversationStore(url=url, **options)
elif backend == "json":
from .conversation.json_store import JSONConversationStore
path = url or options.pop("path", None) or "./praisonai_conversations"
return JSONConversationStore(path=path, **options)
elif backend in ("async_postgres", "asyncpg", "postgres_async"):
from .conversation.async_postgres import AsyncPostgresConversationStore
return AsyncPostgresConversationStore(url=url, **options)
elif backend in ("async_sqlite", "aiosqlite", "sqlite_async"):
from .conversation.async_sqlite import AsyncSQLiteConversationStore
path = url or options.pop("path", None)
return AsyncSQLiteConversationStore(path=path, **options)
elif backend in ("async_mysql", "aiomysql", "mysql_async"):
from .conversation.async_mysql import AsyncMySQLConversationStore
return AsyncMySQLConversationStore(url=url, **options)
else:
raise ValueError(
f"Unknown conversation store backend: {backend}. "
f"Supported: postgres, mysql, sqlite, singlestore, supabase, surrealdb"
f"Supported: postgres, mysql, sqlite, json, singlestore, supabase, surrealdb, "
f"async_postgres, async_sqlite, async_mysql"
)
@@ -135,10 +154,47 @@ def create_knowledge_store(
from .knowledge.clickhouse import ClickHouseKnowledgeStore
return ClickHouseKnowledgeStore(**options)
elif backend == "couchbase":
from .knowledge.couchbase import CouchbaseKnowledgeStore
return CouchbaseKnowledgeStore(**options)
elif backend in ("mongodb_vector", "mongodb_atlas", "mongo_vector"):
from .knowledge.mongodb_vector import MongoDBVectorKnowledgeStore
return MongoDBVectorKnowledgeStore(url=url, **options)
elif backend in ("singlestore_vector", "singlestore_v"):
from .knowledge.singlestore_vector import SingleStoreVectorKnowledgeStore
return SingleStoreVectorKnowledgeStore(url=url, **options)
elif backend in ("surrealdb_vector", "surrealdb_v"):
from .knowledge.surrealdb_vector import SurrealDBVectorKnowledgeStore
return SurrealDBVectorKnowledgeStore(url=url, **options)
elif backend in ("upstash_vector", "upstash_v"):
from .knowledge.upstash_vector import UpstashVectorKnowledgeStore
return UpstashVectorKnowledgeStore(url=url, **options)
elif backend == "lightrag":
from .knowledge.lightrag_adapter import LightRAGKnowledgeStore
return LightRAGKnowledgeStore(**options)
elif backend in ("langchain", "langchain_adapter"):
from .knowledge.langchain_adapter import LangChainKnowledgeStore
return LangChainKnowledgeStore(**options)
elif backend in ("llamaindex", "llama_index", "llamaindex_adapter"):
from .knowledge.llamaindex_adapter import LlamaIndexKnowledgeStore
return LlamaIndexKnowledgeStore(**options)
elif backend in ("cosmosdb", "cosmos", "azure_cosmos", "cosmosdb_vector"):
from .knowledge.cosmosdb_vector import CosmosDBVectorKnowledgeStore
return CosmosDBVectorKnowledgeStore(**options)
else:
raise ValueError(
f"Unknown knowledge store backend: {backend}. "
f"Supported: qdrant, pinecone, chroma, weaviate, lancedb, milvus, pgvector, redis, cassandra, clickhouse"
f"Supported: qdrant, pinecone, chroma, weaviate, lancedb, milvus, pgvector, redis, cassandra, clickhouse, "
f"couchbase, mongodb_vector, singlestore_vector, surrealdb_vector, upstash_vector, lightrag, langchain, llamaindex, cosmosdb"
)
@@ -190,10 +246,21 @@ def create_state_store(
from .state.memory import MemoryStateStore
return MemoryStateStore(**options)
elif backend == "gcs":
from .state.gcs import GCSStateStore
bucket = options.pop("bucket_name", None) or options.pop("bucket", None)
if not bucket:
raise ValueError("GCS state store requires 'bucket_name' option")
return GCSStateStore(bucket_name=bucket, **options)
elif backend in ("async_mongodb", "motor", "mongodb_async"):
from .state.async_mongodb import AsyncMongoDBStateStore
return AsyncMongoDBStateStore(url=url, **options)
else:
raise ValueError(
f"Unknown state store backend: {backend}. "
f"Supported: redis, dynamodb, firestore, mongodb, upstash, memory"
f"Supported: redis, dynamodb, firestore, mongodb, upstash, memory, gcs, async_mongodb"
)
@@ -0,0 +1,438 @@
"""
Azure Cosmos DB Vector implementation of KnowledgeStore.
Supports both MongoDB API and vCore modes.
Requires: pymongo (for MongoDB API) or azure-cosmos (for SQL API)
Install: pip install pymongo # or pip install azure-cosmos
"""
import logging
from typing import Any, Dict, List, Optional
from .base import KnowledgeStore, KnowledgeDocument
logger = logging.getLogger(__name__)
class CosmosDBVectorKnowledgeStore(KnowledgeStore):
"""
Azure Cosmos DB vector store for knowledge/RAG.
Supports MongoDB API with vector search capabilities.
Example:
store = CosmosDBVectorKnowledgeStore(
connection_string="mongodb+srv://...",
database="praisonai",
collection="vectors"
)
"""
def __init__(
self,
connection_string: Optional[str] = None,
database: str = "praisonai",
collection: str = "vectors",
index_name: str = "vector_index",
embedding_field: str = "embedding",
text_field: str = "content",
embedding_dim: int = 1536,
api_mode: str = "mongodb", # "mongodb" or "sql"
):
"""
Initialize Cosmos DB Vector store.
Args:
connection_string: Azure Cosmos DB connection string
database: Database name
collection: Collection name
index_name: Vector search index name
embedding_field: Field name for embeddings
text_field: Field name for text content
embedding_dim: Embedding dimension
api_mode: API mode ("mongodb" or "sql")
"""
self.connection_string = connection_string
self.database_name = database
self.collection_name = collection
self.index_name = index_name
self.embedding_field = embedding_field
self.text_field = text_field
self.embedding_dim = embedding_dim
self.api_mode = api_mode
self._client = None
self._db = None
self._collection = None
self._initialized = False
def _init_client(self):
"""Initialize Cosmos DB client lazily."""
if self._initialized:
return
if not self.connection_string:
import os
self.connection_string = os.getenv("COSMOS_CONNECTION_STRING")
if not self.connection_string:
raise ValueError(
"connection_string required. Set COSMOS_CONNECTION_STRING env var."
)
if self.api_mode == "mongodb":
self._init_mongodb_client()
else:
self._init_sql_client()
self._initialized = True
def _init_mongodb_client(self):
"""Initialize MongoDB API client."""
try:
from pymongo import MongoClient
except ImportError:
raise ImportError(
"pymongo is required for Cosmos DB MongoDB API. "
"Install with: pip install pymongo"
)
self._client = MongoClient(self.connection_string)
self._db = self._client[self.database_name]
self._collection = self._db[self.collection_name]
def _init_sql_client(self):
"""Initialize SQL API client."""
try:
from azure.cosmos import CosmosClient
except ImportError:
raise ImportError(
"azure-cosmos is required for Cosmos DB SQL API. "
"Install with: pip install azure-cosmos"
)
self._client = CosmosClient.from_connection_string(self.connection_string)
self._db = self._client.get_database_client(self.database_name)
self._collection = self._db.get_container_client(self.collection_name)
def create_collection(
self,
name: str,
dimension: int = 1536,
distance: str = "cosine",
metadata: Optional[Dict[str, Any]] = None
) -> None:
"""Create collection with vector index."""
self._init_client()
if self.api_mode == "mongodb":
# Create vector search index via MongoDB command
try:
self._db.command({
"createIndexes": name,
"indexes": [{
"name": self.index_name,
"key": {self.embedding_field: "cosmosSearch"},
"cosmosSearchOptions": {
"kind": "vector-ivf",
"numLists": 100,
"similarity": distance,
"dimensions": dimension
}
}]
})
except Exception as e:
logger.warning(f"Index creation may require manual setup: {e}")
def delete_collection(self, name: str) -> bool:
"""Delete collection."""
self._init_client()
try:
if self.api_mode == "mongodb":
self._db.drop_collection(name)
else:
self._db.delete_container(name)
return True
except Exception as e:
logger.error(f"Failed to delete collection: {e}")
return False
def collection_exists(self, name: str) -> bool:
"""Check if collection exists."""
self._init_client()
if self.api_mode == "mongodb":
return name in self._db.list_collection_names()
return True # SQL API containers are pre-created
def list_collections(self) -> List[str]:
"""List collections."""
self._init_client()
if self.api_mode == "mongodb":
return self._db.list_collection_names()
return [c["id"] for c in self._db.list_containers()]
def insert(
self,
collection: str,
documents: List[KnowledgeDocument]
) -> List[str]:
"""Insert documents."""
self._init_client()
coll = self._db[collection] if self.api_mode == "mongodb" else self._db.get_container_client(collection)
ids = []
for doc in documents:
item = {
"_id" if self.api_mode == "mongodb" else "id": doc.id,
self.text_field: doc.content,
self.embedding_field: doc.embedding,
"metadata": doc.metadata or {},
"content_hash": doc.content_hash,
"created_at": doc.created_at,
}
try:
if self.api_mode == "mongodb":
coll.insert_one(item)
else:
coll.create_item(item)
ids.append(doc.id)
except Exception as e:
logger.warning(f"Failed to insert {doc.id}: {e}")
return ids
def upsert(
self,
collection: str,
documents: List[KnowledgeDocument]
) -> List[str]:
"""Upsert documents."""
self._init_client()
coll = self._db[collection] if self.api_mode == "mongodb" else self._db.get_container_client(collection)
ids = []
for doc in documents:
item = {
"_id" if self.api_mode == "mongodb" else "id": doc.id,
self.text_field: doc.content,
self.embedding_field: doc.embedding,
"metadata": doc.metadata or {},
"content_hash": doc.content_hash,
"created_at": doc.created_at,
}
try:
if self.api_mode == "mongodb":
coll.replace_one({"_id": doc.id}, item, upsert=True)
else:
coll.upsert_item(item)
ids.append(doc.id)
except Exception as e:
logger.warning(f"Failed to upsert {doc.id}: {e}")
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 using vector search."""
self._init_client()
if self.api_mode == "mongodb":
return self._search_mongodb(collection, query_embedding, limit, filters, score_threshold)
else:
return self._search_sql(collection, query_embedding, limit, filters, score_threshold)
def _search_mongodb(
self,
collection: str,
query_embedding: List[float],
limit: int,
filters: Optional[Dict[str, Any]],
score_threshold: Optional[float]
) -> List[KnowledgeDocument]:
"""MongoDB API vector search."""
coll = self._db[collection]
pipeline = [
{
"$search": {
"cosmosSearch": {
"vector": query_embedding,
"path": self.embedding_field,
"k": limit
},
"returnStoredSource": True
}
},
{
"$project": {
"similarityScore": {"$meta": "searchScore"},
self.text_field: 1,
"metadata": 1,
"content_hash": 1,
"created_at": 1
}
}
]
if filters:
pipeline.insert(1, {"$match": filters})
try:
results = list(coll.aggregate(pipeline))
documents = []
for doc in results:
score = doc.get("similarityScore", 0)
if score_threshold and score < score_threshold:
continue
documents.append(KnowledgeDocument(
id=str(doc.get("_id", "")),
content=doc.get(self.text_field, ""),
metadata={**(doc.get("metadata") or {}), "score": score},
content_hash=doc.get("content_hash"),
created_at=doc.get("created_at", 0)
))
return documents
except Exception as e:
logger.error(f"Vector search failed: {e}")
return []
def _search_sql(
self,
collection: str,
query_embedding: List[float],
limit: int,
filters: Optional[Dict[str, Any]],
score_threshold: Optional[float]
) -> List[KnowledgeDocument]:
"""SQL API vector search (requires DiskANN index)."""
coll = self._db.get_container_client(collection)
# SQL API uses VectorDistance function
query = f"""
SELECT TOP {limit} c.id, c.{self.text_field}, c.metadata, c.content_hash, c.created_at,
VectorDistance(c.{self.embedding_field}, @embedding) AS score
FROM c
ORDER BY VectorDistance(c.{self.embedding_field}, @embedding)
"""
try:
results = list(coll.query_items(
query=query,
parameters=[{"name": "@embedding", "value": query_embedding}],
enable_cross_partition_query=True
))
documents = []
for doc in results:
score = doc.get("score", 0)
if score_threshold and score < score_threshold:
continue
documents.append(KnowledgeDocument(
id=doc.get("id", ""),
content=doc.get(self.text_field, ""),
metadata={**(doc.get("metadata") or {}), "score": score},
content_hash=doc.get("content_hash"),
created_at=doc.get("created_at", 0)
))
return documents
except Exception as e:
logger.error(f"Vector search failed: {e}")
return []
def get(
self,
collection: str,
ids: List[str]
) -> List[KnowledgeDocument]:
"""Get documents by IDs."""
self._init_client()
coll = self._db[collection] if self.api_mode == "mongodb" else self._db.get_container_client(collection)
documents = []
for doc_id in ids:
try:
if self.api_mode == "mongodb":
doc = coll.find_one({"_id": doc_id})
else:
doc = coll.read_item(item=doc_id, partition_key=doc_id)
if doc:
documents.append(KnowledgeDocument(
id=str(doc.get("_id" if self.api_mode == "mongodb" else "id", "")),
content=doc.get(self.text_field, ""),
embedding=doc.get(self.embedding_field),
metadata=doc.get("metadata"),
content_hash=doc.get("content_hash"),
created_at=doc.get("created_at", 0)
))
except Exception as e:
logger.warning(f"Failed to get {doc_id}: {e}")
return documents
def delete(
self,
collection: str,
ids: Optional[List[str]] = None,
filters: Optional[Dict[str, Any]] = None
) -> int:
"""Delete documents."""
self._init_client()
coll = self._db[collection] if self.api_mode == "mongodb" else self._db.get_container_client(collection)
if ids:
count = 0
for doc_id in ids:
try:
if self.api_mode == "mongodb":
coll.delete_one({"_id": doc_id})
else:
coll.delete_item(item=doc_id, partition_key=doc_id)
count += 1
except Exception as e:
logger.warning(f"Failed to delete {doc_id}: {e}")
return count
return 0
def count(self, collection: str) -> int:
"""Count documents."""
self._init_client()
if self.api_mode == "mongodb":
return self._db[collection].count_documents({})
else:
# SQL API count
coll = self._db.get_container_client(collection)
result = list(coll.query_items(
query="SELECT VALUE COUNT(1) FROM c",
enable_cross_partition_query=True
))
return result[0] if result else 0
def close(self) -> None:
"""Close the connection."""
if self._client:
if self.api_mode == "mongodb":
self._client.close()
self._client = None
self._db = None
self._collection = None
self._initialized = False
@@ -0,0 +1,286 @@
"""
Couchbase implementation of KnowledgeStore.
Requires: couchbase
Install: pip install couchbase
"""
import logging
from typing import Any, Dict, List, Optional
from .base import KnowledgeStore, KnowledgeDocument
logger = logging.getLogger(__name__)
class CouchbaseKnowledgeStore(KnowledgeStore):
"""
Couchbase vector store for knowledge/RAG.
Uses Couchbase's vector search capabilities.
Example:
store = CouchbaseKnowledgeStore(
connection_string="couchbase://localhost",
username="admin",
password="password",
bucket_name="praisonai"
)
"""
def __init__(
self,
connection_string: str = "couchbase://localhost",
username: str = "Administrator",
password: str = "",
bucket_name: str = "praisonai",
scope_name: str = "_default",
collection_name: str = "vectors",
index_name: str = "vector_index",
vector_field: str = "embedding",
text_field: str = "content",
embedding_dim: int = 1536,
):
"""
Initialize Couchbase knowledge store.
Args:
connection_string: Couchbase connection string
username: Couchbase username
password: Couchbase password
bucket_name: Bucket name
scope_name: Scope name
collection_name: Collection name
index_name: Vector search index name
vector_field: Field name for embeddings
text_field: Field name for text content
embedding_dim: Embedding dimension
"""
self.connection_string = connection_string
self.username = username
self.password = password
self.bucket_name = bucket_name
self.scope_name = scope_name
self.collection_name = collection_name
self.index_name = index_name
self.vector_field = vector_field
self.text_field = text_field
self.embedding_dim = embedding_dim
self._cluster = None
self._bucket = None
self._collection = None
self._initialized = False
def _init_client(self):
"""Initialize Couchbase client lazily."""
if self._initialized:
return
try:
from couchbase.cluster import Cluster
from couchbase.options import ClusterOptions
from couchbase.auth import PasswordAuthenticator
from datetime import timedelta
except ImportError:
raise ImportError(
"couchbase is required for Couchbase support. "
"Install with: pip install couchbase"
)
auth = PasswordAuthenticator(self.username, self.password)
options = ClusterOptions(auth)
self._cluster = Cluster(self.connection_string, options)
self._cluster.wait_until_ready(timedelta(seconds=10))
self._bucket = self._cluster.bucket(self.bucket_name)
scope = self._bucket.scope(self.scope_name)
self._collection = scope.collection(self.collection_name)
self._initialized = True
def insert(
self,
collection_name: str,
documents: List[KnowledgeDocument],
embeddings: Optional[List[List[float]]] = None,
) -> List[str]:
"""Insert documents into the store."""
self._init_client()
ids = []
for i, doc in enumerate(documents):
doc_id = doc.id or f"doc_{i}_{hash(doc.content)}"
data = {
self.text_field: doc.content,
"metadata": doc.metadata or {},
}
if embeddings and i < len(embeddings):
data[self.vector_field] = embeddings[i]
self._collection.upsert(doc_id, data)
ids.append(doc_id)
return ids
def upsert(
self,
collection_name: str,
documents: List[KnowledgeDocument],
embeddings: Optional[List[List[float]]] = None,
) -> List[str]:
"""Upsert documents (same as insert for Couchbase)."""
return self.insert(collection_name, documents, embeddings)
def search(
self,
collection_name: str,
query_embedding: List[float],
limit: int = 10,
filters: Optional[Dict[str, Any]] = None,
) -> List[KnowledgeDocument]:
"""Search for similar documents using vector search."""
self._init_client()
try:
from couchbase.vector_search import VectorQuery, VectorSearch
except ImportError:
logger.warning("Vector search not available in this Couchbase version")
return []
vector_query = VectorQuery(
self.vector_field,
query_embedding,
num_candidates=limit * 2
)
search_req = VectorSearch.from_vector_query(vector_query)
try:
scope = self._bucket.scope(self.scope_name)
result = scope.search(
self.index_name,
search_req,
limit=limit
)
documents = []
for row in result.rows():
doc_id = row.id
try:
doc_data = self._collection.get(doc_id).content_as[dict]
documents.append(KnowledgeDocument(
id=doc_id,
content=doc_data.get(self.text_field, ""),
metadata=doc_data.get("metadata", {}),
score=row.score if hasattr(row, 'score') else None
))
except Exception as e:
logger.warning(f"Failed to fetch document {doc_id}: {e}")
return documents
except Exception as e:
logger.error(f"Vector search failed: {e}")
return []
def delete(
self,
collection_name: str,
ids: Optional[List[str]] = None,
filters: Optional[Dict[str, Any]] = None,
) -> int:
"""Delete documents by ID."""
self._init_client()
if not ids:
return 0
count = 0
for doc_id in ids:
try:
self._collection.remove(doc_id)
count += 1
except Exception as e:
logger.warning(f"Failed to delete {doc_id}: {e}")
return count
def get(
self,
collection_name: str,
ids: List[str],
) -> List[KnowledgeDocument]:
"""Get documents by ID."""
self._init_client()
documents = []
for doc_id in ids:
try:
doc_data = self._collection.get(doc_id).content_as[dict]
documents.append(KnowledgeDocument(
id=doc_id,
content=doc_data.get(self.text_field, ""),
metadata=doc_data.get("metadata", {})
))
except Exception as e:
logger.warning(f"Failed to get {doc_id}: {e}")
return documents
def count(self, collection_name: str) -> int:
"""Count documents in collection."""
self._init_client()
try:
query = f"SELECT COUNT(*) as count FROM `{self.bucket_name}`.`{self.scope_name}`.`{self.collection_name}`"
result = self._cluster.query(query)
for row in result:
return row.get("count", 0)
except Exception as e:
logger.error(f"Count query failed: {e}")
return 0
def create_collection(self, collection_name: str, **kwargs) -> bool:
"""Create collection (Couchbase collections are pre-created)."""
self._init_client()
return True
def delete_collection(self, collection_name: str) -> bool:
"""Delete all documents in collection."""
self._init_client()
try:
query = f"DELETE FROM `{self.bucket_name}`.`{self.scope_name}`.`{self.collection_name}`"
self._cluster.query(query)
return True
except Exception as e:
logger.error(f"Delete collection failed: {e}")
return False
def list_collections(self) -> List[str]:
"""List available collections."""
self._init_client()
try:
collections = self._bucket.collections().get_all_scopes()
result = []
for scope in collections:
for coll in scope.collections:
result.append(f"{scope.name}.{coll.name}")
return result
except Exception as e:
logger.error(f"List collections failed: {e}")
return []
def close(self) -> None:
"""Close the connection."""
if self._cluster:
self._cluster.close()
self._cluster = None
self._bucket = None
self._collection = None
self._initialized = False
@@ -0,0 +1,291 @@
"""
LangChain VectorStore adapter implementation of KnowledgeStore.
Wraps any LangChain VectorStore for use with PraisonAI.
Requires: langchain-core
Install: pip install langchain-core
"""
import logging
from typing import Any, Dict, List, Optional
from .base import KnowledgeStore, KnowledgeDocument
logger = logging.getLogger(__name__)
class LangChainKnowledgeStore(KnowledgeStore):
"""
LangChain VectorStore adapter for knowledge/RAG.
Wraps any LangChain VectorStore for use with PraisonAI's interface.
Example:
from langchain_chroma import Chroma
lc_store = Chroma(persist_directory="./chroma_db")
store = LangChainKnowledgeStore(vectorstore=lc_store)
"""
def __init__(
self,
vectorstore: Any = None,
vectorstore_cls: Optional[str] = None,
vectorstore_kwargs: Optional[Dict[str, Any]] = None,
):
"""
Initialize LangChain adapter.
Args:
vectorstore: Pre-initialized LangChain VectorStore instance
vectorstore_cls: Class name to import (e.g., "langchain_chroma.Chroma")
vectorstore_kwargs: Kwargs for vectorstore initialization
"""
self._vectorstore = vectorstore
self._vectorstore_cls = vectorstore_cls
self._vectorstore_kwargs = vectorstore_kwargs or {}
self._initialized = vectorstore is not None
def _init_client(self):
"""Initialize vectorstore lazily."""
if self._initialized:
return
if not self._vectorstore_cls:
raise ValueError(
"Either vectorstore or vectorstore_cls must be provided"
)
# Dynamic import of vectorstore class
parts = self._vectorstore_cls.rsplit(".", 1)
if len(parts) == 2:
module_name, class_name = parts
import importlib
module = importlib.import_module(module_name)
cls = getattr(module, class_name)
self._vectorstore = cls(**self._vectorstore_kwargs)
else:
raise ValueError(f"Invalid vectorstore_cls: {self._vectorstore_cls}")
self._initialized = True
def create_collection(
self,
name: str,
dimension: int = 1536,
distance: str = "cosine",
metadata: Optional[Dict[str, Any]] = None
) -> None:
"""Create collection (handled by underlying vectorstore)."""
self._init_client()
logger.info(f"Collection creation handled by LangChain vectorstore")
def delete_collection(self, name: str) -> bool:
"""Delete collection."""
self._init_client()
try:
if hasattr(self._vectorstore, 'delete_collection'):
self._vectorstore.delete_collection()
return True
logger.warning("Underlying vectorstore doesn't support delete_collection")
return False
except Exception as e:
logger.error(f"Failed to delete collection: {e}")
return False
def collection_exists(self, name: str) -> bool:
"""Check if collection exists."""
self._init_client()
return self._vectorstore is not None
def list_collections(self) -> List[str]:
"""List collections."""
self._init_client()
if hasattr(self._vectorstore, 'list_collections'):
return self._vectorstore.list_collections()
return ["default"]
def insert(
self,
collection: str,
documents: List[KnowledgeDocument]
) -> List[str]:
"""Insert documents."""
self._init_client()
try:
from langchain_core.documents import Document as LCDocument
except ImportError:
raise ImportError(
"langchain-core is required. Install with: pip install langchain-core"
)
lc_docs = []
ids = []
for doc in documents:
lc_doc = LCDocument(
page_content=doc.content,
metadata={
**(doc.metadata or {}),
"id": doc.id,
"content_hash": doc.content_hash,
"created_at": doc.created_at,
}
)
lc_docs.append(lc_doc)
ids.append(doc.id)
if hasattr(self._vectorstore, 'add_documents'):
self._vectorstore.add_documents(lc_docs, ids=ids)
else:
logger.warning("Vectorstore doesn't support add_documents")
return ids
def upsert(
self,
collection: str,
documents: List[KnowledgeDocument]
) -> List[str]:
"""Upsert documents."""
# Most LangChain vectorstores handle upsert via add_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."""
self._init_client()
try:
if hasattr(self._vectorstore, 'similarity_search_by_vector'):
results = self._vectorstore.similarity_search_by_vector(
query_embedding,
k=limit,
filter=filters
)
else:
logger.warning("Vectorstore doesn't support vector search")
return []
documents = []
for lc_doc in results:
metadata = lc_doc.metadata or {}
documents.append(KnowledgeDocument(
id=metadata.get("id", ""),
content=lc_doc.page_content,
metadata=metadata,
content_hash=metadata.get("content_hash"),
created_at=metadata.get("created_at", 0)
))
return documents
except Exception as e:
logger.error(f"Search failed: {e}")
return []
def similarity_search(
self,
query_text: str,
limit: int = 5,
filters: Optional[Dict[str, Any]] = None
) -> List[KnowledgeDocument]:
"""
Text-based similarity search (native LangChain method).
Args:
query_text: Query string
limit: Maximum results
filters: Metadata filters
Returns:
List of matching documents
"""
self._init_client()
try:
results = self._vectorstore.similarity_search(
query_text,
k=limit,
filter=filters
)
documents = []
for lc_doc in results:
metadata = lc_doc.metadata or {}
documents.append(KnowledgeDocument(
id=metadata.get("id", ""),
content=lc_doc.page_content,
metadata=metadata,
content_hash=metadata.get("content_hash"),
created_at=metadata.get("created_at", 0)
))
return documents
except Exception as e:
logger.error(f"Similarity search failed: {e}")
return []
def get(
self,
collection: str,
ids: List[str]
) -> List[KnowledgeDocument]:
"""Get documents by IDs."""
self._init_client()
if hasattr(self._vectorstore, 'get'):
try:
results = self._vectorstore.get(ids)
documents = []
for lc_doc in results:
metadata = lc_doc.metadata or {}
documents.append(KnowledgeDocument(
id=metadata.get("id", ""),
content=lc_doc.page_content,
metadata=metadata
))
return documents
except Exception as e:
logger.warning(f"Get by ID failed: {e}")
return []
def delete(
self,
collection: str,
ids: Optional[List[str]] = None,
filters: Optional[Dict[str, Any]] = None
) -> int:
"""Delete documents."""
self._init_client()
if ids and hasattr(self._vectorstore, 'delete'):
try:
self._vectorstore.delete(ids)
return len(ids)
except Exception as e:
logger.error(f"Delete failed: {e}")
return 0
def count(self, collection: str) -> int:
"""Count documents."""
self._init_client()
if hasattr(self._vectorstore, '__len__'):
return len(self._vectorstore)
logger.warning("Vectorstore doesn't support counting")
return -1
def close(self) -> None:
"""Close the client."""
self._vectorstore = None
self._initialized = False
@@ -0,0 +1,212 @@
"""
LightRAG adapter implementation of KnowledgeStore.
Requires: lightrag
Install: pip install lightrag
"""
import logging
from typing import Any, Dict, List, Optional
from .base import KnowledgeStore, KnowledgeDocument
logger = logging.getLogger(__name__)
class LightRAGKnowledgeStore(KnowledgeStore):
"""
LightRAG adapter for knowledge/RAG.
Wraps LightRAG for use with PraisonAI's KnowledgeStore interface.
Example:
store = LightRAGKnowledgeStore(
working_dir="./lightrag_data"
)
"""
def __init__(
self,
working_dir: str = "./lightrag_data",
llm_model: str = "gpt-4o-mini",
embedding_model: str = "text-embedding-3-small",
**kwargs
):
"""
Initialize LightRAG adapter.
Args:
working_dir: Directory for LightRAG data
llm_model: LLM model for graph extraction
embedding_model: Embedding model name
**kwargs: Additional LightRAG options
"""
self.working_dir = working_dir
self.llm_model = llm_model
self.embedding_model = embedding_model
self.kwargs = kwargs
self._rag = None
self._initialized = False
def _init_client(self):
"""Initialize LightRAG client lazily."""
if self._initialized:
return
try:
from lightrag import LightRAG, QueryParam
from lightrag.llm import gpt_4o_mini_complete, openai_embedding
except ImportError:
raise ImportError(
"lightrag is required for LightRAG support. "
"Install with: pip install lightrag"
)
self._QueryParam = QueryParam
self._rag = LightRAG(
working_dir=self.working_dir,
llm_model_func=gpt_4o_mini_complete,
embedding_func=openai_embedding,
**self.kwargs
)
self._initialized = True
def create_collection(
self,
name: str,
dimension: int = 1536,
distance: str = "cosine",
metadata: Optional[Dict[str, Any]] = None
) -> None:
"""Create collection (LightRAG uses working directory)."""
self._init_client()
logger.info(f"LightRAG uses working_dir: {self.working_dir}")
def delete_collection(self, name: str) -> bool:
"""Delete collection data."""
import shutil
import os
try:
if os.path.exists(self.working_dir):
shutil.rmtree(self.working_dir)
return True
except Exception as e:
logger.error(f"Failed to delete collection: {e}")
return False
def collection_exists(self, name: str) -> bool:
"""Check if working directory exists."""
import os
return os.path.exists(self.working_dir)
def list_collections(self) -> List[str]:
"""List collections (single collection per working_dir)."""
import os
if os.path.exists(self.working_dir):
return ["default"]
return []
def insert(
self,
collection: str,
documents: List[KnowledgeDocument]
) -> List[str]:
"""Insert documents into LightRAG."""
self._init_client()
ids = []
for doc in documents:
try:
self._rag.insert(doc.content)
ids.append(doc.id)
except Exception as e:
logger.warning(f"Failed to insert document {doc.id}: {e}")
return ids
def upsert(
self,
collection: str,
documents: List[KnowledgeDocument]
) -> List[str]:
"""Upsert documents (same as insert for LightRAG)."""
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 using LightRAG's query method."""
self._init_client()
# LightRAG uses text query, not embeddings directly
# This is a limitation - we need the original query text
logger.warning(
"LightRAG search requires text query, not embeddings. "
"Use query() method directly for best results."
)
return []
def query(
self,
query_text: str,
mode: str = "hybrid",
limit: int = 5
) -> str:
"""
Query LightRAG with text (native method).
Args:
query_text: The query string
mode: Query mode (naive, local, global, hybrid)
limit: Maximum results
Returns:
Query response as string
"""
self._init_client()
try:
result = self._rag.query(
query_text,
param=self._QueryParam(mode=mode, top_k=limit)
)
return result
except Exception as e:
logger.error(f"Query failed: {e}")
return ""
def get(
self,
collection: str,
ids: List[str]
) -> List[KnowledgeDocument]:
"""Get documents by IDs (not supported by LightRAG)."""
logger.warning("LightRAG does not support get by ID")
return []
def delete(
self,
collection: str,
ids: Optional[List[str]] = None,
filters: Optional[Dict[str, Any]] = None
) -> int:
"""Delete documents (not directly supported)."""
logger.warning("LightRAG does not support individual document deletion")
return 0
def count(self, collection: str) -> int:
"""Count documents (not directly supported)."""
logger.warning("LightRAG does not support document counting")
return -1
def close(self) -> None:
"""Close the client."""
self._rag = None
self._initialized = False
@@ -0,0 +1,256 @@
"""
LlamaIndex VectorStore adapter implementation of KnowledgeStore.
Wraps any LlamaIndex VectorStore for use with PraisonAI.
Requires: llama-index-core
Install: pip install llama-index-core
"""
import logging
from typing import Any, Dict, List, Optional
from .base import KnowledgeStore, KnowledgeDocument
logger = logging.getLogger(__name__)
class LlamaIndexKnowledgeStore(KnowledgeStore):
"""
LlamaIndex VectorStore adapter for knowledge/RAG.
Wraps any LlamaIndex VectorStore for use with PraisonAI's interface.
Example:
from llama_index.vector_stores.chroma import ChromaVectorStore
li_store = ChromaVectorStore(chroma_collection=collection)
store = LlamaIndexKnowledgeStore(vector_store=li_store)
"""
def __init__(
self,
vector_store: Any = None,
vector_store_cls: Optional[str] = None,
vector_store_kwargs: Optional[Dict[str, Any]] = None,
):
"""
Initialize LlamaIndex adapter.
Args:
vector_store: Pre-initialized LlamaIndex VectorStore instance
vector_store_cls: Class name to import (e.g., "llama_index.vector_stores.chroma.ChromaVectorStore")
vector_store_kwargs: Kwargs for vector_store initialization
"""
self._vector_store = vector_store
self._vector_store_cls = vector_store_cls
self._vector_store_kwargs = vector_store_kwargs or {}
self._initialized = vector_store is not None
def _init_client(self):
"""Initialize vector store lazily."""
if self._initialized:
return
if not self._vector_store_cls:
raise ValueError(
"Either vector_store or vector_store_cls must be provided"
)
# Dynamic import of vector store class
parts = self._vector_store_cls.rsplit(".", 1)
if len(parts) == 2:
module_name, class_name = parts
import importlib
module = importlib.import_module(module_name)
cls = getattr(module, class_name)
self._vector_store = cls(**self._vector_store_kwargs)
else:
raise ValueError(f"Invalid vector_store_cls: {self._vector_store_cls}")
self._initialized = True
def create_collection(
self,
name: str,
dimension: int = 1536,
distance: str = "cosine",
metadata: Optional[Dict[str, Any]] = None
) -> None:
"""Create collection (handled by underlying vector store)."""
self._init_client()
logger.info("Collection creation handled by LlamaIndex vector store")
def delete_collection(self, name: str) -> bool:
"""Delete collection."""
self._init_client()
try:
if hasattr(self._vector_store, 'delete'):
self._vector_store.delete(delete_all=True)
return True
logger.warning("Underlying vector store doesn't support delete")
return False
except Exception as e:
logger.error(f"Failed to delete collection: {e}")
return False
def collection_exists(self, name: str) -> bool:
"""Check if collection exists."""
self._init_client()
return self._vector_store is not None
def list_collections(self) -> List[str]:
"""List collections."""
self._init_client()
return ["default"]
def insert(
self,
collection: str,
documents: List[KnowledgeDocument]
) -> List[str]:
"""Insert documents."""
self._init_client()
try:
from llama_index.core.schema import TextNode
except ImportError:
raise ImportError(
"llama-index-core is required. Install with: pip install llama-index-core"
)
nodes = []
ids = []
for doc in documents:
node = TextNode(
text=doc.content,
id_=doc.id,
metadata={
**(doc.metadata or {}),
"content_hash": doc.content_hash,
"created_at": doc.created_at,
},
embedding=doc.embedding
)
nodes.append(node)
ids.append(doc.id)
if hasattr(self._vector_store, 'add'):
self._vector_store.add(nodes)
else:
logger.warning("Vector store doesn't support add")
return ids
def upsert(
self,
collection: str,
documents: List[KnowledgeDocument]
) -> List[str]:
"""Upsert 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."""
self._init_client()
try:
from llama_index.core.vector_stores import VectorStoreQuery
except ImportError:
raise ImportError(
"llama-index-core is required. Install with: pip install llama-index-core"
)
try:
query = VectorStoreQuery(
query_embedding=query_embedding,
similarity_top_k=limit,
)
result = self._vector_store.query(query)
documents = []
for i, node in enumerate(result.nodes or []):
score = result.similarities[i] if result.similarities else None
if score_threshold and score and score < score_threshold:
continue
metadata = node.metadata or {}
if score:
metadata["score"] = score
documents.append(KnowledgeDocument(
id=node.node_id or node.id_,
content=node.text or node.get_content(),
embedding=node.embedding,
metadata=metadata,
content_hash=metadata.get("content_hash"),
created_at=metadata.get("created_at", 0)
))
return documents
except Exception as e:
logger.error(f"Search failed: {e}")
return []
def get(
self,
collection: str,
ids: List[str]
) -> List[KnowledgeDocument]:
"""Get documents by IDs."""
self._init_client()
documents = []
for doc_id in ids:
try:
if hasattr(self._vector_store, 'get'):
node = self._vector_store.get(doc_id)
if node:
metadata = node.metadata or {}
documents.append(KnowledgeDocument(
id=node.node_id or node.id_,
content=node.text or node.get_content(),
embedding=node.embedding,
metadata=metadata
))
except Exception as e:
logger.warning(f"Get by ID failed for {doc_id}: {e}")
return documents
def delete(
self,
collection: str,
ids: Optional[List[str]] = None,
filters: Optional[Dict[str, Any]] = None
) -> int:
"""Delete documents."""
self._init_client()
if ids and hasattr(self._vector_store, 'delete'):
try:
self._vector_store.delete(ref_doc_id=ids[0] if len(ids) == 1 else None)
return len(ids)
except Exception as e:
logger.error(f"Delete failed: {e}")
return 0
def count(self, collection: str) -> int:
"""Count documents."""
self._init_client()
logger.warning("LlamaIndex vector stores don't typically support counting")
return -1
def close(self) -> None:
"""Close the client."""
self._vector_store = None
self._initialized = False
@@ -0,0 +1,306 @@
"""
MongoDB Vector Search implementation of KnowledgeStore.
Requires: pymongo
Install: pip install pymongo
"""
import logging
from typing import Any, Dict, List, Optional
from .base import KnowledgeStore, KnowledgeDocument
logger = logging.getLogger(__name__)
class MongoDBVectorKnowledgeStore(KnowledgeStore):
"""
MongoDB Atlas Vector Search store for knowledge/RAG.
Uses MongoDB Atlas Vector Search capabilities.
Example:
store = MongoDBVectorKnowledgeStore(
url="mongodb+srv://...",
database="praisonai",
collection="vectors"
)
"""
def __init__(
self,
url: str = "mongodb://localhost:27017",
database: str = "praisonai",
collection: str = "vectors",
index_name: str = "vector_index",
embedding_field: str = "embedding",
text_field: str = "content",
embedding_dim: int = 1536,
):
"""
Initialize MongoDB Vector store.
Args:
url: MongoDB connection URL
database: Database name
collection: Collection name
index_name: Vector search index name
embedding_field: Field name for embeddings
text_field: Field name for text content
embedding_dim: Embedding dimension
"""
self.url = url
self.database_name = database
self.collection_name = collection
self.index_name = index_name
self.embedding_field = embedding_field
self.text_field = text_field
self.embedding_dim = embedding_dim
self._client = None
self._db = None
self._collection = None
self._initialized = False
def _init_client(self):
"""Initialize MongoDB client lazily."""
if self._initialized:
return
try:
from pymongo import MongoClient
except ImportError:
raise ImportError(
"pymongo is required for MongoDB Vector support. "
"Install with: pip install pymongo"
)
self._client = MongoClient(self.url)
self._db = self._client[self.database_name]
self._collection = self._db[self.collection_name]
self._initialized = True
def create_collection(
self,
name: str,
dimension: int = 1536,
distance: str = "cosine",
metadata: Optional[Dict[str, Any]] = None
) -> None:
"""Create collection (MongoDB creates on first insert)."""
self._init_client()
# MongoDB creates collections automatically on first insert
# Vector search index must be created via Atlas UI or API
logger.info(
f"Collection '{name}' will be created on first insert. "
f"Note: Vector search index must be created via MongoDB Atlas."
)
def delete_collection(self, name: str) -> bool:
"""Delete a collection."""
self._init_client()
try:
self._db.drop_collection(name)
return True
except Exception as e:
logger.error(f"Failed to delete collection {name}: {e}")
return False
def collection_exists(self, name: str) -> bool:
"""Check if collection exists."""
self._init_client()
return name in self._db.list_collection_names()
def list_collections(self) -> List[str]:
"""List all collections."""
self._init_client()
return self._db.list_collection_names()
def insert(
self,
collection: str,
documents: List[KnowledgeDocument]
) -> List[str]:
"""Insert documents into collection."""
self._init_client()
coll = self._db[collection]
ids = []
for doc in documents:
mongo_doc = {
"_id": doc.id,
self.text_field: doc.content,
"metadata": doc.metadata or {},
"content_hash": doc.content_hash,
"created_at": doc.created_at,
}
if doc.embedding:
mongo_doc[self.embedding_field] = doc.embedding
try:
coll.insert_one(mongo_doc)
ids.append(doc.id)
except Exception as e:
logger.warning(f"Failed to insert document {doc.id}: {e}")
return ids
def upsert(
self,
collection: str,
documents: List[KnowledgeDocument]
) -> List[str]:
"""Upsert documents into collection."""
self._init_client()
coll = self._db[collection]
ids = []
for doc in documents:
mongo_doc = {
self.text_field: doc.content,
"metadata": doc.metadata or {},
"content_hash": doc.content_hash,
"created_at": doc.created_at,
}
if doc.embedding:
mongo_doc[self.embedding_field] = doc.embedding
try:
coll.replace_one(
{"_id": doc.id},
{"_id": doc.id, **mongo_doc},
upsert=True
)
ids.append(doc.id)
except Exception as e:
logger.warning(f"Failed to upsert document {doc.id}: {e}")
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 using vector search."""
self._init_client()
coll = self._db[collection]
# Build aggregation pipeline for vector search
pipeline = [
{
"$vectorSearch": {
"index": self.index_name,
"path": self.embedding_field,
"queryVector": query_embedding,
"numCandidates": limit * 10,
"limit": limit
}
},
{
"$project": {
"_id": 1,
self.text_field: 1,
"metadata": 1,
"content_hash": 1,
"created_at": 1,
"score": {"$meta": "vectorSearchScore"}
}
}
]
# Add filter if provided
if filters:
pipeline[0]["$vectorSearch"]["filter"] = filters
try:
results = list(coll.aggregate(pipeline))
documents = []
for doc in results:
score = doc.get("score", 0)
if score_threshold and score < score_threshold:
continue
documents.append(KnowledgeDocument(
id=str(doc["_id"]),
content=doc.get(self.text_field, ""),
metadata={**(doc.get("metadata") or {}), "score": score},
content_hash=doc.get("content_hash"),
created_at=doc.get("created_at", 0)
))
return documents
except Exception as e:
logger.error(f"Vector search failed: {e}")
return []
def get(
self,
collection: str,
ids: List[str]
) -> List[KnowledgeDocument]:
"""Get documents by IDs."""
self._init_client()
coll = self._db[collection]
documents = []
for doc_id in ids:
try:
doc = coll.find_one({"_id": doc_id})
if doc:
documents.append(KnowledgeDocument(
id=str(doc["_id"]),
content=doc.get(self.text_field, ""),
embedding=doc.get(self.embedding_field),
metadata=doc.get("metadata"),
content_hash=doc.get("content_hash"),
created_at=doc.get("created_at", 0)
))
except Exception as e:
logger.warning(f"Failed to get document {doc_id}: {e}")
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."""
self._init_client()
coll = self._db[collection]
if ids:
result = coll.delete_many({"_id": {"$in": ids}})
return result.deleted_count
elif filters:
result = coll.delete_many(filters)
return result.deleted_count
else:
return 0
def count(self, collection: str) -> int:
"""Count documents in collection."""
self._init_client()
return self._db[collection].count_documents({})
def close(self) -> None:
"""Close the connection."""
if self._client:
self._client.close()
self._client = None
self._db = None
self._collection = None
self._initialized = False
Loaded 100 of 127 files, more files were not shown because too many files have changed in this diff. Show more