mirror of
https://github.com/MervinPraison/PraisonAI.git
synced 2026-11-09 03:39:37 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f5d4db4262 | ||
|
|
87d691c616 | ||
|
|
8ea15dee0f | ||
|
|
4f56842e08 | ||
|
|
a24836aaee | ||
|
|
be9ce27615 | ||
|
|
0159a1e082 | ||
|
|
71a5076c07 | ||
|
|
9815a9f8a8 | ||
|
|
2ae44a05b0 |
No files matched your search
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
|
||||
# Install Python packages (using latest versions)
|
||||
RUN pip install --no-cache-dir \
|
||||
praisonai_tools \
|
||||
"praisonai>=3.0.2" \
|
||||
"praisonai>=3.1.2" \
|
||||
"praisonai[chat]" \
|
||||
"embedchain[github,youtube]"
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ RUN mkdir -p /root/.praison
|
||||
# Install Python packages (using latest versions)
|
||||
RUN pip install --no-cache-dir \
|
||||
praisonai_tools \
|
||||
"praisonai>=3.0.2" \
|
||||
"praisonai>=3.1.2" \
|
||||
"praisonai[ui]" \
|
||||
"praisonai[chat]" \
|
||||
"praisonai[realtime]" \
|
||||
|
||||
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
|
||||
# Install Python packages (using latest versions)
|
||||
RUN pip install --no-cache-dir \
|
||||
praisonai_tools \
|
||||
"praisonai>=3.0.2" \
|
||||
"praisonai>=3.1.2" \
|
||||
"praisonai[ui]" \
|
||||
"praisonai[crewai]"
|
||||
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
PraisonAI Benchmark Example
|
||||
|
||||
This example demonstrates how to use the benchmark system programmatically
|
||||
to compare performance across different execution paths.
|
||||
|
||||
Usage:
|
||||
python benchmark_example.py
|
||||
|
||||
Requirements:
|
||||
- OPENAI_API_KEY environment variable set
|
||||
- praisonai package installed
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Ensure the package is importable
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '../../src/praisonai'))
|
||||
|
||||
from praisonai.cli.features.benchmark import BenchmarkHandler, BenchmarkReport
|
||||
|
||||
|
||||
def run_quick_comparison():
|
||||
"""Run a quick comparison of key execution paths."""
|
||||
print("=" * 60)
|
||||
print("Quick Benchmark Comparison")
|
||||
print("=" * 60)
|
||||
|
||||
handler = BenchmarkHandler()
|
||||
|
||||
# Run benchmark with 2 iterations on key paths
|
||||
report = handler.run_full_benchmark(
|
||||
prompt="What is 2+2?",
|
||||
iterations=2,
|
||||
paths=["openai_sdk", "praisonai_agent"],
|
||||
verbose=True
|
||||
)
|
||||
|
||||
# Print comparison table
|
||||
print("\n" + handler.create_comparison_table(report))
|
||||
|
||||
return report
|
||||
|
||||
|
||||
def run_full_benchmark():
|
||||
"""Run full benchmark suite across all paths."""
|
||||
print("=" * 60)
|
||||
print("Full Benchmark Suite")
|
||||
print("=" * 60)
|
||||
|
||||
handler = BenchmarkHandler()
|
||||
|
||||
# Run full benchmark
|
||||
report = handler.run_full_benchmark(
|
||||
prompt="Hi",
|
||||
iterations=3,
|
||||
verbose=True
|
||||
)
|
||||
|
||||
# Print full report
|
||||
handler.print_report(report)
|
||||
|
||||
return report
|
||||
|
||||
|
||||
def run_agent_benchmark():
|
||||
"""Benchmark PraisonAI Agent specifically."""
|
||||
print("=" * 60)
|
||||
print("Agent Benchmark")
|
||||
print("=" * 60)
|
||||
|
||||
handler = BenchmarkHandler()
|
||||
|
||||
# Benchmark agent vs SDK
|
||||
report = handler.run_full_benchmark(
|
||||
prompt="Explain Python in one sentence",
|
||||
iterations=3,
|
||||
paths=["openai_sdk", "praisonai_agent"],
|
||||
verbose=True
|
||||
)
|
||||
|
||||
# Show timeline diagrams
|
||||
for name, result in report.results.items():
|
||||
print(f"\n### {name}")
|
||||
print(handler.create_timeline_diagram(result))
|
||||
|
||||
# Show variance analysis
|
||||
print(handler.create_variance_table(report))
|
||||
|
||||
return report
|
||||
|
||||
|
||||
def save_benchmark_results(report: BenchmarkReport, output_path: str):
|
||||
"""Save benchmark results to JSON file."""
|
||||
import json
|
||||
|
||||
with open(output_path, 'w') as f:
|
||||
json.dump(report.to_dict(), f, indent=2)
|
||||
|
||||
print(f"\nResults saved to: {output_path}")
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point."""
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description="PraisonAI Benchmark Example")
|
||||
parser.add_argument(
|
||||
"--mode",
|
||||
choices=["quick", "full", "agent"],
|
||||
default="quick",
|
||||
help="Benchmark mode: quick (2 paths), full (all paths), agent (agent focus)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Output JSON file path"
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Check for API key
|
||||
if not os.environ.get("OPENAI_API_KEY"):
|
||||
print("Error: OPENAI_API_KEY environment variable not set")
|
||||
sys.exit(1)
|
||||
|
||||
# Run selected benchmark
|
||||
if args.mode == "quick":
|
||||
report = run_quick_comparison()
|
||||
elif args.mode == "full":
|
||||
report = run_full_benchmark()
|
||||
elif args.mode == "agent":
|
||||
report = run_agent_benchmark()
|
||||
|
||||
# Save results if requested
|
||||
if args.output:
|
||||
save_benchmark_results(report, args.output)
|
||||
|
||||
print("\n✅ Benchmark complete!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,165 @@
|
||||
"""
|
||||
Session Persistence Example
|
||||
|
||||
Demonstrates automatic session persistence with zero configuration.
|
||||
When you provide a session_id to an Agent, conversation history is
|
||||
automatically saved to disk and restored on subsequent runs.
|
||||
|
||||
Usage:
|
||||
python session_persistence_example.py
|
||||
|
||||
Requirements:
|
||||
- praisonaiagents package installed
|
||||
- OPENAI_API_KEY environment variable set
|
||||
"""
|
||||
|
||||
from praisonaiagents import Agent
|
||||
|
||||
|
||||
def example_basic_persistence():
|
||||
"""Basic session persistence example."""
|
||||
print("=" * 60)
|
||||
print("EXAMPLE 1: Basic Session Persistence")
|
||||
print("=" * 60)
|
||||
|
||||
session_id = "demo-session-basic"
|
||||
|
||||
# Create agent with session_id
|
||||
agent = Agent(
|
||||
name="Assistant",
|
||||
instructions="You are a helpful assistant. Be very brief.",
|
||||
session_id=session_id,
|
||||
verbose=False,
|
||||
)
|
||||
|
||||
# First message
|
||||
print("\n[User]: My favorite color is blue and my lucky number is 7.")
|
||||
response1 = agent.chat("My favorite color is blue and my lucky number is 7.")
|
||||
print(f"[Assistant]: {response1}")
|
||||
|
||||
# Second message - agent remembers
|
||||
print("\n[User]: What is my favorite color?")
|
||||
response2 = agent.chat("What is my favorite color?")
|
||||
print(f"[Assistant]: {response2}")
|
||||
|
||||
print(f"\n✅ Session saved to: ~/.praison/sessions/{session_id}.json")
|
||||
print(f" Chat history length: {len(agent.chat_history)} messages")
|
||||
|
||||
|
||||
def example_session_restoration():
|
||||
"""Demonstrates session restoration across Agent instances."""
|
||||
print("\n" + "=" * 60)
|
||||
print("EXAMPLE 2: Session Restoration")
|
||||
print("=" * 60)
|
||||
|
||||
session_id = "demo-session-restore"
|
||||
|
||||
# First agent instance
|
||||
print("\n--- First Agent Instance ---")
|
||||
agent1 = Agent(
|
||||
name="MemoryBot",
|
||||
instructions="You are a helpful assistant. Be very brief.",
|
||||
session_id=session_id,
|
||||
verbose=False,
|
||||
)
|
||||
|
||||
print("[User]: Remember that my pet's name is Max.")
|
||||
response1 = agent1.chat("Remember that my pet's name is Max.")
|
||||
print(f"[Assistant]: {response1}")
|
||||
|
||||
# Simulate new process by creating new agent instance
|
||||
print("\n--- New Agent Instance (simulating restart) ---")
|
||||
agent2 = Agent(
|
||||
name="MemoryBot",
|
||||
instructions="You are a helpful assistant. Be very brief.",
|
||||
session_id=session_id,
|
||||
verbose=False,
|
||||
)
|
||||
|
||||
print("[User]: What is my pet's name?")
|
||||
response2 = agent2.chat("What is my pet's name?")
|
||||
print(f"[Assistant]: {response2}")
|
||||
|
||||
if "max" in response2.lower():
|
||||
print("\n✅ Session restored successfully! Agent remembered 'Max'.")
|
||||
else:
|
||||
print("\n⚠️ Agent may not have remembered correctly.")
|
||||
|
||||
|
||||
def example_in_memory_only():
|
||||
"""Demonstrates in-memory memory without persistence."""
|
||||
print("\n" + "=" * 60)
|
||||
print("EXAMPLE 3: In-Memory Only (No session_id)")
|
||||
print("=" * 60)
|
||||
|
||||
# Agent without session_id - in-memory only
|
||||
agent = Agent(
|
||||
name="TempBot",
|
||||
instructions="You are a helpful assistant. Be very brief.",
|
||||
verbose=False,
|
||||
)
|
||||
|
||||
print("\n[User]: My name is Alice.")
|
||||
response1 = agent.chat("My name is Alice.")
|
||||
print(f"[Assistant]: {response1}")
|
||||
|
||||
print("\n[User]: What is my name?")
|
||||
response2 = agent.chat("What is my name?")
|
||||
print(f"[Assistant]: {response2}")
|
||||
|
||||
print(f"\n✅ In-memory chat history: {len(agent.chat_history)} messages")
|
||||
print(" (This will be lost when the Agent instance is garbage collected)")
|
||||
|
||||
|
||||
def example_direct_store_access():
|
||||
"""Demonstrates direct access to the session store."""
|
||||
print("\n" + "=" * 60)
|
||||
print("EXAMPLE 4: Direct Session Store Access")
|
||||
print("=" * 60)
|
||||
|
||||
from praisonaiagents.session import get_default_session_store
|
||||
|
||||
store = get_default_session_store()
|
||||
session_id = "demo-direct-access"
|
||||
|
||||
# Add messages directly
|
||||
store.add_user_message(session_id, "Hello from direct access!")
|
||||
store.add_assistant_message(session_id, "Hi! I received your message.")
|
||||
|
||||
# Get history
|
||||
history = store.get_chat_history(session_id)
|
||||
print(f"\n✅ Added {len(history)} messages directly to session store")
|
||||
|
||||
for msg in history:
|
||||
print(f" [{msg['role']}]: {msg['content']}")
|
||||
|
||||
# List all sessions
|
||||
sessions = store.list_sessions()
|
||||
print(f"\n📋 Total sessions in store: {len(sessions)}")
|
||||
|
||||
# Clean up demo session
|
||||
store.delete_session(session_id)
|
||||
print(f" Deleted demo session: {session_id}")
|
||||
|
||||
|
||||
def main():
|
||||
"""Run all examples."""
|
||||
print("\n" + "=" * 60)
|
||||
print("SESSION PERSISTENCE EXAMPLES")
|
||||
print("=" * 60)
|
||||
|
||||
# Run examples
|
||||
example_basic_persistence()
|
||||
example_session_restoration()
|
||||
example_in_memory_only()
|
||||
example_direct_store_access()
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("ALL EXAMPLES COMPLETE")
|
||||
print("=" * 60)
|
||||
print("\nSession files are stored in: ~/.praison/sessions/")
|
||||
print("You can inspect them with: cat ~/.praison/sessions/<session_id>.json")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -687,6 +687,11 @@ Your Goal: {self.goal}
|
||||
self._db = db
|
||||
self._session_id = session_id
|
||||
self._db_initialized = False
|
||||
|
||||
# Session store for JSON-based persistence (lazy - no imports until used)
|
||||
# Used when session_id is provided but no DB adapter
|
||||
self._session_store = None
|
||||
self._session_store_initialized = False
|
||||
|
||||
@property
|
||||
def console(self):
|
||||
@@ -2094,6 +2099,49 @@ Your Goal: {self.goal}"""
|
||||
|
||||
self._db_initialized = True
|
||||
self._current_run_id = None # Track current run
|
||||
|
||||
def _init_session_store(self):
|
||||
"""
|
||||
Initialize session store for JSON-based persistence (lazy, first chat only).
|
||||
|
||||
This is used when session_id is provided but no DB adapter.
|
||||
Enables automatic session persistence with zero configuration.
|
||||
"""
|
||||
if self._session_store_initialized:
|
||||
return
|
||||
|
||||
# Only initialize if session_id is provided and no DB adapter
|
||||
if self._session_id is None or self._db is not None:
|
||||
self._session_store_initialized = True
|
||||
return
|
||||
|
||||
try:
|
||||
from ..session import get_default_session_store
|
||||
self._session_store = get_default_session_store()
|
||||
|
||||
# Restore chat history from previous session
|
||||
history = self._session_store.get_chat_history(self._session_id)
|
||||
if history:
|
||||
# Only restore if chat_history is empty (avoid duplicates)
|
||||
if not self.chat_history:
|
||||
for msg in history:
|
||||
self.chat_history.append({
|
||||
"role": msg["role"],
|
||||
"content": msg["content"]
|
||||
})
|
||||
logging.info(f"Restored session {self._session_id} with {len(history)} messages from JSON store")
|
||||
|
||||
# Set agent info
|
||||
self._session_store.set_agent_info(
|
||||
self._session_id,
|
||||
agent_name=self.name,
|
||||
user_id=self.user_id,
|
||||
)
|
||||
except Exception as e:
|
||||
logging.warning(f"Failed to initialize session store: {e}")
|
||||
self._session_store = None
|
||||
|
||||
self._session_store_initialized = True
|
||||
|
||||
def _start_run(self, input_content: str):
|
||||
"""Start a new run (turn) for persistence tracking."""
|
||||
@@ -2135,17 +2183,27 @@ Your Goal: {self.goal}"""
|
||||
self._current_run_id = None
|
||||
|
||||
def _persist_message(self, role: str, content: str):
|
||||
"""Persist a message to the DB if adapter is provided."""
|
||||
if self._db is None:
|
||||
"""Persist a message to the DB or session store."""
|
||||
# Try DB adapter first
|
||||
if self._db is not None:
|
||||
try:
|
||||
if role == "user":
|
||||
self._db.on_user_message(self._session_id, content)
|
||||
elif role == "assistant":
|
||||
self._db.on_agent_message(self._session_id, content)
|
||||
except Exception as e:
|
||||
logging.warning(f"Failed to persist message to DB: {e}")
|
||||
return
|
||||
|
||||
try:
|
||||
if role == "user":
|
||||
self._db.on_user_message(self._session_id, content)
|
||||
elif role == "assistant":
|
||||
self._db.on_agent_message(self._session_id, content)
|
||||
except Exception as e:
|
||||
logging.warning(f"Failed to persist message: {e}")
|
||||
# Fall back to session store (JSON-based)
|
||||
if self._session_store is not None and self._session_id is not None:
|
||||
try:
|
||||
if role == "user":
|
||||
self._session_store.add_user_message(self._session_id, content)
|
||||
elif role == "assistant":
|
||||
self._session_store.add_assistant_message(self._session_id, content)
|
||||
except Exception as e:
|
||||
logging.warning(f"Failed to persist message to session store: {e}")
|
||||
|
||||
@property
|
||||
def session_id(self) -> Optional[str]:
|
||||
@@ -2160,6 +2218,10 @@ Your Goal: {self.goal}"""
|
||||
# Initialize DB session on first chat (lazy)
|
||||
self._init_db_session()
|
||||
|
||||
# Initialize session store for JSON-based persistence (lazy)
|
||||
# This enables automatic session persistence when session_id is provided
|
||||
self._init_session_store()
|
||||
|
||||
# Start a new run for this chat turn
|
||||
prompt_str = prompt if isinstance(prompt, str) else str(prompt)
|
||||
self._start_run(prompt_str)
|
||||
|
||||
@@ -58,6 +58,12 @@ class LLM:
|
||||
# Class-level flag for one-time logging configuration
|
||||
_logging_configured = False
|
||||
|
||||
# Class-level cache for LiteLLM module (avoids repeated import overhead)
|
||||
_litellm_module = None
|
||||
|
||||
# Class-level flag for LiteLLM global settings (set once)
|
||||
_litellm_configured = False
|
||||
|
||||
# Default window sizes for different models (75% of actual to be safe)
|
||||
MODEL_WINDOWS = {
|
||||
# OpenAI
|
||||
@@ -272,14 +278,16 @@ Respond with ONLY a valid JSON tool call in this format:
|
||||
if not LLM._logging_configured:
|
||||
LLM._configure_logging()
|
||||
|
||||
# Import litellm after logging is configured
|
||||
try:
|
||||
import litellm
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"LiteLLM is required but not installed. "
|
||||
"Please install with: pip install 'praisonaiagents[llm]'"
|
||||
)
|
||||
# Use cached LiteLLM module or import once (avoids repeated import overhead)
|
||||
if LLM._litellm_module is None:
|
||||
try:
|
||||
import litellm
|
||||
LLM._litellm_module = litellm
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"LiteLLM is required but not installed. "
|
||||
"Please install with: pip install 'praisonaiagents[llm]'"
|
||||
)
|
||||
|
||||
self.model = model
|
||||
self.timeout = timeout
|
||||
@@ -341,11 +349,12 @@ Respond with ONLY a valid JSON tool call in this format:
|
||||
self._formatted_tools_cache = {}
|
||||
self._max_cache_size = 100
|
||||
|
||||
# Enable error dropping for cleaner output
|
||||
import litellm
|
||||
litellm.drop_params = True
|
||||
# Enable parameter modification for providers like Anthropic
|
||||
litellm.modify_params = True
|
||||
# Configure LiteLLM global settings only once (avoids repeated setup cost)
|
||||
if not LLM._litellm_configured:
|
||||
litellm = LLM._litellm_module
|
||||
litellm.drop_params = True
|
||||
litellm.modify_params = True
|
||||
LLM._litellm_configured = True
|
||||
self._setup_event_tracking(events)
|
||||
|
||||
# Log all initialization parameters when in debug mode or verbose >= 10
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
# Session Persistence for PraisonAI Agents
|
||||
|
||||
This module provides automatic session persistence with zero configuration.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```python
|
||||
from praisonaiagents import Agent
|
||||
|
||||
# With session persistence (auto-enabled)
|
||||
agent = Agent(
|
||||
name="Assistant",
|
||||
session_id="my-session-123"
|
||||
)
|
||||
agent.start("Hello, my name is Alice")
|
||||
|
||||
# Later, in a new process - history is restored automatically
|
||||
agent = Agent(
|
||||
name="Assistant",
|
||||
session_id="my-session-123"
|
||||
)
|
||||
agent.start("What is my name?") # Agent remembers: "Alice"
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
When you provide a `session_id` to an Agent:
|
||||
|
||||
1. **Automatic Persistence**: Conversation history is automatically saved to disk
|
||||
2. **Automatic Restoration**: When a new Agent is created with the same `session_id`, history is restored
|
||||
3. **Zero Configuration**: No database setup required - uses JSON files by default
|
||||
|
||||
### Default Storage Location
|
||||
|
||||
Sessions are stored in: `~/.praison/sessions/{session_id}.json`
|
||||
|
||||
### Session File Format
|
||||
|
||||
```json
|
||||
{
|
||||
"session_id": "my-session-123",
|
||||
"messages": [
|
||||
{"role": "user", "content": "Hello", "timestamp": 1704153600.0},
|
||||
{"role": "assistant", "content": "Hi there!", "timestamp": 1704153601.5}
|
||||
],
|
||||
"created_at": "2026-01-02T04:00:00+00:00",
|
||||
"updated_at": "2026-01-02T04:01:00+00:00",
|
||||
"agent_name": "Assistant"
|
||||
}
|
||||
```
|
||||
|
||||
## Behavior Matrix
|
||||
|
||||
| Scenario | Behavior |
|
||||
|----------|----------|
|
||||
| `session_id` provided, no DB | JSON persistence (auto) |
|
||||
| `session_id` provided, with DB | DB adapter used |
|
||||
| No `session_id`, same Agent instance | In-memory only |
|
||||
| No `session_id`, new Agent instance | No history |
|
||||
|
||||
## Advanced Usage
|
||||
|
||||
### Direct Session Store Access
|
||||
|
||||
```python
|
||||
from praisonaiagents.session import get_default_session_store
|
||||
|
||||
store = get_default_session_store()
|
||||
|
||||
# Add messages
|
||||
store.add_user_message("session-123", "Hello")
|
||||
store.add_assistant_message("session-123", "Hi there!")
|
||||
|
||||
# Get history
|
||||
history = store.get_chat_history("session-123")
|
||||
# [{"role": "user", "content": "Hello"}, {"role": "assistant", "content": "Hi there!"}]
|
||||
|
||||
# List all sessions
|
||||
sessions = store.list_sessions()
|
||||
|
||||
# Delete a session
|
||||
store.delete_session("session-123")
|
||||
```
|
||||
|
||||
### Custom Session Directory
|
||||
|
||||
```python
|
||||
from praisonaiagents.session import DefaultSessionStore
|
||||
|
||||
store = DefaultSessionStore(
|
||||
session_dir="/custom/path/sessions",
|
||||
max_messages=200, # Default: 100
|
||||
lock_timeout=10.0, # Default: 5.0 seconds
|
||||
)
|
||||
```
|
||||
|
||||
### Using with DB Adapter
|
||||
|
||||
When a DB adapter is provided, it takes precedence over JSON persistence:
|
||||
|
||||
```python
|
||||
from praisonaiagents import Agent
|
||||
from praisonai.db import PostgresAdapter
|
||||
|
||||
agent = Agent(
|
||||
name="Assistant",
|
||||
session_id="my-session",
|
||||
db=PostgresAdapter(connection_string="...")
|
||||
)
|
||||
```
|
||||
|
||||
## Multi-Process Safety
|
||||
|
||||
The session store uses file locking to ensure safe concurrent access:
|
||||
|
||||
- **Unix**: Uses `fcntl.flock()` for file locking
|
||||
- **Windows**: Uses `msvcrt.locking()` for file locking
|
||||
- **Atomic Writes**: Uses temp file + rename to prevent corruption
|
||||
|
||||
Multiple processes can safely read/write to the same session.
|
||||
|
||||
## Context Caching
|
||||
|
||||
For cost optimization, use `prompt_caching=True` with Anthropic models:
|
||||
|
||||
```python
|
||||
agent = Agent(
|
||||
name="Assistant",
|
||||
session_id="my-session",
|
||||
prompt_caching=True, # Enables Anthropic prompt caching
|
||||
)
|
||||
```
|
||||
|
||||
This caches the system prompt, reducing token costs for repeated conversations.
|
||||
|
||||
## API Reference
|
||||
|
||||
### DefaultSessionStore
|
||||
|
||||
```python
|
||||
class DefaultSessionStore:
|
||||
def __init__(
|
||||
self,
|
||||
session_dir: Optional[str] = None, # Default: ~/.praison/sessions/
|
||||
max_messages: int = 100,
|
||||
lock_timeout: float = 5.0,
|
||||
): ...
|
||||
|
||||
def add_message(self, session_id: str, role: str, content: str) -> bool: ...
|
||||
def add_user_message(self, session_id: str, content: str) -> bool: ...
|
||||
def add_assistant_message(self, session_id: str, content: str) -> bool: ...
|
||||
def get_chat_history(self, session_id: str, max_messages: int = None) -> List[Dict]: ...
|
||||
def get_session(self, session_id: str) -> SessionData: ...
|
||||
def clear_session(self, session_id: str) -> bool: ...
|
||||
def delete_session(self, session_id: str) -> bool: ...
|
||||
def list_sessions(self, limit: int = 50) -> List[Dict]: ...
|
||||
def session_exists(self, session_id: str) -> bool: ...
|
||||
```
|
||||
|
||||
### SessionData
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class SessionData:
|
||||
session_id: str
|
||||
messages: List[SessionMessage]
|
||||
created_at: str
|
||||
updated_at: str
|
||||
agent_name: Optional[str]
|
||||
user_id: Optional[str]
|
||||
metadata: Dict[str, Any]
|
||||
|
||||
def get_chat_history(self, max_messages: int = None) -> List[Dict[str, str]]: ...
|
||||
```
|
||||
|
||||
### SessionMessage
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class SessionMessage:
|
||||
role: str # "user", "assistant", "system"
|
||||
content: str
|
||||
timestamp: float
|
||||
metadata: Dict[str, Any]
|
||||
```
|
||||
@@ -0,0 +1,70 @@
|
||||
"""
|
||||
Session persistence module for PraisonAI Agents.
|
||||
|
||||
Provides automatic session persistence with zero configuration.
|
||||
When a session_id is provided to an Agent, conversation history
|
||||
is automatically persisted to disk and restored on subsequent runs.
|
||||
|
||||
Usage:
|
||||
from praisonaiagents import Agent
|
||||
|
||||
# With session persistence (auto-enabled)
|
||||
agent = Agent(
|
||||
name="Assistant",
|
||||
session_id="my-session-123"
|
||||
)
|
||||
agent.start("Hello")
|
||||
|
||||
# Later, new process - history is restored
|
||||
agent = Agent(
|
||||
name="Assistant",
|
||||
session_id="my-session-123"
|
||||
)
|
||||
agent.start("What did I say before?") # Remembers!
|
||||
|
||||
Default storage: ~/.praison/sessions/{session_id}.json
|
||||
"""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .store import DefaultSessionStore, SessionMessage, SessionData
|
||||
|
||||
# Lazy loading for zero import overhead
|
||||
_module_cache = {}
|
||||
|
||||
|
||||
def __getattr__(name: str):
|
||||
"""Lazy load session components."""
|
||||
if name in _module_cache:
|
||||
return _module_cache[name]
|
||||
|
||||
if name == "DefaultSessionStore":
|
||||
from .store import DefaultSessionStore
|
||||
_module_cache[name] = DefaultSessionStore
|
||||
return DefaultSessionStore
|
||||
|
||||
if name == "SessionMessage":
|
||||
from .store import SessionMessage
|
||||
_module_cache[name] = SessionMessage
|
||||
return SessionMessage
|
||||
|
||||
if name == "SessionData":
|
||||
from .store import SessionData
|
||||
_module_cache[name] = SessionData
|
||||
return SessionData
|
||||
|
||||
if name == "get_default_session_store":
|
||||
from .store import get_default_session_store
|
||||
_module_cache[name] = get_default_session_store
|
||||
return get_default_session_store
|
||||
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DefaultSessionStore",
|
||||
"SessionMessage",
|
||||
"SessionData",
|
||||
"get_default_session_store",
|
||||
]
|
||||
@@ -0,0 +1,521 @@
|
||||
"""
|
||||
Default Session Store for PraisonAI Agents.
|
||||
|
||||
JSON-based session persistence with file locking and atomic writes.
|
||||
Zero dependencies beyond stdlib.
|
||||
"""
|
||||
|
||||
import fcntl
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Default session directory
|
||||
DEFAULT_SESSION_DIR = os.path.expanduser("~/.praison/sessions")
|
||||
|
||||
# Default limits
|
||||
DEFAULT_MAX_MESSAGES = 100
|
||||
DEFAULT_LOCK_TIMEOUT = 5.0 # seconds
|
||||
|
||||
|
||||
@dataclass
|
||||
class SessionMessage:
|
||||
"""A single message in a session."""
|
||||
role: str # "user", "assistant", "system"
|
||||
content: str
|
||||
timestamp: float = field(default_factory=time.time)
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary."""
|
||||
return {
|
||||
"role": self.role,
|
||||
"content": self.content,
|
||||
"timestamp": self.timestamp,
|
||||
"metadata": self.metadata,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> "SessionMessage":
|
||||
"""Create from dictionary."""
|
||||
return cls(
|
||||
role=data.get("role", "user"),
|
||||
content=data.get("content", ""),
|
||||
timestamp=data.get("timestamp", time.time()),
|
||||
metadata=data.get("metadata", {}),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SessionData:
|
||||
"""Complete session data structure."""
|
||||
session_id: str
|
||||
messages: List[SessionMessage] = field(default_factory=list)
|
||||
created_at: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
|
||||
updated_at: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
|
||||
agent_name: Optional[str] = None
|
||||
user_id: Optional[str] = None
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary for JSON serialization."""
|
||||
return {
|
||||
"session_id": self.session_id,
|
||||
"messages": [m.to_dict() for m in self.messages],
|
||||
"created_at": self.created_at,
|
||||
"updated_at": self.updated_at,
|
||||
"agent_name": self.agent_name,
|
||||
"user_id": self.user_id,
|
||||
"metadata": self.metadata,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> "SessionData":
|
||||
"""Create from dictionary."""
|
||||
messages = [
|
||||
SessionMessage.from_dict(m)
|
||||
for m in data.get("messages", [])
|
||||
]
|
||||
return cls(
|
||||
session_id=data.get("session_id", ""),
|
||||
messages=messages,
|
||||
created_at=data.get("created_at", datetime.now(timezone.utc).isoformat()),
|
||||
updated_at=data.get("updated_at", datetime.now(timezone.utc).isoformat()),
|
||||
agent_name=data.get("agent_name"),
|
||||
user_id=data.get("user_id"),
|
||||
metadata=data.get("metadata", {}),
|
||||
)
|
||||
|
||||
def get_chat_history(self, max_messages: Optional[int] = None) -> List[Dict[str, str]]:
|
||||
"""
|
||||
Get chat history in LLM-compatible format.
|
||||
|
||||
Returns list of {"role": "user/assistant", "content": "..."} dicts.
|
||||
"""
|
||||
messages = self.messages
|
||||
if max_messages and len(messages) > max_messages:
|
||||
messages = messages[-max_messages:]
|
||||
return [{"role": m.role, "content": m.content} for m in messages]
|
||||
|
||||
|
||||
class FileLock:
|
||||
"""
|
||||
Cross-platform file locking.
|
||||
|
||||
Uses fcntl on Unix and msvcrt on Windows.
|
||||
"""
|
||||
|
||||
def __init__(self, filepath: str, timeout: float = DEFAULT_LOCK_TIMEOUT):
|
||||
self.filepath = filepath
|
||||
self.timeout = timeout
|
||||
self._lock_file = None
|
||||
self._lock_path = filepath + ".lock"
|
||||
|
||||
def __enter__(self):
|
||||
self.acquire()
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
self.release()
|
||||
return False
|
||||
|
||||
def acquire(self) -> bool:
|
||||
"""Acquire the file lock."""
|
||||
# Ensure directory exists
|
||||
os.makedirs(os.path.dirname(self._lock_path) or ".", exist_ok=True)
|
||||
|
||||
start_time = time.time()
|
||||
while True:
|
||||
try:
|
||||
self._lock_file = open(self._lock_path, "w")
|
||||
if sys.platform == "win32":
|
||||
# Windows locking
|
||||
import msvcrt
|
||||
msvcrt.locking(self._lock_file.fileno(), msvcrt.LK_NBLCK, 1)
|
||||
else:
|
||||
# Unix locking
|
||||
fcntl.flock(self._lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
return True
|
||||
except (IOError, OSError, BlockingIOError):
|
||||
if self._lock_file:
|
||||
self._lock_file.close()
|
||||
self._lock_file = None
|
||||
|
||||
if time.time() - start_time > self.timeout:
|
||||
logger.warning(f"Failed to acquire lock for {self.filepath} after {self.timeout}s")
|
||||
return False
|
||||
|
||||
time.sleep(0.05) # Wait 50ms before retry
|
||||
|
||||
def release(self) -> None:
|
||||
"""Release the file lock."""
|
||||
if self._lock_file:
|
||||
try:
|
||||
if sys.platform == "win32":
|
||||
import msvcrt
|
||||
msvcrt.locking(self._lock_file.fileno(), msvcrt.LK_UNLCK, 1)
|
||||
else:
|
||||
fcntl.flock(self._lock_file.fileno(), fcntl.LOCK_UN)
|
||||
except (IOError, OSError):
|
||||
pass
|
||||
finally:
|
||||
self._lock_file.close()
|
||||
self._lock_file = None
|
||||
# Clean up lock file
|
||||
try:
|
||||
os.remove(self._lock_path)
|
||||
except (IOError, OSError):
|
||||
pass
|
||||
|
||||
|
||||
class DefaultSessionStore:
|
||||
"""
|
||||
JSON-based session persistence with file locking.
|
||||
|
||||
Features:
|
||||
- Zero configuration required
|
||||
- Automatic file locking for multi-process safety
|
||||
- Atomic writes to prevent corruption
|
||||
- Configurable message limits
|
||||
- Thread-safe operations
|
||||
|
||||
Usage:
|
||||
store = DefaultSessionStore()
|
||||
|
||||
# Add messages
|
||||
store.add_message("session-123", "user", "Hello")
|
||||
store.add_message("session-123", "assistant", "Hi there!")
|
||||
|
||||
# Get history
|
||||
history = store.get_chat_history("session-123")
|
||||
# [{"role": "user", "content": "Hello"}, {"role": "assistant", "content": "Hi there!"}]
|
||||
|
||||
# Restore in new process
|
||||
store2 = DefaultSessionStore()
|
||||
history = store2.get_chat_history("session-123") # Same history!
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
session_dir: Optional[str] = None,
|
||||
max_messages: int = DEFAULT_MAX_MESSAGES,
|
||||
lock_timeout: float = DEFAULT_LOCK_TIMEOUT,
|
||||
):
|
||||
"""
|
||||
Initialize session store.
|
||||
|
||||
Args:
|
||||
session_dir: Directory for session files. Defaults to ~/.praison/sessions/
|
||||
max_messages: Maximum messages to keep per session.
|
||||
lock_timeout: Timeout for file lock acquisition.
|
||||
"""
|
||||
self.session_dir = session_dir or DEFAULT_SESSION_DIR
|
||||
self.max_messages = max_messages
|
||||
self.lock_timeout = lock_timeout
|
||||
self._lock = threading.RLock()
|
||||
self._cache: Dict[str, SessionData] = {}
|
||||
|
||||
# Ensure session directory exists
|
||||
os.makedirs(self.session_dir, exist_ok=True)
|
||||
|
||||
def _get_session_path(self, session_id: str) -> str:
|
||||
"""Get the file path for a session."""
|
||||
# Sanitize session_id for filesystem
|
||||
safe_id = "".join(c if c.isalnum() or c in "-_" else "_" for c in session_id)
|
||||
return os.path.join(self.session_dir, f"{safe_id}.json")
|
||||
|
||||
def _load_session(self, session_id: str) -> SessionData:
|
||||
"""Load session from disk with file locking."""
|
||||
filepath = self._get_session_path(session_id)
|
||||
|
||||
# Check cache first
|
||||
with self._lock:
|
||||
if session_id in self._cache:
|
||||
return self._cache[session_id]
|
||||
|
||||
# Load from disk
|
||||
if not os.path.exists(filepath):
|
||||
session = SessionData(session_id=session_id)
|
||||
with self._lock:
|
||||
self._cache[session_id] = session
|
||||
return session
|
||||
|
||||
with FileLock(filepath, self.lock_timeout):
|
||||
try:
|
||||
with open(filepath, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
session = SessionData.from_dict(data)
|
||||
except (json.JSONDecodeError, IOError) as e:
|
||||
logger.warning(f"Failed to load session {session_id}: {e}")
|
||||
session = SessionData(session_id=session_id)
|
||||
|
||||
with self._lock:
|
||||
self._cache[session_id] = session
|
||||
|
||||
return session
|
||||
|
||||
def _save_session(self, session: SessionData) -> bool:
|
||||
"""Save session to disk with atomic write."""
|
||||
filepath = self._get_session_path(session.session_id)
|
||||
session.updated_at = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
# Trim messages if over limit
|
||||
if len(session.messages) > self.max_messages:
|
||||
session.messages = session.messages[-self.max_messages:]
|
||||
|
||||
with FileLock(filepath, self.lock_timeout):
|
||||
try:
|
||||
# Atomic write: write to temp file, then rename
|
||||
dir_path = os.path.dirname(filepath)
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w",
|
||||
encoding="utf-8",
|
||||
dir=dir_path,
|
||||
delete=False,
|
||||
suffix=".tmp"
|
||||
) as f:
|
||||
json.dump(session.to_dict(), f, indent=2, ensure_ascii=False)
|
||||
temp_path = f.name
|
||||
|
||||
# Atomic rename
|
||||
os.replace(temp_path, filepath)
|
||||
return True
|
||||
except (IOError, OSError) as e:
|
||||
logger.error(f"Failed to save session {session.session_id}: {e}")
|
||||
# Clean up temp file if it exists
|
||||
try:
|
||||
if 'temp_path' in locals():
|
||||
os.remove(temp_path)
|
||||
except (IOError, OSError):
|
||||
pass
|
||||
return False
|
||||
|
||||
def add_message(
|
||||
self,
|
||||
session_id: str,
|
||||
role: str,
|
||||
content: str,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Add a message to a session.
|
||||
|
||||
Args:
|
||||
session_id: The session ID.
|
||||
role: Message role ("user", "assistant", "system").
|
||||
content: Message content.
|
||||
metadata: Optional metadata.
|
||||
|
||||
Returns:
|
||||
True if saved successfully.
|
||||
"""
|
||||
filepath = self._get_session_path(session_id)
|
||||
|
||||
message = SessionMessage(
|
||||
role=role,
|
||||
content=content,
|
||||
timestamp=time.time(),
|
||||
metadata=metadata or {},
|
||||
)
|
||||
|
||||
# Use file lock for atomic read-modify-write
|
||||
with FileLock(filepath, self.lock_timeout):
|
||||
# Always reload from disk inside lock to avoid race conditions
|
||||
if os.path.exists(filepath):
|
||||
try:
|
||||
with open(filepath, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
session = SessionData.from_dict(data)
|
||||
except (json.JSONDecodeError, IOError):
|
||||
session = SessionData(session_id=session_id)
|
||||
else:
|
||||
session = SessionData(session_id=session_id)
|
||||
|
||||
# Add message
|
||||
session.messages.append(message)
|
||||
session.updated_at = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
# Trim messages if over limit
|
||||
if len(session.messages) > self.max_messages:
|
||||
session.messages = session.messages[-self.max_messages:]
|
||||
|
||||
# Write atomically
|
||||
try:
|
||||
dir_path = os.path.dirname(filepath) or "."
|
||||
os.makedirs(dir_path, exist_ok=True)
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w",
|
||||
encoding="utf-8",
|
||||
dir=dir_path,
|
||||
delete=False,
|
||||
suffix=".tmp"
|
||||
) as f:
|
||||
json.dump(session.to_dict(), f, indent=2, ensure_ascii=False)
|
||||
temp_path = f.name
|
||||
|
||||
os.replace(temp_path, filepath)
|
||||
|
||||
# Update cache
|
||||
with self._lock:
|
||||
self._cache[session_id] = session
|
||||
|
||||
return True
|
||||
except (IOError, OSError) as e:
|
||||
logger.error(f"Failed to save session {session_id}: {e}")
|
||||
try:
|
||||
if 'temp_path' in locals():
|
||||
os.remove(temp_path)
|
||||
except (IOError, OSError):
|
||||
pass
|
||||
return False
|
||||
|
||||
def add_user_message(
|
||||
self,
|
||||
session_id: str,
|
||||
content: str,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> bool:
|
||||
"""Add a user message to a session."""
|
||||
return self.add_message(session_id, "user", content, metadata)
|
||||
|
||||
def add_assistant_message(
|
||||
self,
|
||||
session_id: str,
|
||||
content: str,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> bool:
|
||||
"""Add an assistant message to a session."""
|
||||
return self.add_message(session_id, "assistant", content, metadata)
|
||||
|
||||
def get_chat_history(
|
||||
self,
|
||||
session_id: str,
|
||||
max_messages: Optional[int] = None,
|
||||
) -> List[Dict[str, str]]:
|
||||
"""
|
||||
Get chat history for a session in LLM-compatible format.
|
||||
|
||||
Args:
|
||||
session_id: The session ID.
|
||||
max_messages: Maximum messages to return (defaults to store limit).
|
||||
|
||||
Returns:
|
||||
List of {"role": "user/assistant", "content": "..."} dicts.
|
||||
"""
|
||||
session = self._load_session(session_id)
|
||||
limit = max_messages or self.max_messages
|
||||
return session.get_chat_history(limit)
|
||||
|
||||
def get_session(self, session_id: str) -> SessionData:
|
||||
"""Get full session data."""
|
||||
return self._load_session(session_id)
|
||||
|
||||
def set_agent_info(
|
||||
self,
|
||||
session_id: str,
|
||||
agent_name: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""Set agent info for a session."""
|
||||
session = self._load_session(session_id)
|
||||
|
||||
with self._lock:
|
||||
if agent_name:
|
||||
session.agent_name = agent_name
|
||||
if user_id:
|
||||
session.user_id = user_id
|
||||
self._cache[session_id] = session
|
||||
|
||||
return self._save_session(session)
|
||||
|
||||
def clear_session(self, session_id: str) -> bool:
|
||||
"""Clear all messages from a session."""
|
||||
session = self._load_session(session_id)
|
||||
|
||||
with self._lock:
|
||||
session.messages.clear()
|
||||
self._cache[session_id] = session
|
||||
|
||||
return self._save_session(session)
|
||||
|
||||
def delete_session(self, session_id: str) -> bool:
|
||||
"""Delete a session completely."""
|
||||
filepath = self._get_session_path(session_id)
|
||||
|
||||
with self._lock:
|
||||
if session_id in self._cache:
|
||||
del self._cache[session_id]
|
||||
|
||||
try:
|
||||
if os.path.exists(filepath):
|
||||
os.remove(filepath)
|
||||
return True
|
||||
except (IOError, OSError) as e:
|
||||
logger.error(f"Failed to delete session {session_id}: {e}")
|
||||
return False
|
||||
|
||||
def list_sessions(self, limit: int = 50) -> List[Dict[str, Any]]:
|
||||
"""List all sessions with metadata."""
|
||||
sessions = []
|
||||
|
||||
try:
|
||||
for filename in os.listdir(self.session_dir):
|
||||
if filename.endswith(".json"):
|
||||
filepath = os.path.join(self.session_dir, filename)
|
||||
try:
|
||||
with open(filepath, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
sessions.append({
|
||||
"session_id": data.get("session_id", filename[:-5]),
|
||||
"agent_name": data.get("agent_name"),
|
||||
"created_at": data.get("created_at"),
|
||||
"updated_at": data.get("updated_at"),
|
||||
"message_count": len(data.get("messages", [])),
|
||||
})
|
||||
except (json.JSONDecodeError, IOError):
|
||||
continue
|
||||
except (IOError, OSError):
|
||||
pass
|
||||
|
||||
# Sort by updated_at descending
|
||||
sessions.sort(key=lambda x: x.get("updated_at", ""), reverse=True)
|
||||
return sessions[:limit]
|
||||
|
||||
def session_exists(self, session_id: str) -> bool:
|
||||
"""Check if a session exists."""
|
||||
filepath = self._get_session_path(session_id)
|
||||
return os.path.exists(filepath)
|
||||
|
||||
def invalidate_cache(self, session_id: Optional[str] = None) -> None:
|
||||
"""Invalidate cache for a session or all sessions."""
|
||||
with self._lock:
|
||||
if session_id:
|
||||
self._cache.pop(session_id, None)
|
||||
else:
|
||||
self._cache.clear()
|
||||
|
||||
|
||||
# Global session store instance (lazy initialized)
|
||||
_default_store: Optional[DefaultSessionStore] = None
|
||||
_store_lock = threading.Lock()
|
||||
|
||||
|
||||
def get_default_session_store() -> DefaultSessionStore:
|
||||
"""Get the global default session store instance."""
|
||||
global _default_store
|
||||
|
||||
if _default_store is None:
|
||||
with _store_lock:
|
||||
if _default_store is None:
|
||||
_default_store = DefaultSessionStore()
|
||||
|
||||
return _default_store
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "praisonaiagents"
|
||||
version = "0.6.0"
|
||||
version = "0.6.2"
|
||||
description = "Praison AI agents for completing complex tasks with Self Reflection Agents"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
# Session module tests
|
||||
@@ -0,0 +1,404 @@
|
||||
"""
|
||||
TDD Tests for Session Store.
|
||||
|
||||
Tests for DefaultSessionStore with JSON-based persistence.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import pytest
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
from praisonaiagents.session.store import (
|
||||
DefaultSessionStore,
|
||||
SessionMessage,
|
||||
SessionData,
|
||||
FileLock,
|
||||
DEFAULT_MAX_MESSAGES,
|
||||
)
|
||||
|
||||
|
||||
class TestSessionMessage:
|
||||
"""Tests for SessionMessage dataclass."""
|
||||
|
||||
def test_create_message(self):
|
||||
"""Test creating a session message."""
|
||||
msg = SessionMessage(role="user", content="Hello")
|
||||
assert msg.role == "user"
|
||||
assert msg.content == "Hello"
|
||||
assert msg.timestamp > 0
|
||||
assert msg.metadata == {}
|
||||
|
||||
def test_message_to_dict(self):
|
||||
"""Test converting message to dict."""
|
||||
msg = SessionMessage(role="assistant", content="Hi there!")
|
||||
d = msg.to_dict()
|
||||
assert d["role"] == "assistant"
|
||||
assert d["content"] == "Hi there!"
|
||||
assert "timestamp" in d
|
||||
assert "metadata" in d
|
||||
|
||||
def test_message_from_dict(self):
|
||||
"""Test creating message from dict."""
|
||||
d = {"role": "user", "content": "Test", "timestamp": 123.0, "metadata": {"key": "value"}}
|
||||
msg = SessionMessage.from_dict(d)
|
||||
assert msg.role == "user"
|
||||
assert msg.content == "Test"
|
||||
assert msg.timestamp == 123.0
|
||||
assert msg.metadata == {"key": "value"}
|
||||
|
||||
|
||||
class TestSessionData:
|
||||
"""Tests for SessionData dataclass."""
|
||||
|
||||
def test_create_session(self):
|
||||
"""Test creating session data."""
|
||||
session = SessionData(session_id="test-123")
|
||||
assert session.session_id == "test-123"
|
||||
assert session.messages == []
|
||||
assert session.created_at is not None
|
||||
assert session.updated_at is not None
|
||||
|
||||
def test_session_to_dict(self):
|
||||
"""Test converting session to dict."""
|
||||
session = SessionData(session_id="test-123", agent_name="Assistant")
|
||||
session.messages.append(SessionMessage(role="user", content="Hello"))
|
||||
d = session.to_dict()
|
||||
assert d["session_id"] == "test-123"
|
||||
assert d["agent_name"] == "Assistant"
|
||||
assert len(d["messages"]) == 1
|
||||
|
||||
def test_session_from_dict(self):
|
||||
"""Test creating session from dict."""
|
||||
d = {
|
||||
"session_id": "test-456",
|
||||
"messages": [{"role": "user", "content": "Hi"}],
|
||||
"agent_name": "Bot",
|
||||
}
|
||||
session = SessionData.from_dict(d)
|
||||
assert session.session_id == "test-456"
|
||||
assert session.agent_name == "Bot"
|
||||
assert len(session.messages) == 1
|
||||
|
||||
def test_get_chat_history(self):
|
||||
"""Test getting chat history in LLM format."""
|
||||
session = SessionData(session_id="test")
|
||||
session.messages.append(SessionMessage(role="user", content="Hello"))
|
||||
session.messages.append(SessionMessage(role="assistant", content="Hi!"))
|
||||
|
||||
history = session.get_chat_history()
|
||||
assert len(history) == 2
|
||||
assert history[0] == {"role": "user", "content": "Hello"}
|
||||
assert history[1] == {"role": "assistant", "content": "Hi!"}
|
||||
|
||||
def test_get_chat_history_with_limit(self):
|
||||
"""Test getting limited chat history."""
|
||||
session = SessionData(session_id="test")
|
||||
for i in range(10):
|
||||
session.messages.append(SessionMessage(role="user", content=f"Message {i}"))
|
||||
|
||||
history = session.get_chat_history(max_messages=3)
|
||||
assert len(history) == 3
|
||||
assert history[0]["content"] == "Message 7"
|
||||
assert history[2]["content"] == "Message 9"
|
||||
|
||||
|
||||
class TestFileLock:
|
||||
"""Tests for FileLock."""
|
||||
|
||||
def test_acquire_release(self):
|
||||
"""Test basic lock acquire and release."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
filepath = os.path.join(tmpdir, "test.json")
|
||||
lock = FileLock(filepath, timeout=1.0)
|
||||
|
||||
assert lock.acquire()
|
||||
lock.release()
|
||||
|
||||
def test_context_manager(self):
|
||||
"""Test lock as context manager."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
filepath = os.path.join(tmpdir, "test.json")
|
||||
|
||||
with FileLock(filepath, timeout=1.0):
|
||||
# Lock is held
|
||||
pass
|
||||
# Lock is released
|
||||
|
||||
def test_concurrent_locks(self):
|
||||
"""Test that concurrent locks are serialized."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
filepath = os.path.join(tmpdir, "test.json")
|
||||
results = []
|
||||
|
||||
def worker(worker_id):
|
||||
with FileLock(filepath, timeout=5.0):
|
||||
results.append(f"start-{worker_id}")
|
||||
time.sleep(0.1)
|
||||
results.append(f"end-{worker_id}")
|
||||
|
||||
threads = [threading.Thread(target=worker, args=(i,)) for i in range(3)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
# Check that starts and ends are paired (no interleaving)
|
||||
for i in range(3):
|
||||
start_idx = results.index(f"start-{i}")
|
||||
end_idx = results.index(f"end-{i}")
|
||||
# No other start should be between this start and end
|
||||
for j in range(3):
|
||||
if i != j:
|
||||
other_start = results.index(f"start-{j}")
|
||||
if start_idx < other_start < end_idx:
|
||||
pytest.fail(f"Lock interleaving detected: {results}")
|
||||
|
||||
|
||||
class TestDefaultSessionStore:
|
||||
"""Tests for DefaultSessionStore."""
|
||||
|
||||
@pytest.fixture
|
||||
def temp_store(self):
|
||||
"""Create a temporary session store."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
store = DefaultSessionStore(session_dir=tmpdir)
|
||||
yield store
|
||||
|
||||
def test_add_message(self, temp_store):
|
||||
"""Test adding a message."""
|
||||
result = temp_store.add_message("session-1", "user", "Hello")
|
||||
assert result is True
|
||||
|
||||
history = temp_store.get_chat_history("session-1")
|
||||
assert len(history) == 1
|
||||
assert history[0]["role"] == "user"
|
||||
assert history[0]["content"] == "Hello"
|
||||
|
||||
def test_add_user_message(self, temp_store):
|
||||
"""Test adding a user message."""
|
||||
temp_store.add_user_message("session-1", "Hello")
|
||||
history = temp_store.get_chat_history("session-1")
|
||||
assert history[0]["role"] == "user"
|
||||
|
||||
def test_add_assistant_message(self, temp_store):
|
||||
"""Test adding an assistant message."""
|
||||
temp_store.add_assistant_message("session-1", "Hi there!")
|
||||
history = temp_store.get_chat_history("session-1")
|
||||
assert history[0]["role"] == "assistant"
|
||||
|
||||
def test_persistence_across_instances(self, temp_store):
|
||||
"""Test that sessions persist across store instances."""
|
||||
session_dir = temp_store.session_dir
|
||||
|
||||
# Add messages with first store
|
||||
temp_store.add_user_message("session-1", "Hello")
|
||||
temp_store.add_assistant_message("session-1", "Hi!")
|
||||
|
||||
# Create new store instance
|
||||
store2 = DefaultSessionStore(session_dir=session_dir)
|
||||
|
||||
# Verify messages are restored
|
||||
history = store2.get_chat_history("session-1")
|
||||
assert len(history) == 2
|
||||
assert history[0]["content"] == "Hello"
|
||||
assert history[1]["content"] == "Hi!"
|
||||
|
||||
def test_session_file_created(self, temp_store):
|
||||
"""Test that session file is created on disk."""
|
||||
temp_store.add_user_message("test-session", "Hello")
|
||||
|
||||
filepath = os.path.join(temp_store.session_dir, "test-session.json")
|
||||
assert os.path.exists(filepath)
|
||||
|
||||
with open(filepath, "r") as f:
|
||||
data = json.load(f)
|
||||
assert data["session_id"] == "test-session"
|
||||
assert len(data["messages"]) == 1
|
||||
|
||||
def test_max_messages_limit(self):
|
||||
"""Test that messages are trimmed to max limit."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
store = DefaultSessionStore(session_dir=tmpdir, max_messages=5)
|
||||
|
||||
for i in range(10):
|
||||
store.add_user_message("session-1", f"Message {i}")
|
||||
|
||||
history = store.get_chat_history("session-1")
|
||||
assert len(history) == 5
|
||||
assert history[0]["content"] == "Message 5"
|
||||
assert history[4]["content"] == "Message 9"
|
||||
|
||||
def test_clear_session(self, temp_store):
|
||||
"""Test clearing a session."""
|
||||
temp_store.add_user_message("session-1", "Hello")
|
||||
temp_store.add_assistant_message("session-1", "Hi!")
|
||||
|
||||
temp_store.clear_session("session-1")
|
||||
|
||||
history = temp_store.get_chat_history("session-1")
|
||||
assert len(history) == 0
|
||||
|
||||
def test_delete_session(self, temp_store):
|
||||
"""Test deleting a session."""
|
||||
temp_store.add_user_message("session-1", "Hello")
|
||||
filepath = os.path.join(temp_store.session_dir, "session-1.json")
|
||||
assert os.path.exists(filepath)
|
||||
|
||||
temp_store.delete_session("session-1")
|
||||
assert not os.path.exists(filepath)
|
||||
|
||||
def test_list_sessions(self, temp_store):
|
||||
"""Test listing sessions."""
|
||||
temp_store.add_user_message("session-1", "Hello")
|
||||
temp_store.add_user_message("session-2", "Hi")
|
||||
temp_store.add_user_message("session-3", "Hey")
|
||||
|
||||
sessions = temp_store.list_sessions()
|
||||
assert len(sessions) == 3
|
||||
session_ids = [s["session_id"] for s in sessions]
|
||||
assert "session-1" in session_ids
|
||||
assert "session-2" in session_ids
|
||||
assert "session-3" in session_ids
|
||||
|
||||
def test_session_exists(self, temp_store):
|
||||
"""Test checking if session exists."""
|
||||
assert not temp_store.session_exists("session-1")
|
||||
|
||||
temp_store.add_user_message("session-1", "Hello")
|
||||
assert temp_store.session_exists("session-1")
|
||||
|
||||
def test_set_agent_info(self, temp_store):
|
||||
"""Test setting agent info."""
|
||||
temp_store.add_user_message("session-1", "Hello")
|
||||
temp_store.set_agent_info("session-1", agent_name="TestBot", user_id="user-123")
|
||||
|
||||
session = temp_store.get_session("session-1")
|
||||
assert session.agent_name == "TestBot"
|
||||
assert session.user_id == "user-123"
|
||||
|
||||
def test_concurrent_writes(self, temp_store):
|
||||
"""Test concurrent writes to same session."""
|
||||
def writer(msg_id):
|
||||
temp_store.add_user_message("session-1", f"Message {msg_id}")
|
||||
|
||||
with ThreadPoolExecutor(max_workers=5) as executor:
|
||||
futures = [executor.submit(writer, i) for i in range(10)]
|
||||
for f in futures:
|
||||
f.result()
|
||||
|
||||
history = temp_store.get_chat_history("session-1")
|
||||
assert len(history) == 10
|
||||
|
||||
def test_sanitize_session_id(self, temp_store):
|
||||
"""Test that session IDs are sanitized for filesystem."""
|
||||
temp_store.add_user_message("session/with/slashes", "Hello")
|
||||
temp_store.add_user_message("session:with:colons", "Hi")
|
||||
|
||||
# Should not raise errors
|
||||
history1 = temp_store.get_chat_history("session/with/slashes")
|
||||
history2 = temp_store.get_chat_history("session:with:colons")
|
||||
assert len(history1) == 1
|
||||
assert len(history2) == 1
|
||||
|
||||
def test_empty_session_returns_empty_history(self, temp_store):
|
||||
"""Test that non-existent session returns empty history."""
|
||||
history = temp_store.get_chat_history("nonexistent")
|
||||
assert history == []
|
||||
|
||||
def test_invalidate_cache(self, temp_store):
|
||||
"""Test cache invalidation."""
|
||||
temp_store.add_user_message("session-1", "Hello")
|
||||
|
||||
# Modify file directly
|
||||
filepath = os.path.join(temp_store.session_dir, "session-1.json")
|
||||
with open(filepath, "r") as f:
|
||||
data = json.load(f)
|
||||
data["messages"].append({"role": "user", "content": "Direct edit", "timestamp": time.time(), "metadata": {}})
|
||||
with open(filepath, "w") as f:
|
||||
json.dump(data, f)
|
||||
|
||||
# Without invalidation, cache returns old data
|
||||
history = temp_store.get_chat_history("session-1")
|
||||
assert len(history) == 1 # Cached
|
||||
|
||||
# After invalidation, new data is loaded
|
||||
temp_store.invalidate_cache("session-1")
|
||||
history = temp_store.get_chat_history("session-1")
|
||||
assert len(history) == 2
|
||||
|
||||
|
||||
class TestAgentSessionIntegration:
|
||||
"""Tests for Agent integration with session store."""
|
||||
|
||||
def test_agent_session_persistence(self):
|
||||
"""Test that Agent persists session with session_id."""
|
||||
from praisonaiagents import Agent
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
# Patch the default session directory
|
||||
import praisonaiagents.session.store as store_module
|
||||
original_dir = store_module.DEFAULT_SESSION_DIR
|
||||
store_module.DEFAULT_SESSION_DIR = tmpdir
|
||||
|
||||
# Reset global store
|
||||
store_module._default_store = None
|
||||
|
||||
try:
|
||||
# Create agent with session_id
|
||||
agent = Agent(
|
||||
name="TestAgent",
|
||||
instructions="You are a test agent.",
|
||||
session_id="test-session-123",
|
||||
verbose=False,
|
||||
)
|
||||
|
||||
# Verify session store is initialized after first chat
|
||||
# (We can't actually call chat without API key, so we test init)
|
||||
agent._init_session_store()
|
||||
|
||||
assert agent._session_store is not None
|
||||
assert agent._session_store_initialized is True
|
||||
finally:
|
||||
store_module.DEFAULT_SESSION_DIR = original_dir
|
||||
store_module._default_store = None
|
||||
|
||||
def test_agent_no_session_id_no_persistence(self):
|
||||
"""Test that Agent without session_id doesn't use session store."""
|
||||
from praisonaiagents import Agent
|
||||
|
||||
agent = Agent(
|
||||
name="TestAgent",
|
||||
instructions="You are a test agent.",
|
||||
verbose=False,
|
||||
)
|
||||
|
||||
agent._init_session_store()
|
||||
|
||||
assert agent._session_store is None
|
||||
assert agent._session_store_initialized is True
|
||||
|
||||
def test_agent_with_db_no_session_store(self):
|
||||
"""Test that Agent with DB adapter doesn't use session store."""
|
||||
from praisonaiagents import Agent
|
||||
|
||||
# Mock DB adapter
|
||||
class MockDbAdapter:
|
||||
def on_agent_start(self, **kwargs):
|
||||
return []
|
||||
|
||||
agent = Agent(
|
||||
name="TestAgent",
|
||||
instructions="You are a test agent.",
|
||||
session_id="test-session",
|
||||
db=MockDbAdapter(),
|
||||
verbose=False,
|
||||
)
|
||||
|
||||
agent._init_session_store()
|
||||
|
||||
# Session store should not be used when DB is provided
|
||||
assert agent._session_store is None
|
||||
Generated
+1
-1
@@ -2422,7 +2422,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "praisonaiagents"
|
||||
version = "0.6.0"
|
||||
version = "0.6.2"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
|
||||
@@ -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/v3.0.2.tar.gz"
|
||||
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v3.0.2.tar.gz | shasum -a 256`.split.first
|
||||
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v3.1.2.tar.gz"
|
||||
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v3.1.2.tar.gz | shasum -a 256`.split.first
|
||||
license "MIT"
|
||||
|
||||
depends_on "[email protected]"
|
||||
|
||||
@@ -0,0 +1,442 @@
|
||||
"""
|
||||
Safe Edit Pipeline for ACP.
|
||||
|
||||
Implements a propose → diff → approve → apply workflow for file edits.
|
||||
Ensures no silent file writes - all changes require explicit approval.
|
||||
"""
|
||||
|
||||
import difflib
|
||||
import logging
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EditStatus(str, Enum):
|
||||
"""Status of an edit proposal."""
|
||||
PENDING = "pending"
|
||||
APPROVED = "approved"
|
||||
REJECTED = "rejected"
|
||||
APPLIED = "applied"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
@dataclass
|
||||
class EditProposal:
|
||||
"""
|
||||
A proposed file edit with diff preview.
|
||||
|
||||
Represents a change that has been proposed but not yet applied.
|
||||
"""
|
||||
proposal_id: str
|
||||
file_path: Path
|
||||
original_content: str
|
||||
proposed_content: str
|
||||
description: str
|
||||
diff_lines: List[str] = field(default_factory=list)
|
||||
status: EditStatus = EditStatus.PENDING
|
||||
created_at: str = field(default_factory=lambda: datetime.now().isoformat())
|
||||
approved_at: Optional[str] = None
|
||||
applied_at: Optional[str] = None
|
||||
error: Optional[str] = None
|
||||
|
||||
def __post_init__(self):
|
||||
"""Generate diff on creation."""
|
||||
if not self.diff_lines:
|
||||
self.diff_lines = self._generate_diff()
|
||||
|
||||
def _generate_diff(self) -> List[str]:
|
||||
"""Generate unified diff between original and proposed content."""
|
||||
original_lines = self.original_content.splitlines(keepends=True)
|
||||
proposed_lines = self.proposed_content.splitlines(keepends=True)
|
||||
|
||||
diff = difflib.unified_diff(
|
||||
original_lines,
|
||||
proposed_lines,
|
||||
fromfile=f"a/{self.file_path.name}",
|
||||
tofile=f"b/{self.file_path.name}",
|
||||
lineterm=""
|
||||
)
|
||||
return list(diff)
|
||||
|
||||
@property
|
||||
def diff_text(self) -> str:
|
||||
"""Get diff as a single string."""
|
||||
return "\n".join(self.diff_lines)
|
||||
|
||||
@property
|
||||
def is_new_file(self) -> bool:
|
||||
"""Check if this is a new file creation."""
|
||||
return not self.original_content
|
||||
|
||||
@property
|
||||
def is_deletion(self) -> bool:
|
||||
"""Check if this is a file deletion."""
|
||||
return not self.proposed_content and bool(self.original_content)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary for serialization."""
|
||||
return {
|
||||
"proposal_id": self.proposal_id,
|
||||
"file_path": str(self.file_path),
|
||||
"description": self.description,
|
||||
"status": self.status.value,
|
||||
"is_new_file": self.is_new_file,
|
||||
"is_deletion": self.is_deletion,
|
||||
"diff": self.diff_text,
|
||||
"created_at": self.created_at,
|
||||
"approved_at": self.approved_at,
|
||||
"applied_at": self.applied_at,
|
||||
"error": self.error,
|
||||
}
|
||||
|
||||
|
||||
class SafeEditPipeline:
|
||||
"""
|
||||
Safe file editing with diff preview and approval workflow.
|
||||
|
||||
Workflow:
|
||||
1. propose_edit() - Create an edit proposal with diff
|
||||
2. show_diff() - Display the diff to user
|
||||
3. approve() / reject() - User decision
|
||||
4. apply_edit() - Apply approved edit
|
||||
|
||||
ACP remains read-only by default. Writes only happen after explicit approval.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
workspace: Optional[Path] = None,
|
||||
auto_approve: bool = False,
|
||||
approval_callback: Optional[callable] = None,
|
||||
):
|
||||
"""
|
||||
Initialize safe edit pipeline.
|
||||
|
||||
Args:
|
||||
workspace: Root workspace directory for path validation
|
||||
auto_approve: If True, auto-approve all edits (dangerous, for testing only)
|
||||
approval_callback: Optional callback for approval (async or sync)
|
||||
"""
|
||||
self.workspace = Path(workspace) if workspace else Path.cwd()
|
||||
self.auto_approve = auto_approve
|
||||
self.approval_callback = approval_callback
|
||||
self._proposals: Dict[str, EditProposal] = {}
|
||||
|
||||
def _validate_path(self, file_path: Path) -> Path:
|
||||
"""
|
||||
Validate and resolve file path.
|
||||
|
||||
Ensures path is within workspace and resolves to absolute path.
|
||||
"""
|
||||
if not file_path.is_absolute():
|
||||
file_path = self.workspace / file_path
|
||||
|
||||
resolved = file_path.resolve()
|
||||
|
||||
# Security check: ensure path is within workspace
|
||||
try:
|
||||
resolved.relative_to(self.workspace.resolve())
|
||||
except ValueError:
|
||||
raise ValueError(f"Path {file_path} is outside workspace {self.workspace}")
|
||||
|
||||
return resolved
|
||||
|
||||
def propose_edit(
|
||||
self,
|
||||
file_path: Path,
|
||||
new_content: str,
|
||||
description: str,
|
||||
) -> EditProposal:
|
||||
"""
|
||||
Create an edit proposal with diff preview.
|
||||
|
||||
Args:
|
||||
file_path: Path to file to edit
|
||||
new_content: Proposed new content
|
||||
description: Human-readable description of the change
|
||||
|
||||
Returns:
|
||||
EditProposal with diff
|
||||
"""
|
||||
resolved_path = self._validate_path(Path(file_path))
|
||||
|
||||
# Read original content if file exists
|
||||
original_content = ""
|
||||
if resolved_path.exists():
|
||||
try:
|
||||
original_content = resolved_path.read_text()
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not read {resolved_path}: {e}")
|
||||
|
||||
# Create proposal
|
||||
proposal = EditProposal(
|
||||
proposal_id=str(uuid.uuid4())[:8],
|
||||
file_path=resolved_path,
|
||||
original_content=original_content,
|
||||
proposed_content=new_content,
|
||||
description=description,
|
||||
)
|
||||
|
||||
self._proposals[proposal.proposal_id] = proposal
|
||||
logger.info(f"Created edit proposal {proposal.proposal_id} for {file_path}")
|
||||
|
||||
return proposal
|
||||
|
||||
def propose_create(
|
||||
self,
|
||||
file_path: Path,
|
||||
content: str,
|
||||
description: str,
|
||||
) -> EditProposal:
|
||||
"""
|
||||
Create a proposal for a new file.
|
||||
|
||||
Args:
|
||||
file_path: Path for new file
|
||||
content: File content
|
||||
description: Description of the new file
|
||||
|
||||
Returns:
|
||||
EditProposal for file creation
|
||||
"""
|
||||
return self.propose_edit(file_path, content, description)
|
||||
|
||||
def propose_delete(
|
||||
self,
|
||||
file_path: Path,
|
||||
description: str,
|
||||
) -> EditProposal:
|
||||
"""
|
||||
Create a proposal to delete a file.
|
||||
|
||||
Args:
|
||||
file_path: Path to file to delete
|
||||
description: Reason for deletion
|
||||
|
||||
Returns:
|
||||
EditProposal for file deletion
|
||||
"""
|
||||
return self.propose_edit(file_path, "", description)
|
||||
|
||||
def get_proposal(self, proposal_id: str) -> Optional[EditProposal]:
|
||||
"""Get a proposal by ID."""
|
||||
return self._proposals.get(proposal_id)
|
||||
|
||||
def list_proposals(self, status: Optional[EditStatus] = None) -> List[EditProposal]:
|
||||
"""
|
||||
List all proposals, optionally filtered by status.
|
||||
|
||||
Args:
|
||||
status: Optional status filter
|
||||
|
||||
Returns:
|
||||
List of proposals
|
||||
"""
|
||||
proposals = list(self._proposals.values())
|
||||
if status:
|
||||
proposals = [p for p in proposals if p.status == status]
|
||||
return proposals
|
||||
|
||||
def approve(self, proposal_id: str) -> bool:
|
||||
"""
|
||||
Approve an edit proposal.
|
||||
|
||||
Args:
|
||||
proposal_id: ID of proposal to approve
|
||||
|
||||
Returns:
|
||||
True if approved, False if not found or already processed
|
||||
"""
|
||||
proposal = self._proposals.get(proposal_id)
|
||||
if not proposal:
|
||||
logger.warning(f"Proposal {proposal_id} not found")
|
||||
return False
|
||||
|
||||
if proposal.status != EditStatus.PENDING:
|
||||
logger.warning(f"Proposal {proposal_id} is not pending (status: {proposal.status})")
|
||||
return False
|
||||
|
||||
proposal.status = EditStatus.APPROVED
|
||||
proposal.approved_at = datetime.now().isoformat()
|
||||
logger.info(f"Approved proposal {proposal_id}")
|
||||
return True
|
||||
|
||||
def reject(self, proposal_id: str, reason: Optional[str] = None) -> bool:
|
||||
"""
|
||||
Reject an edit proposal.
|
||||
|
||||
Args:
|
||||
proposal_id: ID of proposal to reject
|
||||
reason: Optional rejection reason
|
||||
|
||||
Returns:
|
||||
True if rejected, False if not found or already processed
|
||||
"""
|
||||
proposal = self._proposals.get(proposal_id)
|
||||
if not proposal:
|
||||
logger.warning(f"Proposal {proposal_id} not found")
|
||||
return False
|
||||
|
||||
if proposal.status != EditStatus.PENDING:
|
||||
logger.warning(f"Proposal {proposal_id} is not pending (status: {proposal.status})")
|
||||
return False
|
||||
|
||||
proposal.status = EditStatus.REJECTED
|
||||
proposal.error = reason
|
||||
logger.info(f"Rejected proposal {proposal_id}: {reason}")
|
||||
return True
|
||||
|
||||
def apply_edit(self, proposal_id: str) -> bool:
|
||||
"""
|
||||
Apply an approved edit.
|
||||
|
||||
Args:
|
||||
proposal_id: ID of approved proposal to apply
|
||||
|
||||
Returns:
|
||||
True if applied successfully, False otherwise
|
||||
"""
|
||||
proposal = self._proposals.get(proposal_id)
|
||||
if not proposal:
|
||||
logger.error(f"Proposal {proposal_id} not found")
|
||||
return False
|
||||
|
||||
if proposal.status != EditStatus.APPROVED:
|
||||
logger.error(f"Proposal {proposal_id} is not approved (status: {proposal.status})")
|
||||
return False
|
||||
|
||||
try:
|
||||
if proposal.is_deletion:
|
||||
# Delete file
|
||||
if proposal.file_path.exists():
|
||||
proposal.file_path.unlink()
|
||||
logger.info(f"Deleted {proposal.file_path}")
|
||||
else:
|
||||
# Create parent directories if needed
|
||||
proposal.file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Write new content
|
||||
proposal.file_path.write_text(proposal.proposed_content)
|
||||
logger.info(f"Applied edit to {proposal.file_path}")
|
||||
|
||||
proposal.status = EditStatus.APPLIED
|
||||
proposal.applied_at = datetime.now().isoformat()
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to apply proposal {proposal_id}: {e}")
|
||||
proposal.status = EditStatus.FAILED
|
||||
proposal.error = str(e)
|
||||
return False
|
||||
|
||||
def propose_and_apply(
|
||||
self,
|
||||
file_path: Path,
|
||||
new_content: str,
|
||||
description: str,
|
||||
) -> EditProposal:
|
||||
"""
|
||||
Convenience method: propose, auto-approve if enabled, and apply.
|
||||
|
||||
Only applies if auto_approve is True or approval_callback returns True.
|
||||
|
||||
Args:
|
||||
file_path: Path to file
|
||||
new_content: New content
|
||||
description: Description
|
||||
|
||||
Returns:
|
||||
EditProposal (may be pending, approved, or applied)
|
||||
"""
|
||||
proposal = self.propose_edit(file_path, new_content, description)
|
||||
|
||||
if self.auto_approve:
|
||||
self.approve(proposal.proposal_id)
|
||||
self.apply_edit(proposal.proposal_id)
|
||||
elif self.approval_callback:
|
||||
try:
|
||||
if self.approval_callback(proposal):
|
||||
self.approve(proposal.proposal_id)
|
||||
self.apply_edit(proposal.proposal_id)
|
||||
else:
|
||||
self.reject(proposal.proposal_id, "Rejected by callback")
|
||||
except Exception as e:
|
||||
logger.error(f"Approval callback failed: {e}")
|
||||
proposal.error = str(e)
|
||||
|
||||
return proposal
|
||||
|
||||
def format_diff_for_display(self, proposal: EditProposal) -> str:
|
||||
"""
|
||||
Format diff for terminal display with colors.
|
||||
|
||||
Args:
|
||||
proposal: Edit proposal
|
||||
|
||||
Returns:
|
||||
Formatted diff string with ANSI colors
|
||||
"""
|
||||
lines = []
|
||||
lines.append(f"📝 Edit Proposal: {proposal.proposal_id}")
|
||||
lines.append(f" File: {proposal.file_path}")
|
||||
lines.append(f" Description: {proposal.description}")
|
||||
lines.append(f" Status: {proposal.status.value}")
|
||||
lines.append("")
|
||||
|
||||
if proposal.is_new_file:
|
||||
lines.append(" [NEW FILE]")
|
||||
elif proposal.is_deletion:
|
||||
lines.append(" [DELETE FILE]")
|
||||
|
||||
lines.append(" --- Diff ---")
|
||||
for line in proposal.diff_lines:
|
||||
if line.startswith("+") and not line.startswith("+++"):
|
||||
lines.append(f" \033[32m{line}\033[0m") # Green for additions
|
||||
elif line.startswith("-") and not line.startswith("---"):
|
||||
lines.append(f" \033[31m{line}\033[0m") # Red for deletions
|
||||
elif line.startswith("@@"):
|
||||
lines.append(f" \033[36m{line}\033[0m") # Cyan for line numbers
|
||||
else:
|
||||
lines.append(f" {line}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def clear_proposals(self, status: Optional[EditStatus] = None) -> int:
|
||||
"""
|
||||
Clear proposals, optionally filtered by status.
|
||||
|
||||
Args:
|
||||
status: Optional status filter (None = clear all)
|
||||
|
||||
Returns:
|
||||
Number of proposals cleared
|
||||
"""
|
||||
if status is None:
|
||||
count = len(self._proposals)
|
||||
self._proposals.clear()
|
||||
else:
|
||||
to_remove = [pid for pid, p in self._proposals.items() if p.status == status]
|
||||
for pid in to_remove:
|
||||
del self._proposals[pid]
|
||||
count = len(to_remove)
|
||||
|
||||
logger.info(f"Cleared {count} proposals")
|
||||
return count
|
||||
|
||||
|
||||
# Global pipeline instance for ACP
|
||||
_safe_edit_pipeline: Optional[SafeEditPipeline] = None
|
||||
|
||||
|
||||
def get_safe_edit_pipeline(workspace: Optional[Path] = None) -> SafeEditPipeline:
|
||||
"""Get or create the global safe edit pipeline."""
|
||||
global _safe_edit_pipeline
|
||||
if _safe_edit_pipeline is None:
|
||||
_safe_edit_pipeline = SafeEditPipeline(workspace=workspace)
|
||||
return _safe_edit_pipeline
|
||||
@@ -160,7 +160,7 @@ def get_output_controller() -> OutputController:
|
||||
# Import and register command groups
|
||||
def register_commands():
|
||||
"""Register all command groups."""
|
||||
# Import command modules
|
||||
# Import command modules - Core commands
|
||||
from .commands.config import app as config_app
|
||||
from .commands.traces import app as traces_app
|
||||
from .commands.environment import app as env_app
|
||||
@@ -177,12 +177,41 @@ def register_commands():
|
||||
from .commands.schedule import app as schedule_app
|
||||
from .commands.run import app as run_app
|
||||
from .commands.profile import app as profile_app
|
||||
from .commands.benchmark import app as benchmark_app
|
||||
|
||||
# Import new command modules - Previously legacy-only commands
|
||||
from .commands.chat import app as chat_app
|
||||
from .commands.code import app as code_app
|
||||
from .commands.call import app as call_app
|
||||
from .commands.realtime import app as realtime_app
|
||||
from .commands.train import app as train_app
|
||||
from .commands.ui import app as ui_app
|
||||
from .commands.context import app as context_app
|
||||
from .commands.research import app as research_app
|
||||
from .commands.memory import app as memory_app
|
||||
from .commands.workflow import app as workflow_app
|
||||
from .commands.tools import app as tools_app
|
||||
from .commands.knowledge import app as knowledge_app
|
||||
from .commands.deploy import app as deploy_app
|
||||
from .commands.agents import app as agents_app
|
||||
from .commands.skills import app as skills_app
|
||||
from .commands.eval import app as eval_app
|
||||
from .commands.templates import app as templates_app
|
||||
from .commands.recipe import app as recipe_app
|
||||
from .commands.todo import app as todo_app
|
||||
from .commands.docs import app as docs_app
|
||||
from .commands.commit import app as commit_app
|
||||
from .commands.hooks import app as hooks_app
|
||||
from .commands.rules import app as rules_app
|
||||
from .commands.registry import app as registry_app
|
||||
from .commands.package import app as package_app
|
||||
from .commands.endpoints import app as endpoints_app
|
||||
|
||||
# Import TUI and queue commands
|
||||
from .features.tui.debug import create_debug_app as create_tui_debug_app
|
||||
from .features.tui.cli import create_queue_app
|
||||
|
||||
# Register sub-apps
|
||||
# Register sub-apps - Core commands
|
||||
app.add_typer(config_app, name="config", help="Configuration management")
|
||||
app.add_typer(traces_app, name="traces", help="Trace collection management")
|
||||
app.add_typer(env_app, name="env", help="Environment and diagnostics")
|
||||
@@ -199,6 +228,35 @@ def register_commands():
|
||||
app.add_typer(schedule_app, name="schedule", help="Scheduler management")
|
||||
app.add_typer(run_app, name="run", help="Run agents")
|
||||
app.add_typer(profile_app, name="profile", help="Performance profiling and diagnostics")
|
||||
app.add_typer(benchmark_app, name="benchmark", help="Comprehensive performance benchmarking")
|
||||
|
||||
# Register sub-apps - Previously legacy-only commands (now visible in --help)
|
||||
app.add_typer(chat_app, name="chat", help="Interactive chat mode")
|
||||
app.add_typer(code_app, name="code", help="Code assistant mode")
|
||||
app.add_typer(call_app, name="call", help="Voice/call interaction mode")
|
||||
app.add_typer(realtime_app, name="realtime", help="Realtime interaction mode")
|
||||
app.add_typer(train_app, name="train", help="Model training and fine-tuning")
|
||||
app.add_typer(ui_app, name="ui", help="Web UI management")
|
||||
app.add_typer(context_app, name="context", help="Context management")
|
||||
app.add_typer(research_app, name="research", help="Research and analysis")
|
||||
app.add_typer(memory_app, name="memory", help="Memory management")
|
||||
app.add_typer(workflow_app, name="workflow", help="Workflow management")
|
||||
app.add_typer(tools_app, name="tools", help="Tool management")
|
||||
app.add_typer(knowledge_app, name="knowledge", help="Knowledge base management")
|
||||
app.add_typer(deploy_app, name="deploy", help="Deployment management")
|
||||
app.add_typer(agents_app, name="agents", help="Agent management")
|
||||
app.add_typer(skills_app, name="skills", help="Skill management")
|
||||
app.add_typer(eval_app, name="eval", help="Evaluation and testing")
|
||||
app.add_typer(templates_app, name="templates", help="Template management")
|
||||
app.add_typer(recipe_app, name="recipe", help="Recipe management")
|
||||
app.add_typer(todo_app, name="todo", help="Todo/task management")
|
||||
app.add_typer(docs_app, name="docs", help="Documentation management")
|
||||
app.add_typer(commit_app, name="commit", help="AI-assisted git commits")
|
||||
app.add_typer(hooks_app, name="hooks", help="Hook management")
|
||||
app.add_typer(rules_app, name="rules", help="Rules management")
|
||||
app.add_typer(registry_app, name="registry", help="Registry management")
|
||||
app.add_typer(package_app, name="package", help="Package management")
|
||||
app.add_typer(endpoints_app, name="endpoints", help="API endpoint management")
|
||||
|
||||
# Register TUI and queue commands
|
||||
tui_app = create_tui_debug_app()
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
"""
|
||||
Agents command group for PraisonAI CLI.
|
||||
|
||||
Provides agent management commands.
|
||||
"""
|
||||
|
||||
import typer
|
||||
|
||||
app = typer.Typer(help="Agent management")
|
||||
|
||||
|
||||
@app.command("list")
|
||||
def agents_list(
|
||||
verbose: bool = typer.Option(False, "--verbose", "-v", help="Show detailed info"),
|
||||
):
|
||||
"""List available agents."""
|
||||
from praisonai.cli.main import PraisonAI
|
||||
import sys
|
||||
|
||||
argv = ['agents', 'list']
|
||||
if verbose:
|
||||
argv.append('--verbose')
|
||||
|
||||
original_argv = sys.argv
|
||||
sys.argv = ['praisonai'] + argv
|
||||
|
||||
try:
|
||||
praison = PraisonAI()
|
||||
praison.main()
|
||||
except SystemExit:
|
||||
pass
|
||||
finally:
|
||||
sys.argv = original_argv
|
||||
|
||||
|
||||
@app.command("create")
|
||||
def agents_create(
|
||||
name: str = typer.Argument(..., help="Agent name"),
|
||||
template: str = typer.Option(None, "--template", "-t", help="Template to use"),
|
||||
):
|
||||
"""Create a new agent."""
|
||||
from praisonai.cli.main import PraisonAI
|
||||
import sys
|
||||
|
||||
argv = ['agents', 'create', name]
|
||||
if template:
|
||||
argv.extend(['--template', template])
|
||||
|
||||
original_argv = sys.argv
|
||||
sys.argv = ['praisonai'] + argv
|
||||
|
||||
try:
|
||||
praison = PraisonAI()
|
||||
praison.main()
|
||||
except SystemExit:
|
||||
pass
|
||||
finally:
|
||||
sys.argv = original_argv
|
||||
|
||||
|
||||
@app.command("info")
|
||||
def agents_info(
|
||||
name: str = typer.Argument(..., help="Agent name"),
|
||||
):
|
||||
"""Show agent information."""
|
||||
from praisonai.cli.main import PraisonAI
|
||||
import sys
|
||||
|
||||
argv = ['agents', 'info', name]
|
||||
|
||||
original_argv = sys.argv
|
||||
sys.argv = ['praisonai'] + argv
|
||||
|
||||
try:
|
||||
praison = PraisonAI()
|
||||
praison.main()
|
||||
except SystemExit:
|
||||
pass
|
||||
finally:
|
||||
sys.argv = original_argv
|
||||
@@ -0,0 +1,286 @@
|
||||
"""
|
||||
Benchmark command group for PraisonAI CLI.
|
||||
|
||||
Provides comprehensive performance benchmarking across all execution paths.
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
import typer
|
||||
|
||||
app = typer.Typer(help="Comprehensive performance benchmarking")
|
||||
|
||||
|
||||
@app.command("profile")
|
||||
def benchmark_profile(
|
||||
prompt: str = typer.Argument("Hi", help="Prompt to benchmark"),
|
||||
iterations: int = typer.Option(3, "--iterations", "-n", help="Number of iterations per path"),
|
||||
output_format: str = typer.Option("text", "--format", "-f", help="Output format: text or json"),
|
||||
output_file: Optional[str] = typer.Option(None, "--output", "-o", help="Save results to file"),
|
||||
deep: bool = typer.Option(False, "--deep", help="Enable deep cProfile profiling (per-function timing, call graphs)"),
|
||||
limit: int = typer.Option(30, "--limit", "-l", help="Top N functions to show in deep profile"),
|
||||
):
|
||||
"""
|
||||
Run full benchmark suite across all execution paths.
|
||||
|
||||
Benchmarks:
|
||||
- OpenAI SDK (baseline)
|
||||
- PraisonAI Agent
|
||||
- PraisonAI CLI
|
||||
- PraisonAI CLI with profiling
|
||||
- PraisonAI Workflow (single agent)
|
||||
- PraisonAI Workflow (multi-agent)
|
||||
- PraisonAI via LiteLLM
|
||||
- LiteLLM standalone
|
||||
|
||||
With --deep flag, includes:
|
||||
- Per-function timing (cProfile stats)
|
||||
- Self time vs cumulative time
|
||||
- Call counts per function
|
||||
- Caller/callee relationships
|
||||
- Module breakdown by category
|
||||
- Call graph data
|
||||
|
||||
Examples:
|
||||
praisonai benchmark profile "What is 2+2?"
|
||||
praisonai benchmark profile "Hi" --iterations 5
|
||||
praisonai benchmark profile "Hi" --deep --limit 50
|
||||
praisonai benchmark profile "Hi" --deep --format json --output results.json
|
||||
"""
|
||||
try:
|
||||
from ..features.benchmark import BenchmarkHandler
|
||||
except ImportError as e:
|
||||
typer.echo(f"Error: Benchmark module not available: {e}", err=True)
|
||||
raise typer.Exit(1)
|
||||
|
||||
if deep:
|
||||
typer.echo("⚠️ Deep profiling enabled - this adds overhead to measurements", err=True)
|
||||
|
||||
handler = BenchmarkHandler()
|
||||
report = handler.run_full_benchmark(
|
||||
prompt=prompt,
|
||||
iterations=iterations,
|
||||
verbose=True,
|
||||
deep=deep,
|
||||
limit=limit
|
||||
)
|
||||
|
||||
if output_format == "json":
|
||||
import json
|
||||
output = json.dumps(report.to_dict(), indent=2)
|
||||
if output_file:
|
||||
with open(output_file, "w") as f:
|
||||
f.write(output)
|
||||
typer.echo(f"Results saved to {output_file}")
|
||||
else:
|
||||
typer.echo(output)
|
||||
else:
|
||||
handler.print_report(report, deep=deep, limit=limit)
|
||||
if output_file:
|
||||
import json
|
||||
with open(output_file, "w") as f:
|
||||
f.write(json.dumps(report.to_dict(), indent=2))
|
||||
typer.echo(f"\nJSON results saved to {output_file}")
|
||||
|
||||
|
||||
@app.command("compare")
|
||||
def benchmark_compare(
|
||||
prompt: str = typer.Argument("Hi", help="Prompt to benchmark"),
|
||||
iterations: int = typer.Option(2, "--iterations", "-n", help="Number of iterations"),
|
||||
):
|
||||
"""
|
||||
Quick comparison of key execution paths.
|
||||
|
||||
Compares OpenAI SDK, PraisonAI Agent, PraisonAI CLI, and LiteLLM.
|
||||
|
||||
Examples:
|
||||
praisonai benchmark compare "Hi"
|
||||
praisonai benchmark compare "What is 2+2?" --iterations 3
|
||||
"""
|
||||
try:
|
||||
from ..features.benchmark import BenchmarkHandler
|
||||
except ImportError as e:
|
||||
typer.echo(f"Error: Benchmark module not available: {e}", err=True)
|
||||
raise typer.Exit(1)
|
||||
|
||||
handler = BenchmarkHandler()
|
||||
report = handler.run_full_benchmark(
|
||||
prompt=prompt,
|
||||
iterations=iterations,
|
||||
paths=["openai_sdk", "praisonai_agent", "praisonai_cli", "litellm_standalone"],
|
||||
verbose=True
|
||||
)
|
||||
|
||||
typer.echo("\n" + handler.create_comparison_table(report))
|
||||
|
||||
|
||||
@app.command("sdk")
|
||||
def benchmark_sdk(
|
||||
prompt: str = typer.Argument("Hi", help="Prompt to benchmark"),
|
||||
iterations: int = typer.Option(3, "--iterations", "-n", help="Number of iterations"),
|
||||
output_format: str = typer.Option("text", "--format", "-f", help="Output format: text or json"),
|
||||
):
|
||||
"""
|
||||
Benchmark OpenAI SDK only (baseline).
|
||||
|
||||
Examples:
|
||||
praisonai benchmark sdk "Hi"
|
||||
praisonai benchmark sdk "What is 2+2?" --iterations 5
|
||||
"""
|
||||
try:
|
||||
from ..features.benchmark import BenchmarkHandler
|
||||
except ImportError as e:
|
||||
typer.echo(f"Error: Benchmark module not available: {e}", err=True)
|
||||
raise typer.Exit(1)
|
||||
|
||||
handler = BenchmarkHandler()
|
||||
report = handler.run_full_benchmark(prompt=prompt, iterations=iterations, paths=["openai_sdk"], verbose=True)
|
||||
|
||||
if output_format == "json":
|
||||
import json
|
||||
typer.echo(json.dumps(report.to_dict(), indent=2))
|
||||
else:
|
||||
handler.print_report(report)
|
||||
|
||||
|
||||
@app.command("agent")
|
||||
def benchmark_agent(
|
||||
prompt: str = typer.Argument("Hi", help="Prompt to benchmark"),
|
||||
iterations: int = typer.Option(3, "--iterations", "-n", help="Number of iterations"),
|
||||
output_format: str = typer.Option("text", "--format", "-f", help="Output format: text or json"),
|
||||
):
|
||||
"""
|
||||
Benchmark PraisonAI Agent vs SDK baseline.
|
||||
|
||||
Examples:
|
||||
praisonai benchmark agent "Hi"
|
||||
praisonai benchmark agent "What is 2+2?" --iterations 5
|
||||
"""
|
||||
try:
|
||||
from ..features.benchmark import BenchmarkHandler
|
||||
except ImportError as e:
|
||||
typer.echo(f"Error: Benchmark module not available: {e}", err=True)
|
||||
raise typer.Exit(1)
|
||||
|
||||
handler = BenchmarkHandler()
|
||||
report = handler.run_full_benchmark(
|
||||
prompt=prompt,
|
||||
iterations=iterations,
|
||||
paths=["openai_sdk", "praisonai_agent"],
|
||||
verbose=True
|
||||
)
|
||||
|
||||
if output_format == "json":
|
||||
import json
|
||||
typer.echo(json.dumps(report.to_dict(), indent=2))
|
||||
else:
|
||||
handler.print_report(report)
|
||||
|
||||
|
||||
@app.command("cli")
|
||||
def benchmark_cli(
|
||||
prompt: str = typer.Argument("Hi", help="Prompt to benchmark"),
|
||||
iterations: int = typer.Option(3, "--iterations", "-n", help="Number of iterations"),
|
||||
output_format: str = typer.Option("text", "--format", "-f", help="Output format: text or json"),
|
||||
):
|
||||
"""
|
||||
Benchmark PraisonAI CLI vs SDK baseline.
|
||||
|
||||
Examples:
|
||||
praisonai benchmark cli "Hi"
|
||||
praisonai benchmark cli "What is 2+2?" --iterations 5
|
||||
"""
|
||||
try:
|
||||
from ..features.benchmark import BenchmarkHandler
|
||||
except ImportError as e:
|
||||
typer.echo(f"Error: Benchmark module not available: {e}", err=True)
|
||||
raise typer.Exit(1)
|
||||
|
||||
handler = BenchmarkHandler()
|
||||
report = handler.run_full_benchmark(
|
||||
prompt=prompt,
|
||||
iterations=iterations,
|
||||
paths=["openai_sdk", "praisonai_cli", "praisonai_cli_profile"],
|
||||
verbose=True
|
||||
)
|
||||
|
||||
if output_format == "json":
|
||||
import json
|
||||
typer.echo(json.dumps(report.to_dict(), indent=2))
|
||||
else:
|
||||
handler.print_report(report)
|
||||
|
||||
|
||||
@app.command("workflow")
|
||||
def benchmark_workflow(
|
||||
prompt: str = typer.Argument("Hi", help="Prompt to benchmark"),
|
||||
iterations: int = typer.Option(3, "--iterations", "-n", help="Number of iterations"),
|
||||
output_format: str = typer.Option("text", "--format", "-f", help="Output format: text or json"),
|
||||
):
|
||||
"""
|
||||
Benchmark PraisonAI Workflow (single and multi-agent) vs SDK baseline.
|
||||
|
||||
Examples:
|
||||
praisonai benchmark workflow "Hi"
|
||||
praisonai benchmark workflow "What is 2+2?" --iterations 5
|
||||
"""
|
||||
try:
|
||||
from ..features.benchmark import BenchmarkHandler
|
||||
except ImportError as e:
|
||||
typer.echo(f"Error: Benchmark module not available: {e}", err=True)
|
||||
raise typer.Exit(1)
|
||||
|
||||
handler = BenchmarkHandler()
|
||||
report = handler.run_full_benchmark(
|
||||
prompt=prompt,
|
||||
iterations=iterations,
|
||||
paths=["openai_sdk", "praisonai_workflow_single", "praisonai_workflow_multi"],
|
||||
verbose=True
|
||||
)
|
||||
|
||||
if output_format == "json":
|
||||
import json
|
||||
typer.echo(json.dumps(report.to_dict(), indent=2))
|
||||
else:
|
||||
handler.print_report(report)
|
||||
|
||||
|
||||
@app.command("litellm")
|
||||
def benchmark_litellm(
|
||||
prompt: str = typer.Argument("Hi", help="Prompt to benchmark"),
|
||||
iterations: int = typer.Option(3, "--iterations", "-n", help="Number of iterations"),
|
||||
output_format: str = typer.Option("text", "--format", "-f", help="Output format: text or json"),
|
||||
):
|
||||
"""
|
||||
Benchmark LiteLLM paths vs SDK baseline.
|
||||
|
||||
Examples:
|
||||
praisonai benchmark litellm "Hi"
|
||||
praisonai benchmark litellm "What is 2+2?" --iterations 5
|
||||
"""
|
||||
try:
|
||||
from ..features.benchmark import BenchmarkHandler
|
||||
except ImportError as e:
|
||||
typer.echo(f"Error: Benchmark module not available: {e}", err=True)
|
||||
raise typer.Exit(1)
|
||||
|
||||
handler = BenchmarkHandler()
|
||||
report = handler.run_full_benchmark(
|
||||
prompt=prompt,
|
||||
iterations=iterations,
|
||||
paths=["openai_sdk", "praisonai_litellm", "litellm_standalone"],
|
||||
verbose=True
|
||||
)
|
||||
|
||||
if output_format == "json":
|
||||
import json
|
||||
typer.echo(json.dumps(report.to_dict(), indent=2))
|
||||
else:
|
||||
handler.print_report(report)
|
||||
|
||||
|
||||
@app.callback(invoke_without_command=True)
|
||||
def benchmark_callback(ctx: typer.Context):
|
||||
"""Show benchmark help if no subcommand."""
|
||||
if ctx.invoked_subcommand is None:
|
||||
typer.echo(ctx.get_help())
|
||||
@@ -0,0 +1,45 @@
|
||||
"""
|
||||
Call command group for PraisonAI CLI.
|
||||
|
||||
Provides voice/call interaction commands.
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
import typer
|
||||
|
||||
app = typer.Typer(help="Voice/call interaction mode")
|
||||
|
||||
|
||||
@app.callback(invoke_without_command=True)
|
||||
def call_main(
|
||||
ctx: typer.Context,
|
||||
model: Optional[str] = typer.Option(None, "--model", "-m", help="LLM model to use"),
|
||||
verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
|
||||
):
|
||||
"""
|
||||
Start voice/call interaction mode.
|
||||
|
||||
Examples:
|
||||
praisonai call
|
||||
praisonai call --model gpt-4o
|
||||
"""
|
||||
from praisonai.cli.main import PraisonAI
|
||||
import sys
|
||||
|
||||
argv = ['call']
|
||||
if model:
|
||||
argv.extend(['--model', model])
|
||||
if verbose:
|
||||
argv.append('--verbose')
|
||||
|
||||
original_argv = sys.argv
|
||||
sys.argv = ['praisonai'] + argv
|
||||
|
||||
try:
|
||||
praison = PraisonAI()
|
||||
praison.main()
|
||||
except SystemExit:
|
||||
pass
|
||||
finally:
|
||||
sys.argv = original_argv
|
||||
@@ -0,0 +1,60 @@
|
||||
"""
|
||||
Chat command group for PraisonAI CLI.
|
||||
|
||||
Provides interactive chat commands.
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
import typer
|
||||
|
||||
app = typer.Typer(help="Interactive chat mode")
|
||||
|
||||
|
||||
@app.callback(invoke_without_command=True)
|
||||
def chat_main(
|
||||
ctx: typer.Context,
|
||||
prompt: Optional[str] = typer.Argument(None, help="Initial prompt for chat"),
|
||||
model: Optional[str] = typer.Option(None, "--model", "-m", help="LLM model to use"),
|
||||
verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
|
||||
memory: bool = typer.Option(False, "--memory", help="Enable memory"),
|
||||
tools: Optional[str] = typer.Option(None, "--tools", "-t", help="Tools file path"),
|
||||
user_id: Optional[str] = typer.Option(None, "--user-id", help="User ID for memory isolation"),
|
||||
session_id: Optional[str] = typer.Option(None, "--session", "-s", help="Session ID to resume"),
|
||||
):
|
||||
"""
|
||||
Start interactive chat mode.
|
||||
|
||||
Examples:
|
||||
praisonai chat
|
||||
praisonai chat "Hello, how are you?"
|
||||
praisonai chat --model gpt-4o --memory
|
||||
"""
|
||||
from praisonai.cli.main import PraisonAI
|
||||
import sys
|
||||
|
||||
# Build args for legacy handler
|
||||
argv = ['chat']
|
||||
if prompt:
|
||||
argv.append(prompt)
|
||||
if model:
|
||||
argv.extend(['--model', model])
|
||||
if verbose:
|
||||
argv.append('--verbose')
|
||||
if memory:
|
||||
argv.append('--memory')
|
||||
if tools:
|
||||
argv.extend(['--tools', tools])
|
||||
if user_id:
|
||||
argv.extend(['--user-id', user_id])
|
||||
|
||||
original_argv = sys.argv
|
||||
sys.argv = ['praisonai'] + argv
|
||||
|
||||
try:
|
||||
praison = PraisonAI()
|
||||
praison.main()
|
||||
except SystemExit:
|
||||
pass
|
||||
finally:
|
||||
sys.argv = original_argv
|
||||
@@ -0,0 +1,53 @@
|
||||
"""
|
||||
Code command group for PraisonAI CLI.
|
||||
|
||||
Provides code assistant commands.
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
import typer
|
||||
|
||||
app = typer.Typer(help="Code assistant mode")
|
||||
|
||||
|
||||
@app.callback(invoke_without_command=True)
|
||||
def code_main(
|
||||
ctx: typer.Context,
|
||||
prompt: Optional[str] = typer.Argument(None, help="Code task or question"),
|
||||
model: Optional[str] = typer.Option(None, "--model", "-m", help="LLM model to use"),
|
||||
verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
|
||||
tools: Optional[str] = typer.Option(None, "--tools", "-t", help="Tools file path"),
|
||||
workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Workspace directory"),
|
||||
):
|
||||
"""
|
||||
Start code assistant mode.
|
||||
|
||||
Examples:
|
||||
praisonai code
|
||||
praisonai code "Refactor this function"
|
||||
praisonai code --model gpt-4o --workspace ./src
|
||||
"""
|
||||
from praisonai.cli.main import PraisonAI
|
||||
import sys
|
||||
|
||||
argv = ['code']
|
||||
if prompt:
|
||||
argv.append(prompt)
|
||||
if model:
|
||||
argv.extend(['--model', model])
|
||||
if verbose:
|
||||
argv.append('--verbose')
|
||||
if tools:
|
||||
argv.extend(['--tools', tools])
|
||||
|
||||
original_argv = sys.argv
|
||||
sys.argv = ['praisonai'] + argv
|
||||
|
||||
try:
|
||||
praison = PraisonAI()
|
||||
praison.main()
|
||||
except SystemExit:
|
||||
pass
|
||||
finally:
|
||||
sys.argv = original_argv
|
||||
@@ -0,0 +1,47 @@
|
||||
"""
|
||||
Commit command group for PraisonAI CLI.
|
||||
|
||||
Provides git commit commands with AI assistance.
|
||||
"""
|
||||
|
||||
import typer
|
||||
|
||||
app = typer.Typer(help="AI-assisted git commits")
|
||||
|
||||
|
||||
@app.callback(invoke_without_command=True)
|
||||
def commit_main(
|
||||
ctx: typer.Context,
|
||||
message: str = typer.Option(None, "--message", "-m", help="Commit message (auto-generated if not provided)"),
|
||||
all_files: bool = typer.Option(False, "--all", "-a", help="Stage all changes"),
|
||||
push: bool = typer.Option(False, "--push", "-p", help="Push after commit"),
|
||||
):
|
||||
"""
|
||||
Create AI-assisted git commits.
|
||||
|
||||
Examples:
|
||||
praisonai commit
|
||||
praisonai commit -m "Fix bug"
|
||||
praisonai commit --all --push
|
||||
"""
|
||||
from praisonai.cli.main import PraisonAI
|
||||
import sys
|
||||
|
||||
argv = ['commit']
|
||||
if message:
|
||||
argv.extend(['--message', message])
|
||||
if all_files:
|
||||
argv.append('--all')
|
||||
if push:
|
||||
argv.append('--push')
|
||||
|
||||
original_argv = sys.argv
|
||||
sys.argv = ['praisonai'] + argv
|
||||
|
||||
try:
|
||||
praison = PraisonAI()
|
||||
praison.main()
|
||||
except SystemExit:
|
||||
pass
|
||||
finally:
|
||||
sys.argv = original_argv
|
||||
@@ -0,0 +1,77 @@
|
||||
"""
|
||||
Context command group for PraisonAI CLI.
|
||||
|
||||
Provides context management commands.
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
import typer
|
||||
|
||||
app = typer.Typer(help="Context management")
|
||||
|
||||
|
||||
@app.command("show")
|
||||
def context_show(
|
||||
verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
|
||||
):
|
||||
"""Show current context."""
|
||||
from praisonai.cli.main import PraisonAI
|
||||
import sys
|
||||
|
||||
argv = ['context', 'show']
|
||||
if verbose:
|
||||
argv.append('--verbose')
|
||||
|
||||
original_argv = sys.argv
|
||||
sys.argv = ['praisonai'] + argv
|
||||
|
||||
try:
|
||||
praison = PraisonAI()
|
||||
praison.main()
|
||||
except SystemExit:
|
||||
pass
|
||||
finally:
|
||||
sys.argv = original_argv
|
||||
|
||||
|
||||
@app.command("clear")
|
||||
def context_clear():
|
||||
"""Clear current context."""
|
||||
from praisonai.cli.main import PraisonAI
|
||||
import sys
|
||||
|
||||
argv = ['context', 'clear']
|
||||
|
||||
original_argv = sys.argv
|
||||
sys.argv = ['praisonai'] + argv
|
||||
|
||||
try:
|
||||
praison = PraisonAI()
|
||||
praison.main()
|
||||
except SystemExit:
|
||||
pass
|
||||
finally:
|
||||
sys.argv = original_argv
|
||||
|
||||
|
||||
@app.command("add")
|
||||
def context_add(
|
||||
content: str = typer.Argument(..., help="Content to add to context"),
|
||||
):
|
||||
"""Add content to context."""
|
||||
from praisonai.cli.main import PraisonAI
|
||||
import sys
|
||||
|
||||
argv = ['context', 'add', content]
|
||||
|
||||
original_argv = sys.argv
|
||||
sys.argv = ['praisonai'] + argv
|
||||
|
||||
try:
|
||||
praison = PraisonAI()
|
||||
praison.main()
|
||||
except SystemExit:
|
||||
pass
|
||||
finally:
|
||||
sys.argv = original_argv
|
||||
@@ -0,0 +1,109 @@
|
||||
"""
|
||||
Deploy command group for PraisonAI CLI.
|
||||
|
||||
Provides deployment commands.
|
||||
"""
|
||||
|
||||
import typer
|
||||
|
||||
app = typer.Typer(help="Deployment management")
|
||||
|
||||
|
||||
@app.command("docker")
|
||||
def deploy_docker(
|
||||
file: str = typer.Argument("agents.yaml", help="Agent file to deploy"),
|
||||
tag: str = typer.Option(None, "--tag", "-t", help="Docker image tag"),
|
||||
):
|
||||
"""Deploy as Docker container."""
|
||||
from praisonai.cli.main import PraisonAI
|
||||
import sys
|
||||
|
||||
argv = ['deploy', 'docker', file]
|
||||
if tag:
|
||||
argv.extend(['--tag', tag])
|
||||
|
||||
original_argv = sys.argv
|
||||
sys.argv = ['praisonai'] + argv
|
||||
|
||||
try:
|
||||
praison = PraisonAI()
|
||||
praison.main()
|
||||
except SystemExit:
|
||||
pass
|
||||
finally:
|
||||
sys.argv = original_argv
|
||||
|
||||
|
||||
@app.command("aws")
|
||||
def deploy_aws(
|
||||
file: str = typer.Argument("agents.yaml", help="Agent file to deploy"),
|
||||
region: str = typer.Option(None, "--region", "-r", help="AWS region"),
|
||||
):
|
||||
"""Deploy to AWS."""
|
||||
from praisonai.cli.main import PraisonAI
|
||||
import sys
|
||||
|
||||
argv = ['deploy', 'aws', file]
|
||||
if region:
|
||||
argv.extend(['--region', region])
|
||||
|
||||
original_argv = sys.argv
|
||||
sys.argv = ['praisonai'] + argv
|
||||
|
||||
try:
|
||||
praison = PraisonAI()
|
||||
praison.main()
|
||||
except SystemExit:
|
||||
pass
|
||||
finally:
|
||||
sys.argv = original_argv
|
||||
|
||||
|
||||
@app.command("gcp")
|
||||
def deploy_gcp(
|
||||
file: str = typer.Argument("agents.yaml", help="Agent file to deploy"),
|
||||
project: str = typer.Option(None, "--project", "-p", help="GCP project"),
|
||||
):
|
||||
"""Deploy to Google Cloud."""
|
||||
from praisonai.cli.main import PraisonAI
|
||||
import sys
|
||||
|
||||
argv = ['deploy', 'gcp', file]
|
||||
if project:
|
||||
argv.extend(['--project', project])
|
||||
|
||||
original_argv = sys.argv
|
||||
sys.argv = ['praisonai'] + argv
|
||||
|
||||
try:
|
||||
praison = PraisonAI()
|
||||
praison.main()
|
||||
except SystemExit:
|
||||
pass
|
||||
finally:
|
||||
sys.argv = original_argv
|
||||
|
||||
|
||||
@app.command("azure")
|
||||
def deploy_azure(
|
||||
file: str = typer.Argument("agents.yaml", help="Agent file to deploy"),
|
||||
resource_group: str = typer.Option(None, "--resource-group", "-g", help="Azure resource group"),
|
||||
):
|
||||
"""Deploy to Azure."""
|
||||
from praisonai.cli.main import PraisonAI
|
||||
import sys
|
||||
|
||||
argv = ['deploy', 'azure', file]
|
||||
if resource_group:
|
||||
argv.extend(['--resource-group', resource_group])
|
||||
|
||||
original_argv = sys.argv
|
||||
sys.argv = ['praisonai'] + argv
|
||||
|
||||
try:
|
||||
praison = PraisonAI()
|
||||
praison.main()
|
||||
except SystemExit:
|
||||
pass
|
||||
finally:
|
||||
sys.argv = original_argv
|
||||
@@ -0,0 +1,54 @@
|
||||
"""
|
||||
Docs command group for PraisonAI CLI.
|
||||
|
||||
Provides documentation commands.
|
||||
"""
|
||||
|
||||
import typer
|
||||
|
||||
app = typer.Typer(help="Documentation management")
|
||||
|
||||
|
||||
@app.command("generate")
|
||||
def docs_generate(
|
||||
source: str = typer.Argument(".", help="Source directory"),
|
||||
output: str = typer.Option("docs", "--output", "-o", help="Output directory"),
|
||||
):
|
||||
"""Generate documentation."""
|
||||
from praisonai.cli.main import PraisonAI
|
||||
import sys
|
||||
|
||||
argv = ['docs', 'generate', source, '--output', output]
|
||||
|
||||
original_argv = sys.argv
|
||||
sys.argv = ['praisonai'] + argv
|
||||
|
||||
try:
|
||||
praison = PraisonAI()
|
||||
praison.main()
|
||||
except SystemExit:
|
||||
pass
|
||||
finally:
|
||||
sys.argv = original_argv
|
||||
|
||||
|
||||
@app.command("serve")
|
||||
def docs_serve(
|
||||
port: int = typer.Option(8000, "--port", "-p", help="Port to serve on"),
|
||||
):
|
||||
"""Serve documentation locally."""
|
||||
from praisonai.cli.main import PraisonAI
|
||||
import sys
|
||||
|
||||
argv = ['docs', 'serve', '--port', str(port)]
|
||||
|
||||
original_argv = sys.argv
|
||||
sys.argv = ['praisonai'] + argv
|
||||
|
||||
try:
|
||||
praison = PraisonAI()
|
||||
praison.main()
|
||||
except SystemExit:
|
||||
pass
|
||||
finally:
|
||||
sys.argv = original_argv
|
||||
@@ -0,0 +1,51 @@
|
||||
"""
|
||||
Endpoints command group for PraisonAI CLI.
|
||||
|
||||
Provides API endpoint management commands.
|
||||
"""
|
||||
|
||||
import typer
|
||||
|
||||
app = typer.Typer(help="API endpoint management")
|
||||
|
||||
|
||||
@app.command("list")
|
||||
def endpoints_list():
|
||||
"""List available endpoints."""
|
||||
from praisonai.cli.main import PraisonAI
|
||||
import sys
|
||||
|
||||
argv = ['endpoints', 'list']
|
||||
|
||||
original_argv = sys.argv
|
||||
sys.argv = ['praisonai'] + argv
|
||||
|
||||
try:
|
||||
praison = PraisonAI()
|
||||
praison.main()
|
||||
except SystemExit:
|
||||
pass
|
||||
finally:
|
||||
sys.argv = original_argv
|
||||
|
||||
|
||||
@app.command("test")
|
||||
def endpoints_test(
|
||||
endpoint: str = typer.Argument(..., help="Endpoint to test"),
|
||||
):
|
||||
"""Test an endpoint."""
|
||||
from praisonai.cli.main import PraisonAI
|
||||
import sys
|
||||
|
||||
argv = ['endpoints', 'test', endpoint]
|
||||
|
||||
original_argv = sys.argv
|
||||
sys.argv = ['praisonai'] + argv
|
||||
|
||||
try:
|
||||
praison = PraisonAI()
|
||||
praison.main()
|
||||
except SystemExit:
|
||||
pass
|
||||
finally:
|
||||
sys.argv = original_argv
|
||||
@@ -0,0 +1,57 @@
|
||||
"""
|
||||
Eval command group for PraisonAI CLI.
|
||||
|
||||
Provides evaluation commands.
|
||||
"""
|
||||
|
||||
import typer
|
||||
|
||||
app = typer.Typer(help="Evaluation and testing")
|
||||
|
||||
|
||||
@app.command("accuracy")
|
||||
def eval_accuracy(
|
||||
agent: str = typer.Argument(..., help="Agent file or name"),
|
||||
input_text: str = typer.Option(..., "--input", "-i", help="Input text"),
|
||||
expected: str = typer.Option(..., "--expected", "-e", help="Expected output"),
|
||||
iterations: int = typer.Option(3, "--iterations", "-n", help="Number of iterations"),
|
||||
):
|
||||
"""Run accuracy evaluation."""
|
||||
from praisonai.cli.main import PraisonAI
|
||||
import sys
|
||||
|
||||
argv = ['eval', 'accuracy', agent, '--input', input_text, '--expected', expected]
|
||||
|
||||
original_argv = sys.argv
|
||||
sys.argv = ['praisonai'] + argv
|
||||
|
||||
try:
|
||||
praison = PraisonAI()
|
||||
praison.main()
|
||||
except SystemExit:
|
||||
pass
|
||||
finally:
|
||||
sys.argv = original_argv
|
||||
|
||||
|
||||
@app.command("performance")
|
||||
def eval_performance(
|
||||
agent: str = typer.Argument(..., help="Agent file or name"),
|
||||
iterations: int = typer.Option(50, "--iterations", "-n", help="Number of iterations"),
|
||||
):
|
||||
"""Run performance evaluation."""
|
||||
from praisonai.cli.main import PraisonAI
|
||||
import sys
|
||||
|
||||
argv = ['eval', 'performance', agent, '--iterations', str(iterations)]
|
||||
|
||||
original_argv = sys.argv
|
||||
sys.argv = ['praisonai'] + argv
|
||||
|
||||
try:
|
||||
praison = PraisonAI()
|
||||
praison.main()
|
||||
except SystemExit:
|
||||
pass
|
||||
finally:
|
||||
sys.argv = original_argv
|
||||
@@ -0,0 +1,74 @@
|
||||
"""
|
||||
Hooks command group for PraisonAI CLI.
|
||||
|
||||
Provides hook management commands.
|
||||
"""
|
||||
|
||||
import typer
|
||||
|
||||
app = typer.Typer(help="Hook management")
|
||||
|
||||
|
||||
@app.command("list")
|
||||
def hooks_list():
|
||||
"""List available hooks."""
|
||||
from praisonai.cli.main import PraisonAI
|
||||
import sys
|
||||
|
||||
argv = ['hooks', 'list']
|
||||
|
||||
original_argv = sys.argv
|
||||
sys.argv = ['praisonai'] + argv
|
||||
|
||||
try:
|
||||
praison = PraisonAI()
|
||||
praison.main()
|
||||
except SystemExit:
|
||||
pass
|
||||
finally:
|
||||
sys.argv = original_argv
|
||||
|
||||
|
||||
@app.command("add")
|
||||
def hooks_add(
|
||||
name: str = typer.Argument(..., help="Hook name"),
|
||||
event: str = typer.Option(..., "--event", "-e", help="Event to hook into"),
|
||||
):
|
||||
"""Add a hook."""
|
||||
from praisonai.cli.main import PraisonAI
|
||||
import sys
|
||||
|
||||
argv = ['hooks', 'add', name, '--event', event]
|
||||
|
||||
original_argv = sys.argv
|
||||
sys.argv = ['praisonai'] + argv
|
||||
|
||||
try:
|
||||
praison = PraisonAI()
|
||||
praison.main()
|
||||
except SystemExit:
|
||||
pass
|
||||
finally:
|
||||
sys.argv = original_argv
|
||||
|
||||
|
||||
@app.command("remove")
|
||||
def hooks_remove(
|
||||
name: str = typer.Argument(..., help="Hook name to remove"),
|
||||
):
|
||||
"""Remove a hook."""
|
||||
from praisonai.cli.main import PraisonAI
|
||||
import sys
|
||||
|
||||
argv = ['hooks', 'remove', name]
|
||||
|
||||
original_argv = sys.argv
|
||||
sys.argv = ['praisonai'] + argv
|
||||
|
||||
try:
|
||||
praison = PraisonAI()
|
||||
praison.main()
|
||||
except SystemExit:
|
||||
pass
|
||||
finally:
|
||||
sys.argv = original_argv
|
||||
@@ -0,0 +1,76 @@
|
||||
"""
|
||||
Knowledge command group for PraisonAI CLI.
|
||||
|
||||
Provides knowledge base management commands.
|
||||
"""
|
||||
|
||||
import typer
|
||||
|
||||
app = typer.Typer(help="Knowledge base management")
|
||||
|
||||
|
||||
@app.command("add")
|
||||
def knowledge_add(
|
||||
source: str = typer.Argument(..., help="Source file or URL"),
|
||||
name: str = typer.Option(None, "--name", "-n", help="Knowledge base name"),
|
||||
):
|
||||
"""Add knowledge from a source."""
|
||||
from praisonai.cli.main import PraisonAI
|
||||
import sys
|
||||
|
||||
argv = ['knowledge', 'add', source]
|
||||
if name:
|
||||
argv.extend(['--name', name])
|
||||
|
||||
original_argv = sys.argv
|
||||
sys.argv = ['praisonai'] + argv
|
||||
|
||||
try:
|
||||
praison = PraisonAI()
|
||||
praison.main()
|
||||
except SystemExit:
|
||||
pass
|
||||
finally:
|
||||
sys.argv = original_argv
|
||||
|
||||
|
||||
@app.command("list")
|
||||
def knowledge_list():
|
||||
"""List knowledge bases."""
|
||||
from praisonai.cli.main import PraisonAI
|
||||
import sys
|
||||
|
||||
argv = ['knowledge', 'list']
|
||||
|
||||
original_argv = sys.argv
|
||||
sys.argv = ['praisonai'] + argv
|
||||
|
||||
try:
|
||||
praison = PraisonAI()
|
||||
praison.main()
|
||||
except SystemExit:
|
||||
pass
|
||||
finally:
|
||||
sys.argv = original_argv
|
||||
|
||||
|
||||
@app.command("search")
|
||||
def knowledge_search(
|
||||
query: str = typer.Argument(..., help="Search query"),
|
||||
):
|
||||
"""Search knowledge base."""
|
||||
from praisonai.cli.main import PraisonAI
|
||||
import sys
|
||||
|
||||
argv = ['knowledge', 'search', query]
|
||||
|
||||
original_argv = sys.argv
|
||||
sys.argv = ['praisonai'] + argv
|
||||
|
||||
try:
|
||||
praison = PraisonAI()
|
||||
praison.main()
|
||||
except SystemExit:
|
||||
pass
|
||||
finally:
|
||||
sys.argv = original_argv
|
||||
@@ -0,0 +1,109 @@
|
||||
"""
|
||||
Memory command group for PraisonAI CLI.
|
||||
|
||||
Provides memory management commands.
|
||||
"""
|
||||
|
||||
import typer
|
||||
|
||||
app = typer.Typer(help="Memory management")
|
||||
|
||||
|
||||
@app.command("show")
|
||||
def memory_show(
|
||||
user_id: str = typer.Option(None, "--user-id", help="User ID for memory isolation"),
|
||||
limit: int = typer.Option(10, "--limit", "-l", help="Number of memories to show"),
|
||||
):
|
||||
"""Show stored memories."""
|
||||
from praisonai.cli.main import PraisonAI
|
||||
import sys
|
||||
|
||||
argv = ['memory', 'show']
|
||||
if user_id:
|
||||
argv.extend(['--user-id', user_id])
|
||||
|
||||
original_argv = sys.argv
|
||||
sys.argv = ['praisonai'] + argv
|
||||
|
||||
try:
|
||||
praison = PraisonAI()
|
||||
praison.main()
|
||||
except SystemExit:
|
||||
pass
|
||||
finally:
|
||||
sys.argv = original_argv
|
||||
|
||||
|
||||
@app.command("add")
|
||||
def memory_add(
|
||||
content: str = typer.Argument(..., help="Memory content to add"),
|
||||
user_id: str = typer.Option(None, "--user-id", help="User ID for memory isolation"),
|
||||
):
|
||||
"""Add a memory."""
|
||||
from praisonai.cli.main import PraisonAI
|
||||
import sys
|
||||
|
||||
argv = ['memory', 'add', content]
|
||||
if user_id:
|
||||
argv.extend(['--user-id', user_id])
|
||||
|
||||
original_argv = sys.argv
|
||||
sys.argv = ['praisonai'] + argv
|
||||
|
||||
try:
|
||||
praison = PraisonAI()
|
||||
praison.main()
|
||||
except SystemExit:
|
||||
pass
|
||||
finally:
|
||||
sys.argv = original_argv
|
||||
|
||||
|
||||
@app.command("search")
|
||||
def memory_search(
|
||||
query: str = typer.Argument(..., help="Search query"),
|
||||
user_id: str = typer.Option(None, "--user-id", help="User ID for memory isolation"),
|
||||
):
|
||||
"""Search memories."""
|
||||
from praisonai.cli.main import PraisonAI
|
||||
import sys
|
||||
|
||||
argv = ['memory', 'search', query]
|
||||
if user_id:
|
||||
argv.extend(['--user-id', user_id])
|
||||
|
||||
original_argv = sys.argv
|
||||
sys.argv = ['praisonai'] + argv
|
||||
|
||||
try:
|
||||
praison = PraisonAI()
|
||||
praison.main()
|
||||
except SystemExit:
|
||||
pass
|
||||
finally:
|
||||
sys.argv = original_argv
|
||||
|
||||
|
||||
@app.command("clear")
|
||||
def memory_clear(
|
||||
user_id: str = typer.Option(None, "--user-id", help="User ID for memory isolation"),
|
||||
force: bool = typer.Option(False, "--force", "-f", help="Force clear without confirmation"),
|
||||
):
|
||||
"""Clear all memories."""
|
||||
from praisonai.cli.main import PraisonAI
|
||||
import sys
|
||||
|
||||
argv = ['memory', 'clear']
|
||||
if user_id:
|
||||
argv.extend(['--user-id', user_id])
|
||||
|
||||
original_argv = sys.argv
|
||||
sys.argv = ['praisonai'] + argv
|
||||
|
||||
try:
|
||||
praison = PraisonAI()
|
||||
praison.main()
|
||||
except SystemExit:
|
||||
pass
|
||||
finally:
|
||||
sys.argv = original_argv
|
||||
@@ -0,0 +1,76 @@
|
||||
"""
|
||||
Package command group for PraisonAI CLI.
|
||||
|
||||
Provides package management commands (pip-like).
|
||||
"""
|
||||
|
||||
import typer
|
||||
|
||||
app = typer.Typer(help="Package management")
|
||||
|
||||
|
||||
@app.command("install")
|
||||
def package_install(
|
||||
package: str = typer.Argument(..., help="Package to install"),
|
||||
upgrade: bool = typer.Option(False, "--upgrade", "-U", help="Upgrade if already installed"),
|
||||
):
|
||||
"""Install a package."""
|
||||
from praisonai.cli.main import PraisonAI
|
||||
import sys
|
||||
|
||||
argv = ['package', 'install', package]
|
||||
if upgrade:
|
||||
argv.append('--upgrade')
|
||||
|
||||
original_argv = sys.argv
|
||||
sys.argv = ['praisonai'] + argv
|
||||
|
||||
try:
|
||||
praison = PraisonAI()
|
||||
praison.main()
|
||||
except SystemExit:
|
||||
pass
|
||||
finally:
|
||||
sys.argv = original_argv
|
||||
|
||||
|
||||
@app.command("uninstall")
|
||||
def package_uninstall(
|
||||
package: str = typer.Argument(..., help="Package to uninstall"),
|
||||
):
|
||||
"""Uninstall a package."""
|
||||
from praisonai.cli.main import PraisonAI
|
||||
import sys
|
||||
|
||||
argv = ['package', 'uninstall', package]
|
||||
|
||||
original_argv = sys.argv
|
||||
sys.argv = ['praisonai'] + argv
|
||||
|
||||
try:
|
||||
praison = PraisonAI()
|
||||
praison.main()
|
||||
except SystemExit:
|
||||
pass
|
||||
finally:
|
||||
sys.argv = original_argv
|
||||
|
||||
|
||||
@app.command("list")
|
||||
def package_list():
|
||||
"""List installed packages."""
|
||||
from praisonai.cli.main import PraisonAI
|
||||
import sys
|
||||
|
||||
argv = ['package', 'list']
|
||||
|
||||
original_argv = sys.argv
|
||||
sys.argv = ['praisonai'] + argv
|
||||
|
||||
try:
|
||||
praison = PraisonAI()
|
||||
praison.main()
|
||||
except SystemExit:
|
||||
pass
|
||||
finally:
|
||||
sys.argv = original_argv
|
||||
@@ -0,0 +1,45 @@
|
||||
"""
|
||||
Realtime command group for PraisonAI CLI.
|
||||
|
||||
Provides realtime interaction commands.
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
import typer
|
||||
|
||||
app = typer.Typer(help="Realtime interaction mode")
|
||||
|
||||
|
||||
@app.callback(invoke_without_command=True)
|
||||
def realtime_main(
|
||||
ctx: typer.Context,
|
||||
model: Optional[str] = typer.Option(None, "--model", "-m", help="LLM model to use"),
|
||||
verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
|
||||
):
|
||||
"""
|
||||
Start realtime interaction mode.
|
||||
|
||||
Examples:
|
||||
praisonai realtime
|
||||
praisonai realtime --model gpt-4o
|
||||
"""
|
||||
from praisonai.cli.main import PraisonAI
|
||||
import sys
|
||||
|
||||
argv = ['realtime']
|
||||
if model:
|
||||
argv.extend(['--model', model])
|
||||
if verbose:
|
||||
argv.append('--verbose')
|
||||
|
||||
original_argv = sys.argv
|
||||
sys.argv = ['praisonai'] + argv
|
||||
|
||||
try:
|
||||
praison = PraisonAI()
|
||||
praison.main()
|
||||
except SystemExit:
|
||||
pass
|
||||
finally:
|
||||
sys.argv = original_argv
|
||||
@@ -0,0 +1,76 @@
|
||||
"""
|
||||
Recipe command group for PraisonAI CLI.
|
||||
|
||||
Provides recipe management commands.
|
||||
"""
|
||||
|
||||
import typer
|
||||
|
||||
app = typer.Typer(help="Recipe management")
|
||||
|
||||
|
||||
@app.command("list")
|
||||
def recipe_list():
|
||||
"""List available recipes."""
|
||||
from praisonai.cli.main import PraisonAI
|
||||
import sys
|
||||
|
||||
argv = ['recipe', 'list']
|
||||
|
||||
original_argv = sys.argv
|
||||
sys.argv = ['praisonai'] + argv
|
||||
|
||||
try:
|
||||
praison = PraisonAI()
|
||||
praison.main()
|
||||
except SystemExit:
|
||||
pass
|
||||
finally:
|
||||
sys.argv = original_argv
|
||||
|
||||
|
||||
@app.command("run")
|
||||
def recipe_run(
|
||||
name: str = typer.Argument(..., help="Recipe name"),
|
||||
model: str = typer.Option(None, "--model", "-m", help="LLM model to use"),
|
||||
):
|
||||
"""Run a recipe."""
|
||||
from praisonai.cli.main import PraisonAI
|
||||
import sys
|
||||
|
||||
argv = ['recipe', 'run', name]
|
||||
if model:
|
||||
argv.extend(['--model', model])
|
||||
|
||||
original_argv = sys.argv
|
||||
sys.argv = ['praisonai'] + argv
|
||||
|
||||
try:
|
||||
praison = PraisonAI()
|
||||
praison.main()
|
||||
except SystemExit:
|
||||
pass
|
||||
finally:
|
||||
sys.argv = original_argv
|
||||
|
||||
|
||||
@app.command("install")
|
||||
def recipe_install(
|
||||
source: str = typer.Argument(..., help="Recipe source (path or URL)"),
|
||||
):
|
||||
"""Install a recipe."""
|
||||
from praisonai.cli.main import PraisonAI
|
||||
import sys
|
||||
|
||||
argv = ['recipe', 'install', source]
|
||||
|
||||
original_argv = sys.argv
|
||||
sys.argv = ['praisonai'] + argv
|
||||
|
||||
try:
|
||||
praison = PraisonAI()
|
||||
praison.main()
|
||||
except SystemExit:
|
||||
pass
|
||||
finally:
|
||||
sys.argv = original_argv
|
||||
@@ -0,0 +1,51 @@
|
||||
"""
|
||||
Registry command group for PraisonAI CLI.
|
||||
|
||||
Provides registry management commands.
|
||||
"""
|
||||
|
||||
import typer
|
||||
|
||||
app = typer.Typer(help="Registry management")
|
||||
|
||||
|
||||
@app.command("list")
|
||||
def registry_list():
|
||||
"""List registry entries."""
|
||||
from praisonai.cli.main import PraisonAI
|
||||
import sys
|
||||
|
||||
argv = ['registry', 'list']
|
||||
|
||||
original_argv = sys.argv
|
||||
sys.argv = ['praisonai'] + argv
|
||||
|
||||
try:
|
||||
praison = PraisonAI()
|
||||
praison.main()
|
||||
except SystemExit:
|
||||
pass
|
||||
finally:
|
||||
sys.argv = original_argv
|
||||
|
||||
|
||||
@app.command("serve")
|
||||
def registry_serve(
|
||||
port: int = typer.Option(8080, "--port", "-p", help="Port to serve on"),
|
||||
):
|
||||
"""Start registry server."""
|
||||
from praisonai.cli.main import PraisonAI
|
||||
import sys
|
||||
|
||||
argv = ['registry', 'serve', '--port', str(port)]
|
||||
|
||||
original_argv = sys.argv
|
||||
sys.argv = ['praisonai'] + argv
|
||||
|
||||
try:
|
||||
praison = PraisonAI()
|
||||
praison.main()
|
||||
except SystemExit:
|
||||
pass
|
||||
finally:
|
||||
sys.argv = original_argv
|
||||
@@ -0,0 +1,49 @@
|
||||
"""
|
||||
Research command group for PraisonAI CLI.
|
||||
|
||||
Provides research and analysis commands.
|
||||
"""
|
||||
|
||||
import typer
|
||||
|
||||
app = typer.Typer(help="Research and analysis")
|
||||
|
||||
|
||||
@app.callback(invoke_without_command=True)
|
||||
def research_main(
|
||||
ctx: typer.Context,
|
||||
query: str = typer.Argument(None, help="Research query"),
|
||||
model: str = typer.Option(None, "--model", "-m", help="LLM model to use"),
|
||||
verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
|
||||
output: str = typer.Option(None, "--output", "-o", help="Output file path"),
|
||||
):
|
||||
"""
|
||||
Run research and analysis tasks.
|
||||
|
||||
Examples:
|
||||
praisonai research "What are the latest AI trends?"
|
||||
praisonai research --model gpt-4o "Analyze market data"
|
||||
"""
|
||||
from praisonai.cli.main import PraisonAI
|
||||
import sys
|
||||
|
||||
argv = ['research']
|
||||
if query:
|
||||
argv.append(query)
|
||||
if model:
|
||||
argv.extend(['--model', model])
|
||||
if verbose:
|
||||
argv.append('--verbose')
|
||||
if output:
|
||||
argv.extend(['--output', output])
|
||||
|
||||
original_argv = sys.argv
|
||||
sys.argv = ['praisonai'] + argv
|
||||
|
||||
try:
|
||||
praison = PraisonAI()
|
||||
praison.main()
|
||||
except SystemExit:
|
||||
pass
|
||||
finally:
|
||||
sys.argv = original_argv
|
||||
@@ -0,0 +1,71 @@
|
||||
"""
|
||||
Rules command group for PraisonAI CLI.
|
||||
|
||||
Provides rules management commands.
|
||||
"""
|
||||
|
||||
import typer
|
||||
|
||||
app = typer.Typer(help="Rules management")
|
||||
|
||||
|
||||
@app.command("list")
|
||||
def rules_list():
|
||||
"""List active rules."""
|
||||
from praisonai.cli.main import PraisonAI
|
||||
import sys
|
||||
|
||||
argv = ['rules', 'list']
|
||||
|
||||
original_argv = sys.argv
|
||||
sys.argv = ['praisonai'] + argv
|
||||
|
||||
try:
|
||||
praison = PraisonAI()
|
||||
praison.main()
|
||||
except SystemExit:
|
||||
pass
|
||||
finally:
|
||||
sys.argv = original_argv
|
||||
|
||||
|
||||
@app.command("add")
|
||||
def rules_add(
|
||||
rule: str = typer.Argument(..., help="Rule to add"),
|
||||
):
|
||||
"""Add a rule."""
|
||||
from praisonai.cli.main import PraisonAI
|
||||
import sys
|
||||
|
||||
argv = ['rules', 'add', rule]
|
||||
|
||||
original_argv = sys.argv
|
||||
sys.argv = ['praisonai'] + argv
|
||||
|
||||
try:
|
||||
praison = PraisonAI()
|
||||
praison.main()
|
||||
except SystemExit:
|
||||
pass
|
||||
finally:
|
||||
sys.argv = original_argv
|
||||
|
||||
|
||||
@app.command("clear")
|
||||
def rules_clear():
|
||||
"""Clear all rules."""
|
||||
from praisonai.cli.main import PraisonAI
|
||||
import sys
|
||||
|
||||
argv = ['rules', 'clear']
|
||||
|
||||
original_argv = sys.argv
|
||||
sys.argv = ['praisonai'] + argv
|
||||
|
||||
try:
|
||||
praison = PraisonAI()
|
||||
praison.main()
|
||||
except SystemExit:
|
||||
pass
|
||||
finally:
|
||||
sys.argv = original_argv
|
||||
@@ -0,0 +1,99 @@
|
||||
"""
|
||||
Skills command group for PraisonAI CLI.
|
||||
|
||||
Provides skill management commands.
|
||||
"""
|
||||
|
||||
import typer
|
||||
|
||||
app = typer.Typer(help="Skill management")
|
||||
|
||||
|
||||
@app.command("list")
|
||||
def skills_list(
|
||||
verbose: bool = typer.Option(False, "--verbose", "-v", help="Show detailed info"),
|
||||
):
|
||||
"""List available skills."""
|
||||
from praisonai.cli.main import PraisonAI
|
||||
import sys
|
||||
|
||||
argv = ['skills', 'list']
|
||||
if verbose:
|
||||
argv.append('--verbose')
|
||||
|
||||
original_argv = sys.argv
|
||||
sys.argv = ['praisonai'] + argv
|
||||
|
||||
try:
|
||||
praison = PraisonAI()
|
||||
praison.main()
|
||||
except SystemExit:
|
||||
pass
|
||||
finally:
|
||||
sys.argv = original_argv
|
||||
|
||||
|
||||
@app.command("validate")
|
||||
def skills_validate(
|
||||
path: str = typer.Argument(..., help="Skill directory path"),
|
||||
):
|
||||
"""Validate a skill."""
|
||||
from praisonai.cli.main import PraisonAI
|
||||
import sys
|
||||
|
||||
argv = ['skills', 'validate', path]
|
||||
|
||||
original_argv = sys.argv
|
||||
sys.argv = ['praisonai'] + argv
|
||||
|
||||
try:
|
||||
praison = PraisonAI()
|
||||
praison.main()
|
||||
except SystemExit:
|
||||
pass
|
||||
finally:
|
||||
sys.argv = original_argv
|
||||
|
||||
|
||||
@app.command("create")
|
||||
def skills_create(
|
||||
name: str = typer.Argument(..., help="Skill name"),
|
||||
):
|
||||
"""Create a new skill."""
|
||||
from praisonai.cli.main import PraisonAI
|
||||
import sys
|
||||
|
||||
argv = ['skills', 'create', name]
|
||||
|
||||
original_argv = sys.argv
|
||||
sys.argv = ['praisonai'] + argv
|
||||
|
||||
try:
|
||||
praison = PraisonAI()
|
||||
praison.main()
|
||||
except SystemExit:
|
||||
pass
|
||||
finally:
|
||||
sys.argv = original_argv
|
||||
|
||||
|
||||
@app.command("install")
|
||||
def skills_install(
|
||||
source: str = typer.Argument(..., help="Skill source (path or URL)"),
|
||||
):
|
||||
"""Install a skill."""
|
||||
from praisonai.cli.main import PraisonAI
|
||||
import sys
|
||||
|
||||
argv = ['skills', 'install', source]
|
||||
|
||||
original_argv = sys.argv
|
||||
sys.argv = ['praisonai'] + argv
|
||||
|
||||
try:
|
||||
praison = PraisonAI()
|
||||
praison.main()
|
||||
except SystemExit:
|
||||
pass
|
||||
finally:
|
||||
sys.argv = original_argv
|
||||
@@ -0,0 +1,54 @@
|
||||
"""
|
||||
Templates command group for PraisonAI CLI.
|
||||
|
||||
Provides template management commands.
|
||||
"""
|
||||
|
||||
import typer
|
||||
|
||||
app = typer.Typer(help="Template management")
|
||||
|
||||
|
||||
@app.command("list")
|
||||
def templates_list():
|
||||
"""List available templates."""
|
||||
from praisonai.cli.main import PraisonAI
|
||||
import sys
|
||||
|
||||
argv = ['templates', 'list']
|
||||
|
||||
original_argv = sys.argv
|
||||
sys.argv = ['praisonai'] + argv
|
||||
|
||||
try:
|
||||
praison = PraisonAI()
|
||||
praison.main()
|
||||
except SystemExit:
|
||||
pass
|
||||
finally:
|
||||
sys.argv = original_argv
|
||||
|
||||
|
||||
@app.command("create")
|
||||
def templates_create(
|
||||
name: str = typer.Argument(..., help="Template name"),
|
||||
source: str = typer.Option(None, "--source", "-s", help="Source file to create template from"),
|
||||
):
|
||||
"""Create a new template."""
|
||||
from praisonai.cli.main import PraisonAI
|
||||
import sys
|
||||
|
||||
argv = ['templates', 'create', name]
|
||||
if source:
|
||||
argv.extend(['--source', source])
|
||||
|
||||
original_argv = sys.argv
|
||||
sys.argv = ['praisonai'] + argv
|
||||
|
||||
try:
|
||||
praison = PraisonAI()
|
||||
praison.main()
|
||||
except SystemExit:
|
||||
pass
|
||||
finally:
|
||||
sys.argv = original_argv
|
||||
@@ -0,0 +1,74 @@
|
||||
"""
|
||||
Todo command group for PraisonAI CLI.
|
||||
|
||||
Provides todo/task management commands.
|
||||
"""
|
||||
|
||||
import typer
|
||||
|
||||
app = typer.Typer(help="Todo/task management")
|
||||
|
||||
|
||||
@app.command("list")
|
||||
def todo_list():
|
||||
"""List todos."""
|
||||
from praisonai.cli.main import PraisonAI
|
||||
import sys
|
||||
|
||||
argv = ['todo', 'list']
|
||||
|
||||
original_argv = sys.argv
|
||||
sys.argv = ['praisonai'] + argv
|
||||
|
||||
try:
|
||||
praison = PraisonAI()
|
||||
praison.main()
|
||||
except SystemExit:
|
||||
pass
|
||||
finally:
|
||||
sys.argv = original_argv
|
||||
|
||||
|
||||
@app.command("add")
|
||||
def todo_add(
|
||||
task: str = typer.Argument(..., help="Task description"),
|
||||
priority: str = typer.Option("medium", "--priority", "-p", help="Priority (low, medium, high)"),
|
||||
):
|
||||
"""Add a todo."""
|
||||
from praisonai.cli.main import PraisonAI
|
||||
import sys
|
||||
|
||||
argv = ['todo', 'add', task]
|
||||
|
||||
original_argv = sys.argv
|
||||
sys.argv = ['praisonai'] + argv
|
||||
|
||||
try:
|
||||
praison = PraisonAI()
|
||||
praison.main()
|
||||
except SystemExit:
|
||||
pass
|
||||
finally:
|
||||
sys.argv = original_argv
|
||||
|
||||
|
||||
@app.command("done")
|
||||
def todo_done(
|
||||
task_id: str = typer.Argument(..., help="Task ID to mark as done"),
|
||||
):
|
||||
"""Mark a todo as done."""
|
||||
from praisonai.cli.main import PraisonAI
|
||||
import sys
|
||||
|
||||
argv = ['todo', 'done', task_id]
|
||||
|
||||
original_argv = sys.argv
|
||||
sys.argv = ['praisonai'] + argv
|
||||
|
||||
try:
|
||||
praison = PraisonAI()
|
||||
praison.main()
|
||||
except SystemExit:
|
||||
pass
|
||||
finally:
|
||||
sys.argv = original_argv
|
||||
@@ -0,0 +1,77 @@
|
||||
"""
|
||||
Tools command group for PraisonAI CLI.
|
||||
|
||||
Provides tool management commands.
|
||||
"""
|
||||
|
||||
import typer
|
||||
|
||||
app = typer.Typer(help="Tool management")
|
||||
|
||||
|
||||
@app.command("list")
|
||||
def tools_list(
|
||||
verbose: bool = typer.Option(False, "--verbose", "-v", help="Show detailed info"),
|
||||
):
|
||||
"""List available tools."""
|
||||
from praisonai.cli.main import PraisonAI
|
||||
import sys
|
||||
|
||||
argv = ['tools', 'list']
|
||||
if verbose:
|
||||
argv.append('--verbose')
|
||||
|
||||
original_argv = sys.argv
|
||||
sys.argv = ['praisonai'] + argv
|
||||
|
||||
try:
|
||||
praison = PraisonAI()
|
||||
praison.main()
|
||||
except SystemExit:
|
||||
pass
|
||||
finally:
|
||||
sys.argv = original_argv
|
||||
|
||||
|
||||
@app.command("info")
|
||||
def tools_info(
|
||||
name: str = typer.Argument(..., help="Tool name"),
|
||||
):
|
||||
"""Show tool information."""
|
||||
from praisonai.cli.main import PraisonAI
|
||||
import sys
|
||||
|
||||
argv = ['tools', 'info', name]
|
||||
|
||||
original_argv = sys.argv
|
||||
sys.argv = ['praisonai'] + argv
|
||||
|
||||
try:
|
||||
praison = PraisonAI()
|
||||
praison.main()
|
||||
except SystemExit:
|
||||
pass
|
||||
finally:
|
||||
sys.argv = original_argv
|
||||
|
||||
|
||||
@app.command("test")
|
||||
def tools_test(
|
||||
name: str = typer.Argument(..., help="Tool name to test"),
|
||||
):
|
||||
"""Test a tool."""
|
||||
from praisonai.cli.main import PraisonAI
|
||||
import sys
|
||||
|
||||
argv = ['tools', 'test', name]
|
||||
|
||||
original_argv = sys.argv
|
||||
sys.argv = ['praisonai'] + argv
|
||||
|
||||
try:
|
||||
praison = PraisonAI()
|
||||
praison.main()
|
||||
except SystemExit:
|
||||
pass
|
||||
finally:
|
||||
sys.argv = original_argv
|
||||
@@ -0,0 +1,48 @@
|
||||
"""
|
||||
Train command group for PraisonAI CLI.
|
||||
|
||||
Provides model training commands.
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
import typer
|
||||
|
||||
app = typer.Typer(help="Model training and fine-tuning")
|
||||
|
||||
|
||||
@app.callback(invoke_without_command=True)
|
||||
def train_main(
|
||||
ctx: typer.Context,
|
||||
dataset: Optional[str] = typer.Argument(None, help="Training dataset path"),
|
||||
model: Optional[str] = typer.Option(None, "--model", "-m", help="Base model to fine-tune"),
|
||||
verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
|
||||
):
|
||||
"""
|
||||
Train or fine-tune models.
|
||||
|
||||
Examples:
|
||||
praisonai train dataset.json
|
||||
praisonai train --model gpt-4o dataset.json
|
||||
"""
|
||||
from praisonai.cli.main import PraisonAI
|
||||
import sys
|
||||
|
||||
argv = ['train']
|
||||
if dataset:
|
||||
argv.append(dataset)
|
||||
if model:
|
||||
argv.extend(['--model', model])
|
||||
if verbose:
|
||||
argv.append('--verbose')
|
||||
|
||||
original_argv = sys.argv
|
||||
sys.argv = ['praisonai'] + argv
|
||||
|
||||
try:
|
||||
praison = PraisonAI()
|
||||
praison.main()
|
||||
except SystemExit:
|
||||
pass
|
||||
finally:
|
||||
sys.argv = original_argv
|
||||
@@ -0,0 +1,47 @@
|
||||
"""
|
||||
UI command group for PraisonAI CLI.
|
||||
|
||||
Provides web UI commands.
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
import typer
|
||||
|
||||
app = typer.Typer(help="Web UI management")
|
||||
|
||||
|
||||
@app.callback(invoke_without_command=True)
|
||||
def ui_main(
|
||||
ctx: typer.Context,
|
||||
port: int = typer.Option(8080, "--port", "-p", help="Port to run UI on"),
|
||||
host: str = typer.Option("127.0.0.1", "--host", "-h", help="Host to bind to"),
|
||||
public: bool = typer.Option(False, "--public", help="Make UI publicly accessible"),
|
||||
):
|
||||
"""
|
||||
Start the web UI.
|
||||
|
||||
Examples:
|
||||
praisonai ui
|
||||
praisonai ui --port 3000
|
||||
praisonai ui --public
|
||||
"""
|
||||
from praisonai.cli.main import PraisonAI
|
||||
import sys
|
||||
|
||||
argv = ['ui']
|
||||
argv.extend(['--port', str(port)])
|
||||
argv.extend(['--host', host])
|
||||
if public:
|
||||
argv.append('--public')
|
||||
|
||||
original_argv = sys.argv
|
||||
sys.argv = ['praisonai'] + argv
|
||||
|
||||
try:
|
||||
praison = PraisonAI()
|
||||
praison.main()
|
||||
except SystemExit:
|
||||
pass
|
||||
finally:
|
||||
sys.argv = original_argv
|
||||
@@ -0,0 +1,82 @@
|
||||
"""
|
||||
Workflow command group for PraisonAI CLI.
|
||||
|
||||
Provides workflow management commands.
|
||||
"""
|
||||
|
||||
import typer
|
||||
|
||||
app = typer.Typer(help="Workflow management")
|
||||
|
||||
|
||||
@app.command("run")
|
||||
def workflow_run(
|
||||
file: str = typer.Argument(..., help="Workflow file path"),
|
||||
model: str = typer.Option(None, "--model", "-m", help="LLM model to use"),
|
||||
verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
|
||||
):
|
||||
"""Run a workflow."""
|
||||
from praisonai.cli.main import PraisonAI
|
||||
import sys
|
||||
|
||||
argv = ['workflow', 'run', file]
|
||||
if model:
|
||||
argv.extend(['--model', model])
|
||||
if verbose:
|
||||
argv.append('--verbose')
|
||||
|
||||
original_argv = sys.argv
|
||||
sys.argv = ['praisonai'] + argv
|
||||
|
||||
try:
|
||||
praison = PraisonAI()
|
||||
praison.main()
|
||||
except SystemExit:
|
||||
pass
|
||||
finally:
|
||||
sys.argv = original_argv
|
||||
|
||||
|
||||
@app.command("list")
|
||||
def workflow_list():
|
||||
"""List available workflows."""
|
||||
from praisonai.cli.main import PraisonAI
|
||||
import sys
|
||||
|
||||
argv = ['workflow', 'list']
|
||||
|
||||
original_argv = sys.argv
|
||||
sys.argv = ['praisonai'] + argv
|
||||
|
||||
try:
|
||||
praison = PraisonAI()
|
||||
praison.main()
|
||||
except SystemExit:
|
||||
pass
|
||||
finally:
|
||||
sys.argv = original_argv
|
||||
|
||||
|
||||
@app.command("create")
|
||||
def workflow_create(
|
||||
name: str = typer.Argument(..., help="Workflow name"),
|
||||
template: str = typer.Option(None, "--template", "-t", help="Template to use"),
|
||||
):
|
||||
"""Create a new workflow."""
|
||||
from praisonai.cli.main import PraisonAI
|
||||
import sys
|
||||
|
||||
argv = ['workflow', 'create', name]
|
||||
if template:
|
||||
argv.extend(['--template', template])
|
||||
|
||||
original_argv = sys.argv
|
||||
sys.argv = ['praisonai'] + argv
|
||||
|
||||
try:
|
||||
praison = PraisonAI()
|
||||
praison.main()
|
||||
except SystemExit:
|
||||
pass
|
||||
finally:
|
||||
sys.argv = original_argv
|
||||
@@ -419,6 +419,8 @@ class ProfileReport:
|
||||
"timestamp": self.timestamp,
|
||||
"invocation": self.invocation.to_dict(),
|
||||
"timing": self.timing.to_dict(),
|
||||
"timeline": self.timing.to_timeline(),
|
||||
"timeline_diagram": self.to_timeline_diagram(),
|
||||
"response_preview": self.response_preview,
|
||||
}
|
||||
|
||||
@@ -444,6 +446,85 @@ class ProfileReport:
|
||||
import json
|
||||
return json.dumps(self.to_dict(), indent=2, default=str)
|
||||
|
||||
def to_timeline_diagram(self) -> str:
|
||||
"""
|
||||
Generate ASCII timeline diagram showing execution phases.
|
||||
|
||||
Format:
|
||||
ENTER ─────────────────────────────────────────────────────────► RESPONSE
|
||||
│ imports │ init │ network │
|
||||
│ XXXms │ XXms │ XXXms │
|
||||
└─────────┴──────┴───────────────────────┘
|
||||
TOTAL: XXXXms
|
||||
"""
|
||||
lines = []
|
||||
total = self.timing.total_ms
|
||||
if total <= 0:
|
||||
return "No timing data available"
|
||||
|
||||
# Calculate phase widths (proportional to time, min 8 chars)
|
||||
phases = []
|
||||
if self.timing.imports_ms > 0:
|
||||
phases.append(("imports", self.timing.imports_ms))
|
||||
if self.timing.agent_init_ms > 0:
|
||||
phases.append(("init", self.timing.agent_init_ms))
|
||||
if self.timing.execution_ms > 0:
|
||||
phases.append(("network", self.timing.execution_ms))
|
||||
|
||||
if not phases:
|
||||
phases = [("total", total)]
|
||||
|
||||
# Scale to fit in ~60 chars
|
||||
scale = 50.0 / total if total > 0 else 1
|
||||
|
||||
# Build the diagram
|
||||
lines.append("")
|
||||
lines.append("## Timeline Diagram")
|
||||
lines.append("")
|
||||
|
||||
# Top line with arrow
|
||||
top_line = "ENTER "
|
||||
for name, ms in phases:
|
||||
width = max(8, int(ms * scale))
|
||||
top_line += "─" * width
|
||||
top_line += "► RESPONSE"
|
||||
lines.append(top_line)
|
||||
|
||||
# Phase names line
|
||||
name_line = " "
|
||||
for name, ms in phases:
|
||||
width = max(8, int(ms * scale))
|
||||
name_line += "│" + name.center(width - 1)
|
||||
name_line += "│"
|
||||
lines.append(name_line)
|
||||
|
||||
# Phase times line
|
||||
time_line = " "
|
||||
for name, ms in phases:
|
||||
width = max(8, int(ms * scale))
|
||||
time_str = f"{ms:.0f}ms"
|
||||
time_line += "│" + time_str.center(width - 1)
|
||||
time_line += "│"
|
||||
lines.append(time_line)
|
||||
|
||||
# Bottom line
|
||||
bottom_line = " "
|
||||
for i, (name, ms) in enumerate(phases):
|
||||
width = max(8, int(ms * scale))
|
||||
if i == 0:
|
||||
bottom_line += "└" + "─" * (width - 1)
|
||||
else:
|
||||
bottom_line += "┴" + "─" * (width - 1)
|
||||
bottom_line += "┘"
|
||||
lines.append(bottom_line)
|
||||
|
||||
# Total line
|
||||
total_line = " " * (len(bottom_line) - 15) + f"TOTAL: {total:.0f}ms"
|
||||
lines.append(total_line)
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def to_text(self) -> str:
|
||||
"""Format as human-readable text."""
|
||||
lines = []
|
||||
@@ -459,7 +540,10 @@ class ProfileReport:
|
||||
lines.append(f"Version: {self.invocation.praisonai_version}")
|
||||
lines.append("")
|
||||
|
||||
# TIMELINE SECTION (new - shows ENTER → First Response)
|
||||
# TIMELINE DIAGRAM (visual representation)
|
||||
lines.append(self.to_timeline_diagram())
|
||||
|
||||
# TIMELINE SECTION (shows ENTER → First Response)
|
||||
lines.append("## Execution Timeline")
|
||||
lines.append("-" * 45)
|
||||
timeline = self.timing.to_timeline()
|
||||
|
||||
@@ -94,6 +94,8 @@ __all__ = [
|
||||
'CapabilitiesHandler',
|
||||
# Performance benchmarking
|
||||
'PerformanceHandler',
|
||||
# Comprehensive benchmarking
|
||||
'BenchmarkHandler',
|
||||
# Lite agent (BYO-LLM)
|
||||
'LiteHandler',
|
||||
# Agent-centric tools (LSP/ACP powered)
|
||||
@@ -233,6 +235,9 @@ def __getattr__(name):
|
||||
elif name == 'PerformanceHandler':
|
||||
from .performance import PerformanceHandler
|
||||
return PerformanceHandler
|
||||
elif name == 'BenchmarkHandler':
|
||||
from .benchmark import BenchmarkHandler
|
||||
return BenchmarkHandler
|
||||
elif name == 'LiteHandler':
|
||||
from .lite import LiteHandler
|
||||
return LiteHandler
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -4,6 +4,7 @@ ACP health checks for PraisonAI Doctor.
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
|
||||
from ..models import CheckCategory, CheckResult, CheckStatus
|
||||
from ..registry import register_check
|
||||
@@ -17,7 +18,7 @@ logger = logging.getLogger(__name__)
|
||||
category=CheckCategory.TOOLS,
|
||||
description="Check if ACP module is installed"
|
||||
)
|
||||
def check_acp_module() -> CheckResult:
|
||||
def check_acp_module(config=None) -> CheckResult:
|
||||
"""Check if ACP module is available."""
|
||||
try:
|
||||
import importlib.util
|
||||
@@ -25,21 +26,30 @@ def check_acp_module() -> CheckResult:
|
||||
|
||||
if spec is not None:
|
||||
return CheckResult(
|
||||
id="acp_module",
|
||||
title="ACP Module Available",
|
||||
category=CheckCategory.TOOLS,
|
||||
status=CheckStatus.PASS,
|
||||
message="ACP module is available",
|
||||
details={"module": "praisonai.acp"}
|
||||
metadata={"module": "praisonai.acp"}
|
||||
)
|
||||
else:
|
||||
return CheckResult(
|
||||
id="acp_module",
|
||||
title="ACP Module Available",
|
||||
category=CheckCategory.TOOLS,
|
||||
status=CheckStatus.WARN,
|
||||
message="ACP module not found",
|
||||
details={"hint": "ACP module should be part of praisonai package"}
|
||||
remediation="ACP module should be part of praisonai package"
|
||||
)
|
||||
except Exception as e:
|
||||
return CheckResult(
|
||||
id="acp_module",
|
||||
title="ACP Module Available",
|
||||
category=CheckCategory.TOOLS,
|
||||
status=CheckStatus.FAIL,
|
||||
message=f"Error checking ACP module: {e}",
|
||||
details={"error": str(e)}
|
||||
metadata={"error": str(e)}
|
||||
)
|
||||
|
||||
|
||||
@@ -49,7 +59,7 @@ def check_acp_module() -> CheckResult:
|
||||
category=CheckCategory.TOOLS,
|
||||
description="Check if agent-client-protocol SDK is installed"
|
||||
)
|
||||
def check_acp_sdk() -> CheckResult:
|
||||
def check_acp_sdk(config=None) -> CheckResult:
|
||||
"""Check if ACP SDK is available."""
|
||||
try:
|
||||
import importlib.util
|
||||
@@ -57,21 +67,30 @@ def check_acp_sdk() -> CheckResult:
|
||||
|
||||
if spec is not None:
|
||||
return CheckResult(
|
||||
id="acp_sdk",
|
||||
title="ACP SDK Available",
|
||||
category=CheckCategory.TOOLS,
|
||||
status=CheckStatus.PASS,
|
||||
message="ACP SDK (agent-client-protocol) is installed",
|
||||
details={"package": "agent-client-protocol"}
|
||||
metadata={"package": "agent-client-protocol"}
|
||||
)
|
||||
else:
|
||||
return CheckResult(
|
||||
status=CheckStatus.INFO,
|
||||
id="acp_sdk",
|
||||
title="ACP SDK Available",
|
||||
category=CheckCategory.TOOLS,
|
||||
status=CheckStatus.SKIP,
|
||||
message="ACP SDK not installed (optional)",
|
||||
details={"hint": "Install with: pip install agent-client-protocol"}
|
||||
remediation="Install with: pip install agent-client-protocol"
|
||||
)
|
||||
except Exception as e:
|
||||
return CheckResult(
|
||||
id="acp_sdk",
|
||||
title="ACP SDK Available",
|
||||
category=CheckCategory.TOOLS,
|
||||
status=CheckStatus.FAIL,
|
||||
message=f"Error checking ACP SDK: {e}",
|
||||
details={"error": str(e)}
|
||||
metadata={"error": str(e)}
|
||||
)
|
||||
|
||||
|
||||
@@ -81,27 +100,36 @@ def check_acp_sdk() -> CheckResult:
|
||||
category=CheckCategory.TOOLS,
|
||||
description="Check if ACP server can be imported"
|
||||
)
|
||||
def check_acp_server() -> CheckResult:
|
||||
def check_acp_server(config=None) -> CheckResult:
|
||||
"""Check if ACP server can be imported."""
|
||||
try:
|
||||
from praisonai.acp import ACPServer # noqa: F401
|
||||
|
||||
return CheckResult(
|
||||
id="acp_server",
|
||||
title="ACP Server Import",
|
||||
category=CheckCategory.TOOLS,
|
||||
status=CheckStatus.PASS,
|
||||
message="ACP server can be imported",
|
||||
details={"class": "ACPServer"}
|
||||
metadata={"class": "ACPServer"}
|
||||
)
|
||||
except ImportError as e:
|
||||
return CheckResult(
|
||||
id="acp_server",
|
||||
title="ACP Server Import",
|
||||
category=CheckCategory.TOOLS,
|
||||
status=CheckStatus.WARN,
|
||||
message=f"ACP server import failed: {e}",
|
||||
details={"error": str(e)}
|
||||
metadata={"error": str(e)}
|
||||
)
|
||||
except Exception as e:
|
||||
return CheckResult(
|
||||
id="acp_server",
|
||||
title="ACP Server Import",
|
||||
category=CheckCategory.TOOLS,
|
||||
status=CheckStatus.FAIL,
|
||||
message=f"Error importing ACP server: {e}",
|
||||
details={"error": str(e)}
|
||||
metadata={"error": str(e)}
|
||||
)
|
||||
|
||||
|
||||
@@ -111,20 +139,21 @@ def check_acp_server() -> CheckResult:
|
||||
category=CheckCategory.CONFIG,
|
||||
description="Check ACP configuration"
|
||||
)
|
||||
def check_acp_config() -> CheckResult:
|
||||
def check_acp_config(config=None) -> CheckResult:
|
||||
"""Check ACP configuration."""
|
||||
import os
|
||||
|
||||
config = {
|
||||
acp_conf = {
|
||||
"approval_mode": os.environ.get("PRAISONAI_APPROVAL_MODE", "manual"),
|
||||
"allow_write": os.environ.get("PRAISONAI_ALLOW_WRITE", "false"),
|
||||
"allow_shell": os.environ.get("PRAISONAI_ALLOW_SHELL", "false"),
|
||||
}
|
||||
|
||||
return CheckResult(
|
||||
id="acp_config",
|
||||
title="ACP Configuration",
|
||||
category=CheckCategory.CONFIG,
|
||||
status=CheckStatus.PASS,
|
||||
message=f"ACP configured with approval_mode={config['approval_mode']}",
|
||||
details=config
|
||||
message=f"ACP configured with approval_mode={acp_conf['approval_mode']}",
|
||||
metadata=acp_conf
|
||||
)
|
||||
|
||||
|
||||
@@ -135,7 +164,7 @@ def check_acp_config() -> CheckResult:
|
||||
description="Check if ACP runtime can start",
|
||||
requires_deep=True
|
||||
)
|
||||
def check_acp_runtime() -> CheckResult:
|
||||
def check_acp_runtime(config=None) -> CheckResult:
|
||||
"""Check if ACP runtime can start."""
|
||||
try:
|
||||
from praisonai.cli.features.interactive_runtime import create_runtime
|
||||
@@ -150,88 +179,39 @@ def check_acp_runtime() -> CheckResult:
|
||||
await runtime.start()
|
||||
ready = runtime.acp_ready
|
||||
error = runtime._acp_state.error
|
||||
read_only = runtime.read_only
|
||||
return ready, error, read_only
|
||||
return ready, error
|
||||
finally:
|
||||
await runtime.stop()
|
||||
|
||||
ready, error, read_only = asyncio.run(_check())
|
||||
ready, error = asyncio.run(_check())
|
||||
|
||||
if ready:
|
||||
return CheckResult(
|
||||
id="acp_runtime",
|
||||
title="ACP Runtime Check",
|
||||
category=CheckCategory.TOOLS,
|
||||
status=CheckStatus.PASS,
|
||||
message="ACP runtime started successfully",
|
||||
details={"ready": True, "read_only": read_only}
|
||||
metadata={"ready": True}
|
||||
)
|
||||
else:
|
||||
return CheckResult(
|
||||
id="acp_runtime",
|
||||
title="ACP Runtime Check",
|
||||
category=CheckCategory.TOOLS,
|
||||
status=CheckStatus.WARN,
|
||||
message=f"ACP runtime failed to start: {error}",
|
||||
details={"ready": False, "error": error, "read_only": read_only}
|
||||
metadata={"ready": False, "error": str(error)}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return CheckResult(
|
||||
id="acp_runtime",
|
||||
title="ACP Runtime Check",
|
||||
category=CheckCategory.TOOLS,
|
||||
status=CheckStatus.FAIL,
|
||||
message=f"ACP runtime check failed: {e}",
|
||||
details={"error": str(e)}
|
||||
)
|
||||
|
||||
|
||||
@register_check(
|
||||
id="interactive_runtime",
|
||||
title="Interactive Runtime Check",
|
||||
category=CheckCategory.TOOLS,
|
||||
description="Check if full interactive runtime can start",
|
||||
requires_deep=True
|
||||
)
|
||||
def check_interactive_runtime() -> CheckResult:
|
||||
"""Check if full interactive runtime can start."""
|
||||
try:
|
||||
from praisonai.cli.features.interactive_runtime import create_runtime
|
||||
|
||||
async def _check():
|
||||
runtime = create_runtime(
|
||||
workspace=".",
|
||||
lsp=True,
|
||||
acp=True
|
||||
)
|
||||
try:
|
||||
await runtime.start()
|
||||
status = runtime.get_status()
|
||||
return status
|
||||
finally:
|
||||
await runtime.stop()
|
||||
|
||||
status = asyncio.run(_check())
|
||||
|
||||
lsp_ready = status.get("lsp", {}).get("ready", False)
|
||||
acp_ready = status.get("acp", {}).get("ready", False)
|
||||
|
||||
if lsp_ready and acp_ready:
|
||||
return CheckResult(
|
||||
status=CheckStatus.PASS,
|
||||
message="Interactive runtime fully operational",
|
||||
details=status
|
||||
)
|
||||
elif lsp_ready or acp_ready:
|
||||
return CheckResult(
|
||||
status=CheckStatus.WARN,
|
||||
message="Interactive runtime partially operational",
|
||||
details=status
|
||||
)
|
||||
else:
|
||||
return CheckResult(
|
||||
status=CheckStatus.FAIL,
|
||||
message="Interactive runtime failed to start",
|
||||
details=status
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return CheckResult(
|
||||
status=CheckStatus.FAIL,
|
||||
message=f"Interactive runtime check failed: {e}",
|
||||
details={"error": str(e)}
|
||||
metadata={"error": str(e)}
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ LSP health checks for PraisonAI Doctor.
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import shutil
|
||||
|
||||
from ..models import CheckCategory, CheckResult, CheckStatus
|
||||
from ..registry import register_check
|
||||
@@ -17,7 +18,7 @@ logger = logging.getLogger(__name__)
|
||||
category=CheckCategory.TOOLS,
|
||||
description="Check if LSP module is installed"
|
||||
)
|
||||
def check_lsp_module() -> CheckResult:
|
||||
def check_lsp_module(config=None) -> CheckResult:
|
||||
"""Check if LSP module is available."""
|
||||
try:
|
||||
import importlib.util
|
||||
@@ -25,21 +26,30 @@ def check_lsp_module() -> CheckResult:
|
||||
|
||||
if spec is not None:
|
||||
return CheckResult(
|
||||
id="lsp_module",
|
||||
title="LSP Module Available",
|
||||
category=CheckCategory.TOOLS,
|
||||
status=CheckStatus.PASS,
|
||||
message="LSP module is available",
|
||||
details={"module": "praisonaiagents.lsp"}
|
||||
metadata={"module": "praisonaiagents.lsp"}
|
||||
)
|
||||
else:
|
||||
return CheckResult(
|
||||
id="lsp_module",
|
||||
title="LSP Module Available",
|
||||
category=CheckCategory.TOOLS,
|
||||
status=CheckStatus.WARN,
|
||||
message="LSP module not found",
|
||||
details={"hint": "Install praisonaiagents with LSP support"}
|
||||
remediation="Install praisonaiagents with LSP support"
|
||||
)
|
||||
except Exception as e:
|
||||
return CheckResult(
|
||||
id="lsp_module",
|
||||
title="LSP Module Available",
|
||||
category=CheckCategory.TOOLS,
|
||||
status=CheckStatus.FAIL,
|
||||
message=f"Error checking LSP module: {e}",
|
||||
details={"error": str(e)}
|
||||
metadata={"error": str(e)}
|
||||
)
|
||||
|
||||
|
||||
@@ -49,27 +59,36 @@ def check_lsp_module() -> CheckResult:
|
||||
category=CheckCategory.TOOLS,
|
||||
description="Check if LSP client can be imported"
|
||||
)
|
||||
def check_lsp_client() -> CheckResult:
|
||||
def check_lsp_client(config=None) -> CheckResult:
|
||||
"""Check if LSP client can be imported."""
|
||||
try:
|
||||
from praisonaiagents.lsp import LSPClient # noqa: F401
|
||||
|
||||
return CheckResult(
|
||||
id="lsp_client",
|
||||
title="LSP Client Import",
|
||||
category=CheckCategory.TOOLS,
|
||||
status=CheckStatus.PASS,
|
||||
message="LSP client can be imported",
|
||||
details={"class": "LSPClient"}
|
||||
metadata={"class": "LSPClient"}
|
||||
)
|
||||
except ImportError as e:
|
||||
return CheckResult(
|
||||
id="lsp_client",
|
||||
title="LSP Client Import",
|
||||
category=CheckCategory.TOOLS,
|
||||
status=CheckStatus.WARN,
|
||||
message=f"LSP client import failed: {e}",
|
||||
details={"error": str(e)}
|
||||
metadata={"error": str(e)}
|
||||
)
|
||||
except Exception as e:
|
||||
return CheckResult(
|
||||
id="lsp_client",
|
||||
title="LSP Client Import",
|
||||
category=CheckCategory.TOOLS,
|
||||
status=CheckStatus.FAIL,
|
||||
message=f"Error importing LSP client: {e}",
|
||||
details={"error": str(e)}
|
||||
metadata={"error": str(e)}
|
||||
)
|
||||
|
||||
|
||||
@@ -79,11 +98,8 @@ def check_lsp_client() -> CheckResult:
|
||||
category=CheckCategory.TOOLS,
|
||||
description="Check if Python language server is available"
|
||||
)
|
||||
def check_lsp_server_python() -> CheckResult:
|
||||
def check_lsp_server_python(config=None) -> CheckResult:
|
||||
"""Check if Python language server is available."""
|
||||
import shutil
|
||||
|
||||
# Check for common Python language servers
|
||||
servers = [
|
||||
("pyright-langserver", "pyright"),
|
||||
("pylsp", "python-lsp-server"),
|
||||
@@ -97,18 +113,21 @@ def check_lsp_server_python() -> CheckResult:
|
||||
|
||||
if found:
|
||||
return CheckResult(
|
||||
id="lsp_server_python",
|
||||
title="Python LSP Server",
|
||||
category=CheckCategory.TOOLS,
|
||||
status=CheckStatus.PASS,
|
||||
message=f"Python LSP server available: {found[0]['command']}",
|
||||
details={"servers": found}
|
||||
metadata={"servers": found}
|
||||
)
|
||||
else:
|
||||
return CheckResult(
|
||||
id="lsp_server_python",
|
||||
title="Python LSP Server",
|
||||
category=CheckCategory.TOOLS,
|
||||
status=CheckStatus.WARN,
|
||||
message="No Python LSP server found",
|
||||
details={
|
||||
"hint": "Install with: npm install -g pyright OR pip install python-lsp-server",
|
||||
"checked": [s[0] for s in servers]
|
||||
}
|
||||
remediation="Install with: npm install -g pyright OR pip install python-lsp-server"
|
||||
)
|
||||
|
||||
|
||||
@@ -118,30 +137,34 @@ def check_lsp_server_python() -> CheckResult:
|
||||
category=CheckCategory.TOOLS,
|
||||
description="Check if TypeScript language server is available"
|
||||
)
|
||||
def check_lsp_server_typescript() -> CheckResult:
|
||||
def check_lsp_server_typescript(config=None) -> CheckResult:
|
||||
"""Check if TypeScript language server is available."""
|
||||
import shutil
|
||||
|
||||
# Check for TypeScript language server
|
||||
if shutil.which("typescript-language-server"):
|
||||
return CheckResult(
|
||||
id="lsp_server_typescript",
|
||||
title="TypeScript LSP Server",
|
||||
category=CheckCategory.TOOLS,
|
||||
status=CheckStatus.PASS,
|
||||
message="TypeScript LSP server available",
|
||||
details={"command": "typescript-language-server"}
|
||||
metadata={"command": "typescript-language-server"}
|
||||
)
|
||||
elif shutil.which("tsserver"):
|
||||
return CheckResult(
|
||||
id="lsp_server_typescript",
|
||||
title="TypeScript LSP Server",
|
||||
category=CheckCategory.TOOLS,
|
||||
status=CheckStatus.PASS,
|
||||
message="TypeScript server available (tsserver)",
|
||||
details={"command": "tsserver"}
|
||||
metadata={"command": "tsserver"}
|
||||
)
|
||||
else:
|
||||
return CheckResult(
|
||||
status=CheckStatus.INFO,
|
||||
id="lsp_server_typescript",
|
||||
title="TypeScript LSP Server",
|
||||
category=CheckCategory.TOOLS,
|
||||
status=CheckStatus.SKIP,
|
||||
message="TypeScript LSP server not found (optional)",
|
||||
details={
|
||||
"hint": "Install with: npm install -g typescript-language-server typescript"
|
||||
}
|
||||
remediation="Install with: npm install -g typescript-language-server typescript"
|
||||
)
|
||||
|
||||
|
||||
@@ -152,7 +175,7 @@ def check_lsp_server_typescript() -> CheckResult:
|
||||
description="Check if LSP runtime can start",
|
||||
requires_deep=True
|
||||
)
|
||||
def check_lsp_runtime() -> CheckResult:
|
||||
def check_lsp_runtime(config=None) -> CheckResult:
|
||||
"""Check if LSP runtime can start."""
|
||||
try:
|
||||
from praisonai.cli.features.interactive_runtime import create_runtime
|
||||
@@ -175,22 +198,31 @@ def check_lsp_runtime() -> CheckResult:
|
||||
|
||||
if ready:
|
||||
return CheckResult(
|
||||
id="lsp_runtime",
|
||||
title="LSP Runtime Check",
|
||||
category=CheckCategory.TOOLS,
|
||||
status=CheckStatus.PASS,
|
||||
message="LSP runtime started successfully",
|
||||
details={"ready": True}
|
||||
metadata={"ready": True}
|
||||
)
|
||||
else:
|
||||
return CheckResult(
|
||||
id="lsp_runtime",
|
||||
title="LSP Runtime Check",
|
||||
category=CheckCategory.TOOLS,
|
||||
status=CheckStatus.WARN,
|
||||
message=f"LSP runtime failed to start: {error}",
|
||||
details={"ready": False, "error": error}
|
||||
metadata={"ready": False, "error": str(error)}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return CheckResult(
|
||||
id="lsp_runtime",
|
||||
title="LSP Runtime Check",
|
||||
category=CheckCategory.TOOLS,
|
||||
status=CheckStatus.FAIL,
|
||||
message=f"LSP runtime check failed: {e}",
|
||||
details={"error": str(e)}
|
||||
metadata={"error": str(e)}
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -220,6 +220,7 @@ class WorkerPool:
|
||||
tools = QueueManager.get_tools_for_run(run.run_id)
|
||||
|
||||
# Create agent with session_id for history persistence
|
||||
# Agent automatically restores history from JSON store when session_id is provided
|
||||
# Note: Agent uses 'llm' parameter, not 'model'
|
||||
agent = Agent(
|
||||
name=agent_name,
|
||||
@@ -230,10 +231,12 @@ class WorkerPool:
|
||||
session_id=agent_config.get("session_id") or run.session_id, # Use session for history
|
||||
)
|
||||
|
||||
# Inject chat history from session store for context continuity
|
||||
if run.chat_history:
|
||||
# Legacy: Inject chat history if provided (for backward compatibility)
|
||||
# This is no longer needed as Agent now has built-in JSON persistence
|
||||
# but we keep it for cases where chat_history is explicitly passed
|
||||
if run.chat_history and not agent.chat_history:
|
||||
agent.chat_history = list(run.chat_history)
|
||||
logger.debug(f"Injected {len(run.chat_history)} messages from session history")
|
||||
logger.debug(f"Injected {len(run.chat_history)} messages from run.chat_history (legacy)")
|
||||
|
||||
# Check for cancellation before starting
|
||||
if self.scheduler.is_cancelled(run.run_id):
|
||||
|
||||
@@ -33,7 +33,8 @@ if TEXTUAL_AVAILABLE:
|
||||
from .widgets.status import StatusInfo
|
||||
from .widgets.queue_panel import QueueItem
|
||||
from ..queue import QueueManager, QueueConfig, QueuedRun, RunPriority
|
||||
from .session_store import SessionStore, get_session_store
|
||||
from .session_store import SessionStore
|
||||
from praisonai.cli.session import get_session_store, UnifiedSession
|
||||
|
||||
class TUIApp(App):
|
||||
"""
|
||||
@@ -99,8 +100,9 @@ if TEXTUAL_AVAILABLE:
|
||||
self.queue_config = queue_config or QueueConfig()
|
||||
self.queue_manager: Optional[QueueManager] = None
|
||||
|
||||
# Session store for chat history persistence
|
||||
self.session_store = get_session_store()
|
||||
# Session store for chat history persistence (uses UnifiedSession)
|
||||
self._session_store = get_session_store()
|
||||
self._unified_session = self._session_store.get_or_create(self.session_id)
|
||||
|
||||
# State
|
||||
self._current_run_id: Optional[str] = None
|
||||
|
||||
@@ -1,10 +1,27 @@
|
||||
"""
|
||||
Session Store for PraisonAI TUI.
|
||||
|
||||
DEPRECATED: This module is deprecated in favor of the core SDK session store.
|
||||
Use `praisonaiagents.session.DefaultSessionStore` instead, which provides:
|
||||
- JSON-based persistence (survives restarts)
|
||||
- File locking for multi-process safety
|
||||
- Automatic integration with Agent class
|
||||
|
||||
This TUI-specific in-memory store is kept for backward compatibility only.
|
||||
New code should use the core SDK session store.
|
||||
|
||||
Example:
|
||||
from praisonaiagents.session import get_default_session_store
|
||||
store = get_default_session_store()
|
||||
store.add_user_message("session-id", "Hello")
|
||||
history = store.get_chat_history("session-id")
|
||||
|
||||
Maintains chat history in memory for session persistence.
|
||||
This allows the AI to remember previous messages within a session.
|
||||
"""
|
||||
|
||||
import warnings
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
@@ -25,9 +25,15 @@ LEGACY_COMMANDS = {
|
||||
|
||||
# Typer commands that have been implemented
|
||||
TYPER_COMMANDS = {
|
||||
# Core commands
|
||||
'config', 'traces', 'env', 'session', 'completion', 'version',
|
||||
'debug', 'lsp', 'diag', 'doctor', 'acp', 'mcp', 'serve', 'schedule', 'run',
|
||||
'tui', 'queue', 'profile', # TUI, queue management, and profiling commands
|
||||
'tui', 'queue', 'profile', 'benchmark',
|
||||
# Previously legacy-only commands (now in Typer)
|
||||
'chat', 'code', 'call', 'realtime', 'train', 'ui', 'context', 'research',
|
||||
'memory', 'workflow', 'tools', 'knowledge', 'deploy', 'agents', 'skills',
|
||||
'eval', 'templates', 'recipe', 'todo', 'docs', 'commit', 'hooks', 'rules',
|
||||
'registry', 'package', 'endpoints',
|
||||
}
|
||||
|
||||
|
||||
|
||||
+580
-153
@@ -64,8 +64,19 @@ import importlib
|
||||
# Lazy imports for performance - these are imported when needed, not at module load
|
||||
# from praisonai.auto import AutoGenerator # Lazy: imported in auto/init commands
|
||||
# from praisonai.agents_generator import AgentsGenerator # Lazy: imported when running agents
|
||||
from praisonai.inbuilt_tools import *
|
||||
from praisonai.inc.config import generate_config
|
||||
# REMOVED: from praisonai.inbuilt_tools import * - causes ~3200ms crewai import
|
||||
# REMOVED: from praisonai.inc.config import generate_config - causes ~3500ms langchain import
|
||||
|
||||
# Lazy import helpers for inbuilt_tools and config
|
||||
def _get_inbuilt_tools():
|
||||
"""Lazy import inbuilt_tools only when crewai/autogen features are used."""
|
||||
from praisonai import inbuilt_tools
|
||||
return inbuilt_tools
|
||||
|
||||
def _get_generate_config():
|
||||
"""Lazy import generate_config only when training features are used."""
|
||||
from praisonai.inc.config import generate_config
|
||||
return generate_config
|
||||
|
||||
# Lazy import helpers for heavy modules
|
||||
def _get_auto_generator():
|
||||
@@ -116,7 +127,47 @@ AUTOGEN_AVAILABLE = importlib.util.find_spec("autogen") is not None
|
||||
PRAISONAI_AVAILABLE = importlib.util.find_spec("praisonaiagents") is not None
|
||||
TRAIN_AVAILABLE = importlib.util.find_spec("unsloth") is not None
|
||||
|
||||
logging.basicConfig(level=os.environ.get('LOGLEVEL', 'WARNING'), format='%(asctime)s - %(levelname)s - %(message)s')
|
||||
# Lazy import helpers for optional dependencies (defined after availability flags)
|
||||
def _get_call_module():
|
||||
"""Lazy import call module only when call feature is used.
|
||||
|
||||
Raises:
|
||||
ImportError: If praisonai.api.call is not installed
|
||||
"""
|
||||
if not CALL_MODULE_AVAILABLE:
|
||||
raise ImportError(
|
||||
"Call feature is not installed. Install with: pip install \"praisonai[call]\""
|
||||
)
|
||||
from praisonai.api import call as call_module
|
||||
return call_module
|
||||
|
||||
def _get_gradio():
|
||||
"""Lazy import gradio only when gradio UI is used.
|
||||
|
||||
Raises:
|
||||
ImportError: If gradio is not installed
|
||||
"""
|
||||
if not GRADIO_AVAILABLE:
|
||||
raise ImportError(
|
||||
"Gradio is not installed. Install with: pip install gradio"
|
||||
)
|
||||
import gradio as gr
|
||||
return gr
|
||||
|
||||
def _get_autogen():
|
||||
"""Lazy import autogen only when autogen framework is used.
|
||||
|
||||
Raises:
|
||||
ImportError: If autogen is not installed
|
||||
"""
|
||||
if not AUTOGEN_AVAILABLE:
|
||||
raise ImportError(
|
||||
"AutoGen is not installed. Install with: pip install \"praisonai[autogen]\""
|
||||
)
|
||||
import autogen
|
||||
return autogen
|
||||
|
||||
logging.basicConfig(level=os.environ.get('LOGLEVEL', 'WARNING') or 'WARNING', format='%(asctime)s - %(levelname)s - %(message)s')
|
||||
logging.getLogger('alembic').setLevel(logging.ERROR)
|
||||
logging.getLogger('gradio').setLevel(logging.ERROR)
|
||||
logging.getLogger('gradio').setLevel(os.environ.get('GRADIO_LOGLEVEL', 'WARNING'))
|
||||
@@ -462,10 +513,14 @@ class PraisonAI:
|
||||
return
|
||||
|
||||
if getattr(args, 'call', False):
|
||||
if not CALL_MODULE_AVAILABLE:
|
||||
print("[red]ERROR: Call feature is not installed. Install with:[/red]")
|
||||
print("\npip install \"praisonai[call]\"\n")
|
||||
return
|
||||
call_args = []
|
||||
if args.public:
|
||||
call_args.append('--public')
|
||||
call_module.main(call_args)
|
||||
_get_call_module().main(call_args)
|
||||
return
|
||||
|
||||
if args.command == 'train':
|
||||
@@ -478,7 +533,7 @@ class PraisonAI:
|
||||
config_yaml_destination = os.path.join(os.getcwd(), 'config.yaml')
|
||||
|
||||
if not os.path.exists(config_yaml_destination):
|
||||
config = generate_config(
|
||||
config = _get_generate_config()(
|
||||
model_name=args.model,
|
||||
hf_model_name=args.hf,
|
||||
ollama_model_name=args.ollama,
|
||||
@@ -489,7 +544,7 @@ class PraisonAI:
|
||||
with open('config.yaml', 'w') as f:
|
||||
yaml.dump(config, f, default_flow_style=False, indent=2)
|
||||
elif args.model or args.hf or args.ollama or (args.dataset and args.dataset != "yahma/alpaca-cleaned"):
|
||||
config = generate_config(
|
||||
config = _get_generate_config()(
|
||||
model_name=args.model,
|
||||
hf_model_name=args.hf,
|
||||
ollama_model_name=args.ollama,
|
||||
@@ -826,6 +881,7 @@ class PraisonAI:
|
||||
# Interactive TUI - terminal interface with slash commands
|
||||
parser.add_argument("--interactive", "-i", action="store_true", help="Start interactive terminal mode with slash commands")
|
||||
parser.add_argument("--chat-mode", "--chat", action="store_true", dest="chat_mode", help="Run single prompt in interactive style (non-interactive, for testing)")
|
||||
parser.add_argument("--resume", type=str, dest="resume_session", metavar="SESSION", help="Resume a session (use 'last' for most recent, or session ID)")
|
||||
|
||||
# Direct prompt flag - alternative to positional command
|
||||
parser.add_argument("-p", "--prompt", type=str, dest="prompt_flag", help="Direct prompt to execute (alternative to positional argument)")
|
||||
@@ -944,7 +1000,7 @@ class PraisonAI:
|
||||
call_args = []
|
||||
if args.public:
|
||||
call_args.append('--public')
|
||||
call_module.main(call_args)
|
||||
_get_call_module().main(call_args)
|
||||
sys.exit(0)
|
||||
|
||||
# Handle special commands
|
||||
@@ -982,7 +1038,7 @@ class PraisonAI:
|
||||
print("[red]ERROR: Call feature is not installed. Install with:[/red]")
|
||||
print("\npip install \"praisonai[call]\"\n")
|
||||
sys.exit(1)
|
||||
call_module.main()
|
||||
_get_call_module().main()
|
||||
sys.exit(0)
|
||||
|
||||
elif args.command == 'realtime':
|
||||
@@ -2115,10 +2171,10 @@ class PraisonAI:
|
||||
|
||||
# Save output if requested
|
||||
if workflow_save and final_results:
|
||||
import datetime
|
||||
from datetime import datetime
|
||||
output_dir = os.path.join(os.getcwd(), "output", "workflows")
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
timestamp = datetime.datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
|
||||
timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
|
||||
safe_name = workflow_name.replace(" ", "_").lower()
|
||||
output_file = os.path.join(output_dir, f"{timestamp}_{safe_name}.md")
|
||||
|
||||
@@ -3433,10 +3489,10 @@ Provide ONLY the commit message, no explanations."""
|
||||
|
||||
# Save if requested
|
||||
if workflow_save:
|
||||
import datetime
|
||||
from datetime import datetime
|
||||
output_dir = os.path.join(os.getcwd(), "output", "workflows")
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
timestamp = datetime.datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
|
||||
timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
|
||||
output_file = os.path.join(output_dir, f"{timestamp}_inline_workflow.md")
|
||||
|
||||
with open(output_file, "w") as f:
|
||||
@@ -3920,6 +3976,8 @@ Now, {final_instruction.lower()}:"""
|
||||
)
|
||||
return crew.kickoff()
|
||||
elif AUTOGEN_AVAILABLE:
|
||||
# Lazy import autogen only when needed
|
||||
autogen = _get_autogen()
|
||||
config_list = self.config_list
|
||||
# Add llm if specified
|
||||
if hasattr(self, 'args') and self.args.llm:
|
||||
@@ -4010,8 +4068,13 @@ Now, {final_instruction.lower()}:"""
|
||||
console = Console()
|
||||
|
||||
# Import callback registration from praisonaiagents
|
||||
# Store in local variable for safe access throughout the function
|
||||
_sync_display_callbacks = None
|
||||
_register_display_callback = None
|
||||
try:
|
||||
from praisonaiagents import register_display_callback, sync_display_callbacks
|
||||
_sync_display_callbacks = sync_display_callbacks
|
||||
_register_display_callback = register_display_callback
|
||||
except ImportError:
|
||||
# Fallback if callbacks not available - just run agent directly
|
||||
try:
|
||||
@@ -4063,8 +4126,8 @@ Now, {final_instruction.lower()}:"""
|
||||
# Tool execution completed, update status
|
||||
status_info['status'] = "Processing result..."
|
||||
|
||||
# Register callback for tool calls
|
||||
register_display_callback('tool_call', tool_call_callback)
|
||||
# Register callback for tool calls (use local variable)
|
||||
_register_display_callback('tool_call', tool_call_callback)
|
||||
|
||||
def build_status_display():
|
||||
"""Build the status display text."""
|
||||
@@ -4183,17 +4246,17 @@ Now, {final_instruction.lower()}:"""
|
||||
time.sleep(0.1)
|
||||
except KeyboardInterrupt:
|
||||
console.print("\n[dim]Interrupted[/dim]")
|
||||
# Unregister callback
|
||||
if 'tool_call' in sync_display_callbacks:
|
||||
del sync_display_callbacks['tool_call']
|
||||
# Unregister callback (use local variable with None check)
|
||||
if _sync_display_callbacks is not None and 'tool_call' in _sync_display_callbacks:
|
||||
del _sync_display_callbacks['tool_call']
|
||||
return None
|
||||
|
||||
# Wait for thread to complete
|
||||
thread.join(timeout=1.0)
|
||||
|
||||
# Unregister callback to avoid memory leaks
|
||||
if 'tool_call' in sync_display_callbacks:
|
||||
del sync_display_callbacks['tool_call']
|
||||
# Unregister callback to avoid memory leaks (use local variable with None check)
|
||||
if _sync_display_callbacks is not None and 'tool_call' in _sync_display_callbacks:
|
||||
del _sync_display_callbacks['tool_call']
|
||||
|
||||
# Handle result
|
||||
if status_info['error']:
|
||||
@@ -4470,6 +4533,9 @@ Now, {final_instruction.lower()}:"""
|
||||
Create a Gradio interface for generating agents and performing tasks.
|
||||
"""
|
||||
if GRADIO_AVAILABLE:
|
||||
# Lazy import gradio only when needed
|
||||
gr = _get_gradio()
|
||||
|
||||
def generate_crew_and_kickoff_interface(auto_args, framework):
|
||||
self.framework = framework
|
||||
self.agent_file = "test.yaml"
|
||||
@@ -4791,9 +4857,10 @@ Now, {final_instruction.lower()}:"""
|
||||
# Import message queue components
|
||||
from praisonai.cli.features.message_queue import (
|
||||
MessageQueue, StateManager, QueueDisplay, ProcessingState,
|
||||
AsyncProcessor, LiveStatusDisplay
|
||||
LiveStatusDisplay
|
||||
)
|
||||
import threading
|
||||
import queue as queue_module
|
||||
|
||||
# Create message queue and state manager
|
||||
message_queue = MessageQueue()
|
||||
@@ -4802,24 +4869,75 @@ Now, {final_instruction.lower()}:"""
|
||||
live_status = LiveStatusDisplay()
|
||||
processing_lock = threading.Lock()
|
||||
|
||||
# Session state
|
||||
# Create execution queue for worker thread (TRUE ASYNC)
|
||||
execution_queue = queue_module.Queue()
|
||||
approval_request_queue = queue_module.Queue()
|
||||
approval_response_queue = queue_module.Queue()
|
||||
|
||||
# Worker state for cross-thread communication
|
||||
# Enhanced with task-bound context for proper Q/A mapping
|
||||
worker_state = {
|
||||
'running': True,
|
||||
'current_task': None, # Full task context object (FIFO head)
|
||||
'approval_pending': False,
|
||||
'waiting_for_approval_input': False,
|
||||
'completed_tasks': [], # Queue of completed tasks to display (FIFO order)
|
||||
'error_tasks': [], # Queue of error tasks to display
|
||||
'tool_activity': None, # Current tool being used
|
||||
'last_status_line': None, # For transient status updates
|
||||
}
|
||||
|
||||
# Task counter for unique IDs (FIFO position tracking)
|
||||
task_counter = {'value': 0}
|
||||
|
||||
# Check for verbose mode
|
||||
verbose_mode = getattr(args, 'verbose', False) if hasattr(args, 'verbose') else False
|
||||
|
||||
# Initialize persistent session
|
||||
from praisonai.cli.session import get_session_store
|
||||
session_store = get_session_store()
|
||||
|
||||
# Check for --resume flag or get/create session
|
||||
resume_session_id = getattr(args, 'resume_session', None)
|
||||
if resume_session_id == 'last':
|
||||
unified_session = session_store.get_last_session()
|
||||
if not unified_session:
|
||||
unified_session = session_store.get_or_create()
|
||||
elif resume_session_id:
|
||||
unified_session = session_store.get_or_create(resume_session_id)
|
||||
else:
|
||||
unified_session = session_store.get_or_create()
|
||||
|
||||
# Set model from args or session
|
||||
current_model = getattr(args, 'llm', None) or unified_session.current_model or os.environ.get('OPENAI_MODEL_NAME', 'gpt-4o-mini')
|
||||
unified_session.current_model = current_model
|
||||
|
||||
# Session state (now backed by persistent UnifiedSession)
|
||||
session_state = {
|
||||
'show_profiling': False,
|
||||
'current_model': getattr(args, 'llm', None) or os.environ.get('OPENAI_MODEL_NAME', 'gpt-4o-mini'),
|
||||
'conversation_history': [],
|
||||
'total_input_tokens': 0,
|
||||
'total_output_tokens': 0,
|
||||
'total_cost': 0.0,
|
||||
'request_count': 0,
|
||||
'current_model': current_model,
|
||||
'conversation_history': unified_session.get_chat_history(),
|
||||
'total_input_tokens': unified_session.total_input_tokens,
|
||||
'total_output_tokens': unified_session.total_output_tokens,
|
||||
'total_cost': unified_session.total_cost,
|
||||
'request_count': unified_session.request_count,
|
||||
'undo_stack': [], # Stack of (prompt, response) for undo
|
||||
'message_queue': message_queue,
|
||||
'state_manager': state_manager,
|
||||
'queue_display': queue_display,
|
||||
'live_status': live_status,
|
||||
'processing_lock': processing_lock,
|
||||
'async_processor': None, # Will be created per-request
|
||||
'execution_queue': execution_queue, # Queue for worker thread
|
||||
'approval_request_queue': approval_request_queue,
|
||||
'approval_response_queue': approval_response_queue,
|
||||
'worker_state': worker_state,
|
||||
'unified_session': unified_session, # Reference to persistent session
|
||||
'session_store': session_store, # Reference to store for saving
|
||||
}
|
||||
|
||||
# Start the execution worker thread (TRUE ASYNC - runs in background)
|
||||
worker_thread = self._start_execution_worker(tools_list, console, session_state)
|
||||
|
||||
# Try to use prompt_toolkit for autocomplete
|
||||
prompt_session = None
|
||||
try:
|
||||
@@ -4848,13 +4966,160 @@ Now, {final_instruction.lower()}:"""
|
||||
# Print welcome message
|
||||
console.print("\n[bold cyan]PraisonAI Interactive Mode[/bold cyan]")
|
||||
console.print("[dim]Type your prompt, use /help for commands, /exit to quit[/dim]")
|
||||
console.print(f"[dim]Model: {session_state['current_model']} | Tools: {len(tools_list)}[/dim]")
|
||||
console.print(f"[dim]Model: {session_state['current_model']} | Tools: {len(tools_list)} | Session: {unified_session.session_id}[/dim]")
|
||||
if unified_session.message_count > 0:
|
||||
console.print(f"[dim]Resumed session with {unified_session.message_count} messages[/dim]")
|
||||
console.print("[dim]Use @file.txt to include file content | Queue messages while processing[/dim]\n")
|
||||
|
||||
# Start display thread for status updates (non-blocking)
|
||||
import time
|
||||
from rich.panel import Panel
|
||||
|
||||
display_running = {'value': True}
|
||||
|
||||
def display_loop():
|
||||
"""Background thread for status display - NEVER blocks input.
|
||||
|
||||
FIFO-aligned display:
|
||||
- Default mode: Minimal, calm output. Just show response.
|
||||
- Verbose mode: Full task lifecycle with IDs, times, word counts.
|
||||
"""
|
||||
while display_running['value']:
|
||||
try:
|
||||
# Check for completed tasks to display (FIFO order guaranteed)
|
||||
if worker_state['completed_tasks']:
|
||||
task = worker_state['completed_tasks'].pop(0)
|
||||
response = task.get('response', '')
|
||||
|
||||
# Clear any transient status line
|
||||
if worker_state.get('last_status_line'):
|
||||
console.print("\r" + " " * 80 + "\r", end="")
|
||||
worker_state['last_status_line'] = None
|
||||
|
||||
console.print() # New line before response
|
||||
|
||||
if verbose_mode:
|
||||
# VERBOSE: Full task lifecycle with metadata
|
||||
question = task.get('question', '')
|
||||
task_id = task.get('task_id', 0)
|
||||
elapsed = task.get('elapsed', 0)
|
||||
word_count = len(response.split()) if response else 0
|
||||
q_display = question[:80] + "..." if len(question) > 80 else question
|
||||
|
||||
console.print(f"[bold cyan]─── Task #{task_id} completed ({elapsed:.1f}s, {word_count} words) ───[/bold cyan]")
|
||||
console.print(f"[bold green]Q:[/bold green] {q_display}")
|
||||
console.print(f"[bold blue]A:[/bold blue] ", end="")
|
||||
|
||||
# Stream response (both modes)
|
||||
if response:
|
||||
words = response.split()
|
||||
for i, word in enumerate(words):
|
||||
console.print(word + " ", end="")
|
||||
if i % 20 == 19:
|
||||
time.sleep(0.003)
|
||||
console.print()
|
||||
|
||||
if verbose_mode:
|
||||
task_id = task.get('task_id', 0)
|
||||
console.print(f"[dim]─── End Task #{task_id} ───[/dim]")
|
||||
console.print()
|
||||
|
||||
# Check for error tasks to display
|
||||
if worker_state['error_tasks']:
|
||||
task = worker_state['error_tasks'].pop(0)
|
||||
error = task.get('error', '')
|
||||
|
||||
# Clear transient status
|
||||
if worker_state.get('last_status_line'):
|
||||
console.print("\r" + " " * 80 + "\r", end="")
|
||||
worker_state['last_status_line'] = None
|
||||
|
||||
console.print()
|
||||
if verbose_mode:
|
||||
question = task.get('question', '')
|
||||
task_id = task.get('task_id', 0)
|
||||
q_display = question[:80] + "..." if len(question) > 80 else question
|
||||
console.print(f"[bold red]─── Task #{task_id} failed ───[/bold red]")
|
||||
console.print(f"[bold green]Q:[/bold green] {q_display}")
|
||||
console.print(f"[red]Error: {error}[/red]")
|
||||
console.print()
|
||||
|
||||
# Check for approval requests
|
||||
try:
|
||||
approval_info = approval_request_queue.get_nowait()
|
||||
|
||||
function_name = approval_info['function_name']
|
||||
arguments = approval_info['arguments']
|
||||
risk_level = approval_info['risk_level']
|
||||
|
||||
risk_colors = {"critical": "bold red", "high": "red", "medium": "yellow", "low": "blue"}
|
||||
risk_color = risk_colors.get(risk_level, "white")
|
||||
|
||||
# Clear transient status
|
||||
if worker_state.get('last_status_line'):
|
||||
console.print("\r" + " " * 80 + "\r", end="")
|
||||
worker_state['last_status_line'] = None
|
||||
|
||||
console.print()
|
||||
|
||||
# Show approval panel (both modes)
|
||||
tool_info = f"[bold]Function:[/] {function_name}\n"
|
||||
tool_info += f"[bold]Risk Level:[/] [{risk_color}]{risk_level.upper()}[/{risk_color}]\n"
|
||||
tool_info += "[bold]Arguments:[/]\n"
|
||||
for key, value in arguments.items():
|
||||
str_value = str(value)[:100] + "..." if len(str(value)) > 100 else str(value)
|
||||
tool_info += f" {key}: {str_value}\n"
|
||||
|
||||
console.print(Panel(tool_info.strip(), title="🔒 Tool Approval Required", border_style=risk_color))
|
||||
console.print(f"[{risk_color}]Type 'y' to approve, 'n' to reject:[/{risk_color}] ", end="")
|
||||
|
||||
worker_state['waiting_for_approval_input'] = True
|
||||
|
||||
except queue_module.Empty:
|
||||
pass
|
||||
|
||||
# Show FIFO head status (transient, both modes)
|
||||
current_task = worker_state.get('current_task')
|
||||
if current_task and not worker_state.get('waiting_for_approval_input'):
|
||||
queue_size = execution_queue.qsize()
|
||||
prompt_preview = current_task.get('question', '')[:50]
|
||||
if len(current_task.get('question', '')) > 50:
|
||||
prompt_preview += "..."
|
||||
|
||||
# Build status line
|
||||
tool_activity = worker_state.get('tool_activity')
|
||||
if tool_activity and tool_activity.get('status') == 'started':
|
||||
tool_name = tool_activity.get('name', '')
|
||||
status_line = f"⚙️ {tool_name}"
|
||||
else:
|
||||
status_line = f"⏳ Processing: {prompt_preview}"
|
||||
|
||||
if queue_size > 0:
|
||||
status_line += f" | 📋 {queue_size} waiting"
|
||||
|
||||
# Only update if changed (avoid flicker)
|
||||
if status_line != worker_state.get('last_status_line'):
|
||||
console.print(f"\r[dim cyan]{status_line}[/dim cyan]" + " " * 20, end="\r")
|
||||
worker_state['last_status_line'] = status_line
|
||||
elif not current_task and worker_state.get('last_status_line'):
|
||||
# Clear status when idle
|
||||
console.print("\r" + " " * 80 + "\r", end="")
|
||||
worker_state['last_status_line'] = None
|
||||
|
||||
time.sleep(0.1)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Start display thread
|
||||
display_thread = threading.Thread(target=display_loop, daemon=True, name="DisplayLoop")
|
||||
display_thread.start()
|
||||
|
||||
running = True
|
||||
while running:
|
||||
try:
|
||||
# Get user input (with autocomplete if available)
|
||||
# This is NON-BLOCKING relative to LLM execution
|
||||
# Status is shown by the display thread, not here
|
||||
if prompt_session:
|
||||
user_input = prompt_session.prompt().strip()
|
||||
else:
|
||||
@@ -4863,6 +5128,21 @@ Now, {final_instruction.lower()}:"""
|
||||
if not user_input:
|
||||
continue
|
||||
|
||||
# Check if this is an approval response
|
||||
if worker_state.get('waiting_for_approval_input'):
|
||||
try:
|
||||
from praisonaiagents.approval import ApprovalDecision
|
||||
if user_input.lower() in ['y', 'yes', 'approve']:
|
||||
console.print("[green]✅ Approved[/green]")
|
||||
approval_response_queue.put(ApprovalDecision(approved=True, reason="User approved"))
|
||||
else:
|
||||
console.print("[red]❌ Denied[/red]")
|
||||
approval_response_queue.put(ApprovalDecision(approved=False, reason="User denied"))
|
||||
worker_state['waiting_for_approval_input'] = False
|
||||
continue
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Handle slash commands
|
||||
if user_input.startswith("/"):
|
||||
cmd_parts = user_input[1:].split(maxsplit=1)
|
||||
@@ -4872,6 +5152,8 @@ Now, {final_instruction.lower()}:"""
|
||||
if cmd in ["exit", "quit", "q"]:
|
||||
console.print("[dim]Goodbye![/dim]")
|
||||
running = False
|
||||
worker_state['running'] = False
|
||||
display_running['value'] = False
|
||||
continue
|
||||
elif cmd == "help":
|
||||
self._print_interactive_help(console)
|
||||
@@ -4905,6 +5187,54 @@ Now, {final_instruction.lower()}:"""
|
||||
elif cmd == "queue":
|
||||
self._handle_queue_command(console, cmd_args, session_state)
|
||||
continue
|
||||
elif cmd == "session":
|
||||
# Show session info
|
||||
us = session_state.get('unified_session')
|
||||
if us:
|
||||
console.print(f"[cyan]Session ID:[/cyan] {us.session_id}")
|
||||
console.print(f"[cyan]Messages:[/cyan] {us.message_count}")
|
||||
console.print(f"[cyan]Created:[/cyan] {us.created_at}")
|
||||
console.print(f"[cyan]Updated:[/cyan] {us.updated_at}")
|
||||
console.print(f"[cyan]Total tokens:[/cyan] {us.total_input_tokens + us.total_output_tokens}")
|
||||
continue
|
||||
elif cmd == "history":
|
||||
# Show conversation history
|
||||
us = session_state.get('unified_session')
|
||||
if us and us.messages:
|
||||
console.print(f"[cyan]Conversation history ({len(us.messages)} messages):[/cyan]")
|
||||
for i, msg in enumerate(us.messages[-10:]): # Show last 10
|
||||
role = msg.get('role', 'unknown')
|
||||
content = msg.get('content', '')[:100]
|
||||
if len(msg.get('content', '')) > 100:
|
||||
content += "..."
|
||||
style = "green" if role == "user" else "blue"
|
||||
console.print(f" [{style}]{role}:[/{style}] {content}")
|
||||
else:
|
||||
console.print("[dim]No conversation history[/dim]")
|
||||
continue
|
||||
elif cmd == "new":
|
||||
# Start a new session
|
||||
store = session_state.get('session_store')
|
||||
if store:
|
||||
new_session = store.get_or_create()
|
||||
session_state['unified_session'] = new_session
|
||||
session_state['conversation_history'] = []
|
||||
session_state['total_input_tokens'] = 0
|
||||
session_state['total_output_tokens'] = 0
|
||||
session_state['request_count'] = 0
|
||||
console.print(f"[cyan]Started new session: {new_session.session_id}[/cyan]")
|
||||
continue
|
||||
elif cmd == "status":
|
||||
# Show current processing status
|
||||
current = worker_state.get('current_prompt')
|
||||
queue_size = execution_queue.qsize()
|
||||
if current:
|
||||
console.print(f"[cyan]Processing:[/cyan] {current}")
|
||||
if queue_size > 0:
|
||||
console.print(f"[cyan]Queued:[/cyan] {queue_size} messages")
|
||||
if not current and queue_size == 0:
|
||||
console.print("[dim]Idle - no messages processing[/dim]")
|
||||
continue
|
||||
else:
|
||||
console.print(f"[yellow]Unknown command: /{cmd}. Type /help for available commands.[/yellow]")
|
||||
continue
|
||||
@@ -4912,20 +5242,35 @@ Now, {final_instruction.lower()}:"""
|
||||
# Process @file mentions before sending to LLM
|
||||
processed_input = self._process_at_mentions(user_input, console)
|
||||
|
||||
# Check if already processing - queue if so
|
||||
if state_manager.is_processing:
|
||||
message_queue.add(processed_input)
|
||||
queue_count = message_queue.count
|
||||
console.print(f"[dim]📋 Queued ({queue_count}) - processing will continue in order[/dim]")
|
||||
continue
|
||||
# Create task with unique ID and full context
|
||||
task_counter['value'] += 1
|
||||
task_id = task_counter['value']
|
||||
|
||||
# Start async processing
|
||||
self._process_interactive_prompt_async(
|
||||
processed_input,
|
||||
tools_list,
|
||||
console,
|
||||
session_state=session_state
|
||||
)
|
||||
task = {
|
||||
'task_id': task_id,
|
||||
'prompt': processed_input,
|
||||
'question': processed_input, # Full question for Q/A mapping
|
||||
'status': 'queued',
|
||||
'queued_at': time.time(),
|
||||
}
|
||||
|
||||
# Submit to execution queue - THIS IS NON-BLOCKING (FIFO)
|
||||
queue_size = execution_queue.qsize()
|
||||
execution_queue.put(task)
|
||||
|
||||
# Show queue status (minimal by default, verbose shows task IDs)
|
||||
if verbose_mode:
|
||||
q_preview = processed_input[:60] + "..." if len(processed_input) > 60 else processed_input
|
||||
if queue_size > 0:
|
||||
console.print(f"[dim cyan]📋 Task #{task_id} queued (FIFO position {queue_size + 1})[/dim cyan]")
|
||||
console.print(f"[dim] └─ {q_preview}[/dim]")
|
||||
else:
|
||||
console.print(f"[dim cyan]▶ Task #{task_id} started (FIFO head)[/dim cyan]")
|
||||
console.print(f"[dim] └─ {q_preview}[/dim]")
|
||||
else:
|
||||
# DEFAULT: Minimal, calm output
|
||||
if queue_size > 0:
|
||||
console.print(f"[dim]📋 Queued ({queue_size + 1} in queue)[/dim]")
|
||||
|
||||
except KeyboardInterrupt:
|
||||
console.print("\n[dim]Use /exit to quit[/dim]")
|
||||
@@ -5048,6 +5393,10 @@ Now, {final_instruction.lower()}:"""
|
||||
console.print(" /undo - Undo last response")
|
||||
console.print(" /queue - Show queued messages")
|
||||
console.print(" /queue clear - Clear message queue")
|
||||
console.print("\n[bold]Session Commands:[/bold]")
|
||||
console.print(" /session - Show current session info")
|
||||
console.print(" /history - Show conversation history")
|
||||
console.print(" /new - Start a new session")
|
||||
console.print("\n[bold]@ Mentions:[/bold]")
|
||||
console.print(" @file.txt - Include file content in prompt")
|
||||
console.print(" @src/ - Include directory listing")
|
||||
@@ -5056,6 +5405,7 @@ Now, {final_instruction.lower()}:"""
|
||||
console.print(" • Shell command execution")
|
||||
console.print(" • Web search")
|
||||
console.print(" • Context compression for long sessions")
|
||||
console.print(" • Persistent sessions (auto-saved)")
|
||||
console.print(" • Queue messages while agent is processing")
|
||||
console.print("")
|
||||
|
||||
@@ -5363,132 +5713,201 @@ Provide a concise summary (max 200 words):"""
|
||||
|
||||
return None
|
||||
|
||||
def _process_interactive_prompt_async(self, prompt, tools_list, console, session_state=None):
|
||||
def _start_execution_worker(self, tools_list, console, session_state):
|
||||
"""
|
||||
Process a prompt asynchronously with non-blocking status display.
|
||||
Start the background execution worker thread.
|
||||
|
||||
Shows status updates without blocking user input.
|
||||
Allows user to queue more messages while processing.
|
||||
This worker continuously processes messages from the queue,
|
||||
allowing the main input loop to remain non-blocking.
|
||||
"""
|
||||
import threading
|
||||
import time
|
||||
import sys
|
||||
import queue as queue_module
|
||||
from rich.panel import Panel
|
||||
from praisonai.cli.features.message_queue import ProcessingState
|
||||
|
||||
state_manager = session_state['state_manager']
|
||||
message_queue = session_state['message_queue']
|
||||
live_status = session_state['live_status']
|
||||
execution_queue = session_state['execution_queue']
|
||||
approval_request_queue = session_state['approval_request_queue']
|
||||
approval_response_queue = session_state['approval_response_queue']
|
||||
worker_state = session_state['worker_state']
|
||||
|
||||
# Set processing state
|
||||
state_manager.set_state(ProcessingState.PROCESSING)
|
||||
live_status.clear()
|
||||
live_status.update_status("Thinking...")
|
||||
# Check if trust mode is enabled
|
||||
trust_mode = getattr(self.args, 'trust', False) if hasattr(self, 'args') else False
|
||||
|
||||
# Print initial status (non-blocking - user can still type)
|
||||
console.print(f"[dim cyan]⏳ {live_status.current_status}[/dim cyan]")
|
||||
|
||||
# Result container for thread communication
|
||||
result_container = {'response': None, 'error': None, 'done': False, 'last_status': ''}
|
||||
|
||||
def process_in_background():
|
||||
"""Run agent processing in background thread."""
|
||||
try:
|
||||
from praisonaiagents import Agent
|
||||
import logging
|
||||
import warnings
|
||||
def worker_loop():
|
||||
"""Main worker loop - processes execution queue."""
|
||||
while worker_state['running']:
|
||||
try:
|
||||
# Wait for a message with timeout (allows checking running flag)
|
||||
try:
|
||||
task = execution_queue.get(timeout=0.5)
|
||||
except queue_module.Empty:
|
||||
continue
|
||||
|
||||
# Extract task context
|
||||
prompt = task.get('prompt', '')
|
||||
task_id = task.get('task_id', 0)
|
||||
question = task.get('question', prompt)
|
||||
start_time = time.time()
|
||||
|
||||
# Set processing state with full task context
|
||||
state_manager.set_state(ProcessingState.PROCESSING)
|
||||
task['status'] = 'running'
|
||||
task['start_time'] = start_time
|
||||
worker_state['current_task'] = task # Full task context
|
||||
live_status.clear()
|
||||
live_status.update_status(f"Task #{task_id}: Thinking...")
|
||||
|
||||
try:
|
||||
from praisonaiagents import Agent
|
||||
import logging
|
||||
import warnings
|
||||
|
||||
# Suppress noisy loggers
|
||||
for logger_name in ["httpx", "httpcore", "duckduckgo_search", "crawl4ai"]:
|
||||
logging.getLogger(logger_name).setLevel(logging.WARNING)
|
||||
|
||||
# Set up approval callback
|
||||
try:
|
||||
from praisonaiagents.approval import set_approval_callback, ApprovalDecision
|
||||
|
||||
if trust_mode:
|
||||
def auto_approve_all(function_name, arguments, risk_level):
|
||||
return ApprovalDecision(approved=True, reason="Auto-approved via --trust flag")
|
||||
set_approval_callback(auto_approve_all)
|
||||
else:
|
||||
def interactive_approval_callback(function_name, arguments, risk_level):
|
||||
"""Request approval from main thread via queue."""
|
||||
approval_request_queue.put({
|
||||
'function_name': function_name,
|
||||
'arguments': arguments,
|
||||
'risk_level': risk_level
|
||||
})
|
||||
worker_state['approval_pending'] = True
|
||||
|
||||
try:
|
||||
response = approval_response_queue.get(timeout=120)
|
||||
worker_state['approval_pending'] = False
|
||||
return response
|
||||
except queue_module.Empty:
|
||||
worker_state['approval_pending'] = False
|
||||
return ApprovalDecision(approved=False, reason="Approval timeout")
|
||||
|
||||
set_approval_callback(interactive_approval_callback)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
with warnings.catch_warnings():
|
||||
warnings.filterwarnings("ignore")
|
||||
|
||||
model = session_state.get('current_model')
|
||||
conversation_history = session_state.get('conversation_history', [])
|
||||
|
||||
live_status.update_status("Creating agent...")
|
||||
|
||||
# Build backstory with context
|
||||
backstory = "You are a helpful AI assistant with access to tools for file operations, code intelligence, and shell commands."
|
||||
if conversation_history:
|
||||
recent = conversation_history[-10:]
|
||||
context_lines = []
|
||||
for msg in recent:
|
||||
role = msg.get('role', 'unknown')
|
||||
content = msg.get('content', '')[:200]
|
||||
context_lines.append(f"{role}: {content}")
|
||||
if context_lines:
|
||||
backstory += "\n\nRecent conversation context:\n" + "\n".join(context_lines)
|
||||
|
||||
agent = Agent(
|
||||
name="Assistant",
|
||||
role="Helpful AI Assistant",
|
||||
goal="Help the user with their tasks",
|
||||
backstory=backstory,
|
||||
tools=tools_list if tools_list else None,
|
||||
verbose=False,
|
||||
llm=model
|
||||
)
|
||||
|
||||
live_status.update_status(f"Task #{task_id}: Calling LLM...")
|
||||
|
||||
response = agent.chat(prompt, stream=False)
|
||||
response_str = str(response) if response else ""
|
||||
|
||||
# Calculate elapsed time
|
||||
elapsed = time.time() - start_time
|
||||
|
||||
# Store completed task for display with Q/A mapping
|
||||
completed_task = {
|
||||
'task_id': task_id,
|
||||
'question': task.get('question', prompt),
|
||||
'response': response_str,
|
||||
'elapsed': elapsed,
|
||||
'status': 'completed',
|
||||
}
|
||||
worker_state['completed_tasks'].append(completed_task)
|
||||
|
||||
# Update session state
|
||||
input_tokens = len(prompt) // 4
|
||||
output_tokens = len(response_str) // 4
|
||||
session_state['total_input_tokens'] += input_tokens
|
||||
session_state['total_output_tokens'] += output_tokens
|
||||
session_state['request_count'] += 1
|
||||
session_state['conversation_history'].append({'role': 'user', 'content': prompt})
|
||||
session_state['conversation_history'].append({'role': 'assistant', 'content': response_str})
|
||||
|
||||
# Persist to UnifiedSession
|
||||
if 'unified_session' in session_state and 'session_store' in session_state:
|
||||
unified_session = session_state['unified_session']
|
||||
unified_session.add_user_message(prompt)
|
||||
unified_session.add_assistant_message(response_str)
|
||||
unified_session.update_stats(input_tokens, output_tokens)
|
||||
session_state['session_store'].save(unified_session)
|
||||
|
||||
except Exception as e:
|
||||
# Store error task for display with Q/A mapping
|
||||
error_task = {
|
||||
'task_id': task_id,
|
||||
'question': task.get('question', prompt),
|
||||
'error': str(e),
|
||||
'status': 'failed',
|
||||
}
|
||||
worker_state['error_tasks'].append(error_task)
|
||||
|
||||
finally:
|
||||
worker_state['current_task'] = None
|
||||
worker_state['tool_activity'] = None
|
||||
state_manager.set_state(ProcessingState.IDLE)
|
||||
live_status.clear()
|
||||
execution_queue.task_done()
|
||||
|
||||
# Suppress noisy loggers
|
||||
for logger_name in ["httpx", "httpcore", "duckduckgo_search", "crawl4ai"]:
|
||||
logging.getLogger(logger_name).setLevel(logging.WARNING)
|
||||
|
||||
with warnings.catch_warnings():
|
||||
warnings.filterwarnings("ignore")
|
||||
|
||||
model = session_state.get('current_model')
|
||||
|
||||
live_status.update_status("Creating agent...")
|
||||
|
||||
# Create agent with tools
|
||||
agent = Agent(
|
||||
name="Assistant",
|
||||
role="Helpful AI Assistant",
|
||||
goal="Help the user with their tasks",
|
||||
backstory="You are a helpful AI assistant with access to tools.",
|
||||
tools=tools_list if tools_list else None,
|
||||
verbose=False,
|
||||
llm=model
|
||||
)
|
||||
|
||||
live_status.update_status("Calling LLM...")
|
||||
|
||||
# Get response
|
||||
response = agent.chat(prompt, stream=False)
|
||||
result_container['response'] = str(response) if response else ""
|
||||
|
||||
except Exception as e:
|
||||
result_container['error'] = e
|
||||
finally:
|
||||
result_container['done'] = True
|
||||
except Exception as e:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
# Start background thread
|
||||
bg_thread = threading.Thread(target=process_in_background, daemon=True)
|
||||
bg_thread.start()
|
||||
# Start worker thread
|
||||
worker_thread = threading.Thread(target=worker_loop, daemon=True, name="ExecutionWorker")
|
||||
worker_thread.start()
|
||||
return worker_thread
|
||||
|
||||
def _submit_prompt_to_worker(self, prompt, session_state):
|
||||
"""
|
||||
Submit a prompt to the execution worker queue.
|
||||
|
||||
# Wait for completion with periodic status updates (non-blocking style)
|
||||
start_time = time.time()
|
||||
last_status_update = ""
|
||||
while not result_container['done']:
|
||||
time.sleep(0.2)
|
||||
current_status = live_status.current_status
|
||||
# Only print status if it changed
|
||||
if current_status != last_status_update:
|
||||
# Use carriage return to update in place
|
||||
elapsed = time.time() - start_time
|
||||
queue_count = message_queue.count
|
||||
queue_info = f" | 📋 Queued: {queue_count}" if queue_count > 0 else ""
|
||||
sys.stdout.write(f"\r[dim cyan]⏳ {current_status} ({elapsed:.1f}s){queue_info}[/dim cyan]")
|
||||
sys.stdout.flush()
|
||||
last_status_update = current_status
|
||||
|
||||
# Clear the status line
|
||||
sys.stdout.write("\r" + " " * 80 + "\r")
|
||||
sys.stdout.flush()
|
||||
|
||||
# Wait for thread to finish
|
||||
bg_thread.join(timeout=1.0)
|
||||
|
||||
# Handle result
|
||||
if result_container['error']:
|
||||
console.print(f"[red]Error: {result_container['error']}[/red]")
|
||||
elif result_container['response']:
|
||||
# Print response with streaming effect
|
||||
response_str = result_container['response']
|
||||
words = response_str.split()
|
||||
for i, word in enumerate(words):
|
||||
console.print(word + " ", end="")
|
||||
sys.stdout.flush()
|
||||
if i % 15 == 14:
|
||||
time.sleep(0.005)
|
||||
console.print()
|
||||
|
||||
# Update session state
|
||||
input_tokens = len(prompt) // 4
|
||||
output_tokens = len(response_str) // 4
|
||||
session_state['total_input_tokens'] += input_tokens
|
||||
session_state['total_output_tokens'] += output_tokens
|
||||
session_state['request_count'] += 1
|
||||
session_state['conversation_history'].append({'role': 'user', 'content': prompt})
|
||||
session_state['conversation_history'].append({'role': 'assistant', 'content': response_str})
|
||||
|
||||
# Set state back to idle
|
||||
state_manager.set_state(ProcessingState.IDLE)
|
||||
live_status.clear()
|
||||
|
||||
# Process next queued message if any
|
||||
next_msg = message_queue.pop()
|
||||
if next_msg:
|
||||
console.print(f"\n[dim cyan]Processing queued message...[/dim cyan]")
|
||||
self._process_interactive_prompt_async(next_msg, tools_list, console, session_state)
|
||||
This is NON-BLOCKING - returns immediately after queuing.
|
||||
"""
|
||||
execution_queue = session_state['execution_queue']
|
||||
execution_queue.put({'prompt': prompt})
|
||||
|
||||
def _process_interactive_prompt_async(self, prompt, tools_list, console, session_state=None):
|
||||
"""
|
||||
DEPRECATED: This method is kept for backward compatibility.
|
||||
Use _submit_prompt_to_worker instead for true non-blocking behavior.
|
||||
"""
|
||||
# Just submit to worker queue - non-blocking
|
||||
self._submit_prompt_to_worker(prompt, session_state)
|
||||
|
||||
def _process_interactive_prompt(self, prompt, tools_list, console, show_profiling=False, session_state=None):
|
||||
"""Process a prompt in interactive mode with streaming."""
|
||||
@@ -5592,6 +6011,14 @@ Provide a concise summary (max 200 words):"""
|
||||
'role': 'assistant',
|
||||
'content': response_str
|
||||
})
|
||||
|
||||
# Persist to UnifiedSession
|
||||
if 'unified_session' in session_state and 'session_store' in session_state:
|
||||
unified_session = session_state['unified_session']
|
||||
unified_session.add_user_message(prompt)
|
||||
unified_session.add_assistant_message(response_str)
|
||||
unified_session.update_stats(input_tokens, output_tokens)
|
||||
session_state['session_store'].save(unified_session)
|
||||
|
||||
# Show profiling if enabled
|
||||
if show_profiling:
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
"""
|
||||
Unified Session Management for PraisonAI CLI.
|
||||
|
||||
Provides persistent session storage shared between TUI and --interactive mode.
|
||||
"""
|
||||
|
||||
from .unified import UnifiedSession, UnifiedSessionStore, get_session_store
|
||||
|
||||
__all__ = [
|
||||
"UnifiedSession",
|
||||
"UnifiedSessionStore",
|
||||
"get_session_store",
|
||||
]
|
||||
@@ -0,0 +1,306 @@
|
||||
"""
|
||||
Unified Session for PraisonAI CLI.
|
||||
|
||||
Provides persistent session storage shared between TUI and --interactive mode.
|
||||
Uses JSON-based persistence with file locking for multi-process safety.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
import fcntl
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Default session directory
|
||||
DEFAULT_SESSION_DIR = Path.home() / ".praison" / "sessions"
|
||||
|
||||
|
||||
@dataclass
|
||||
class UnifiedSession:
|
||||
"""
|
||||
Unified session for TUI and --interactive mode.
|
||||
|
||||
Stores conversation history, metadata, and token/cost stats.
|
||||
Persists to disk automatically.
|
||||
"""
|
||||
session_id: str
|
||||
workspace: str = field(default_factory=os.getcwd)
|
||||
created_at: str = field(default_factory=lambda: datetime.now().isoformat())
|
||||
updated_at: str = field(default_factory=lambda: datetime.now().isoformat())
|
||||
messages: List[Dict[str, str]] = field(default_factory=list)
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
# Token and cost tracking
|
||||
total_input_tokens: int = 0
|
||||
total_output_tokens: int = 0
|
||||
total_cost: float = 0.0
|
||||
request_count: int = 0
|
||||
|
||||
# Model info
|
||||
current_model: str = "gpt-4o-mini"
|
||||
|
||||
def add_message(self, role: str, content: str) -> None:
|
||||
"""Add a message to the session."""
|
||||
self.messages.append({
|
||||
"role": role,
|
||||
"content": content,
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
})
|
||||
self.updated_at = datetime.now().isoformat()
|
||||
|
||||
def add_user_message(self, content: str) -> None:
|
||||
"""Add a user message."""
|
||||
self.add_message("user", content)
|
||||
|
||||
def add_assistant_message(self, content: str) -> None:
|
||||
"""Add an assistant message."""
|
||||
self.add_message("assistant", content)
|
||||
|
||||
def get_chat_history(self, max_messages: int = 50) -> List[Dict[str, str]]:
|
||||
"""
|
||||
Get chat history in LLM-compatible format.
|
||||
|
||||
Returns list of {"role": "user/assistant", "content": "..."} dicts.
|
||||
"""
|
||||
recent = self.messages[-max_messages:] if len(self.messages) > max_messages else self.messages
|
||||
return [{"role": msg["role"], "content": msg["content"]} for msg in recent]
|
||||
|
||||
def update_stats(self, input_tokens: int, output_tokens: int, cost: float = 0.0) -> None:
|
||||
"""Update token and cost statistics."""
|
||||
self.total_input_tokens += input_tokens
|
||||
self.total_output_tokens += output_tokens
|
||||
self.total_cost += cost
|
||||
self.request_count += 1
|
||||
self.updated_at = datetime.now().isoformat()
|
||||
|
||||
def clear_messages(self) -> None:
|
||||
"""Clear all messages from the session."""
|
||||
self.messages.clear()
|
||||
self.updated_at = datetime.now().isoformat()
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert session to dictionary."""
|
||||
return asdict(self)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> "UnifiedSession":
|
||||
"""Create session from dictionary."""
|
||||
return cls(**data)
|
||||
|
||||
@property
|
||||
def message_count(self) -> int:
|
||||
"""Number of messages in the session."""
|
||||
return len(self.messages)
|
||||
|
||||
@property
|
||||
def user_message_count(self) -> int:
|
||||
"""Number of user messages in the session."""
|
||||
return sum(1 for msg in self.messages if msg.get("role") == "user")
|
||||
|
||||
|
||||
class UnifiedSessionStore:
|
||||
"""
|
||||
Persistent session store with file locking.
|
||||
|
||||
Stores sessions as JSON files in ~/.praison/sessions/
|
||||
"""
|
||||
|
||||
def __init__(self, session_dir: Optional[Path] = None):
|
||||
"""
|
||||
Initialize session store.
|
||||
|
||||
Args:
|
||||
session_dir: Directory to store sessions. Defaults to ~/.praison/sessions/
|
||||
"""
|
||||
self.session_dir = session_dir or DEFAULT_SESSION_DIR
|
||||
self.session_dir.mkdir(parents=True, exist_ok=True)
|
||||
self._cache: Dict[str, UnifiedSession] = {}
|
||||
self._last_session_id: Optional[str] = None
|
||||
|
||||
def _get_session_path(self, session_id: str) -> Path:
|
||||
"""Get the file path for a session."""
|
||||
return self.session_dir / f"{session_id}.json"
|
||||
|
||||
def _get_last_session_path(self) -> Path:
|
||||
"""Get the path to the last session marker file."""
|
||||
return self.session_dir / ".last_session"
|
||||
|
||||
def save(self, session: UnifiedSession) -> None:
|
||||
"""
|
||||
Save a session to disk with file locking.
|
||||
|
||||
Args:
|
||||
session: Session to save
|
||||
"""
|
||||
path = self._get_session_path(session.session_id)
|
||||
session.updated_at = datetime.now().isoformat()
|
||||
|
||||
try:
|
||||
with open(path, 'w') as f:
|
||||
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
|
||||
try:
|
||||
json.dump(session.to_dict(), f, indent=2)
|
||||
finally:
|
||||
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
|
||||
|
||||
# Update cache
|
||||
self._cache[session.session_id] = session
|
||||
|
||||
# Update last session marker
|
||||
self._update_last_session(session.session_id)
|
||||
|
||||
logger.debug(f"Saved session: {session.session_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save session {session.session_id}: {e}")
|
||||
raise
|
||||
|
||||
def load(self, session_id: str) -> Optional[UnifiedSession]:
|
||||
"""
|
||||
Load a session from disk.
|
||||
|
||||
Args:
|
||||
session_id: Session ID to load
|
||||
|
||||
Returns:
|
||||
Session if found, None otherwise
|
||||
"""
|
||||
# Check cache first
|
||||
if session_id in self._cache:
|
||||
return self._cache[session_id]
|
||||
|
||||
path = self._get_session_path(session_id)
|
||||
if not path.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(path, 'r') as f:
|
||||
fcntl.flock(f.fileno(), fcntl.LOCK_SH)
|
||||
try:
|
||||
data = json.load(f)
|
||||
finally:
|
||||
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
|
||||
|
||||
session = UnifiedSession.from_dict(data)
|
||||
self._cache[session_id] = session
|
||||
logger.debug(f"Loaded session: {session_id}")
|
||||
return session
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load session {session_id}: {e}")
|
||||
return None
|
||||
|
||||
def get_or_create(self, session_id: Optional[str] = None) -> UnifiedSession:
|
||||
"""
|
||||
Get existing session or create new one.
|
||||
|
||||
Args:
|
||||
session_id: Optional session ID. If None, creates new session.
|
||||
|
||||
Returns:
|
||||
Session instance
|
||||
"""
|
||||
if session_id:
|
||||
session = self.load(session_id)
|
||||
if session:
|
||||
return session
|
||||
|
||||
# Create new session
|
||||
new_id = session_id or str(uuid.uuid4())[:8]
|
||||
session = UnifiedSession(session_id=new_id)
|
||||
self.save(session)
|
||||
return session
|
||||
|
||||
def delete(self, session_id: str) -> bool:
|
||||
"""
|
||||
Delete a session.
|
||||
|
||||
Args:
|
||||
session_id: Session ID to delete
|
||||
|
||||
Returns:
|
||||
True if deleted, False if not found
|
||||
"""
|
||||
path = self._get_session_path(session_id)
|
||||
if path.exists():
|
||||
path.unlink()
|
||||
self._cache.pop(session_id, None)
|
||||
logger.debug(f"Deleted session: {session_id}")
|
||||
return True
|
||||
return False
|
||||
|
||||
def list_sessions(self, limit: int = 50) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
List all sessions with metadata.
|
||||
|
||||
Args:
|
||||
limit: Maximum number of sessions to return
|
||||
|
||||
Returns:
|
||||
List of session metadata dicts
|
||||
"""
|
||||
sessions = []
|
||||
for path in self.session_dir.glob("*.json"):
|
||||
try:
|
||||
with open(path, 'r') as f:
|
||||
data = json.load(f)
|
||||
sessions.append({
|
||||
"session_id": data.get("session_id"),
|
||||
"created_at": data.get("created_at"),
|
||||
"updated_at": data.get("updated_at"),
|
||||
"message_count": len(data.get("messages", [])),
|
||||
"workspace": data.get("workspace"),
|
||||
})
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to read session {path}: {e}")
|
||||
|
||||
# Sort by updated_at descending
|
||||
sessions.sort(key=lambda s: s.get("updated_at", ""), reverse=True)
|
||||
return sessions[:limit]
|
||||
|
||||
def _update_last_session(self, session_id: str) -> None:
|
||||
"""Update the last session marker."""
|
||||
try:
|
||||
path = self._get_last_session_path()
|
||||
with open(path, 'w') as f:
|
||||
f.write(session_id)
|
||||
self._last_session_id = session_id
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to update last session marker: {e}")
|
||||
|
||||
def get_last_session_id(self) -> Optional[str]:
|
||||
"""Get the ID of the last used session."""
|
||||
if self._last_session_id:
|
||||
return self._last_session_id
|
||||
|
||||
path = self._get_last_session_path()
|
||||
if path.exists():
|
||||
try:
|
||||
self._last_session_id = path.read_text().strip()
|
||||
return self._last_session_id
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to read last session marker: {e}")
|
||||
return None
|
||||
|
||||
def get_last_session(self) -> Optional[UnifiedSession]:
|
||||
"""Get the last used session."""
|
||||
session_id = self.get_last_session_id()
|
||||
if session_id:
|
||||
return self.load(session_id)
|
||||
return None
|
||||
|
||||
|
||||
# Global session store instance
|
||||
_session_store: Optional[UnifiedSessionStore] = None
|
||||
|
||||
|
||||
def get_session_store() -> UnifiedSessionStore:
|
||||
"""Get the global session store instance."""
|
||||
global _session_store
|
||||
if _session_store is None:
|
||||
_session_store = UnifiedSessionStore()
|
||||
return _session_store
|
||||
@@ -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==3.0.2 gunicorn markdown\n")
|
||||
file.write("RUN pip install flask praisonai==3.1.2 gunicorn markdown\n")
|
||||
file.write("EXPOSE 8080\n")
|
||||
file.write('CMD ["gunicorn", "-b", "0.0.0.0:8080", "api:app"]\n')
|
||||
|
||||
|
||||
@@ -1,24 +1,28 @@
|
||||
# Only try to import autogen_tools if either CrewAI or AG2 is available
|
||||
CREWAI_AVAILABLE = False
|
||||
AUTOGEN_AVAILABLE = False
|
||||
# Lazy availability checks using find_spec (no actual import)
|
||||
# This avoids the ~3200ms crewai import at CLI startup
|
||||
import importlib.util
|
||||
|
||||
CREWAI_AVAILABLE = importlib.util.find_spec("crewai") is not None
|
||||
AUTOGEN_AVAILABLE = importlib.util.find_spec("autogen") is not None
|
||||
PRAISONAI_TOOLS_AVAILABLE = False
|
||||
|
||||
try:
|
||||
from crewai import Agent, Task, Crew
|
||||
CREWAI_AVAILABLE = True
|
||||
except ImportError:
|
||||
pass
|
||||
# Lazy import helper - only imports when actually needed
|
||||
def _get_autogen_tools():
|
||||
"""Lazy import autogen_tools only when needed."""
|
||||
global PRAISONAI_TOOLS_AVAILABLE
|
||||
if CREWAI_AVAILABLE or AUTOGEN_AVAILABLE:
|
||||
try:
|
||||
from . import autogen_tools
|
||||
PRAISONAI_TOOLS_AVAILABLE = True
|
||||
return autogen_tools
|
||||
except ImportError:
|
||||
pass
|
||||
return None
|
||||
|
||||
try:
|
||||
import autogen
|
||||
AUTOGEN_AVAILABLE = True
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Only try to import tools if a framework is available
|
||||
if CREWAI_AVAILABLE or AUTOGEN_AVAILABLE:
|
||||
try:
|
||||
from .autogen_tools import *
|
||||
PRAISONAI_TOOLS_AVAILABLE = True
|
||||
except ImportError:
|
||||
pass
|
||||
# For backward compatibility, provide __getattr__ for lazy access
|
||||
def __getattr__(name):
|
||||
"""Lazy load autogen_tools exports on demand."""
|
||||
tools_module = _get_autogen_tools()
|
||||
if tools_module and hasattr(tools_module, name):
|
||||
return getattr(tools_module, name)
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
@@ -1,2 +1,12 @@
|
||||
# praisonai/inc/__init__.py
|
||||
from .models import PraisonAIModel
|
||||
# Lazy loading - PraisonAIModel is only imported when accessed
|
||||
# This avoids the ~3500ms langchain_openai import at CLI startup
|
||||
|
||||
def __getattr__(name):
|
||||
"""Lazy load PraisonAIModel only when accessed."""
|
||||
if name == "PraisonAIModel":
|
||||
from .models import PraisonAIModel
|
||||
return PraisonAIModel
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
||||
__all__ = ["PraisonAIModel"]
|
||||
@@ -1,7 +1,11 @@
|
||||
# praisonai/inc/models.py
|
||||
# PERFORMANCE OPTIMIZED: Removed all LangChain imports
|
||||
# Uses direct OpenAI SDK for fast startup (~600ms vs ~3200ms with langchain_openai)
|
||||
import os
|
||||
import logging
|
||||
from urllib.parse import urlparse
|
||||
import importlib.util
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
_loglevel = os.environ.get('LOGLEVEL', 'INFO').strip().upper() or 'INFO'
|
||||
logging.basicConfig(level=_loglevel, format='%(asctime)s - %(levelname)s - %(message)s')
|
||||
@@ -9,30 +13,33 @@ logging.basicConfig(level=_loglevel, format='%(asctime)s - %(levelname)s - %(mes
|
||||
# Constants
|
||||
LOCAL_SERVER_API_KEY_PLACEHOLDER = "not-needed"
|
||||
|
||||
# Conditionally import modules based on availability
|
||||
try:
|
||||
from langchain_openai import ChatOpenAI # pip install langchain-openai
|
||||
OPENAI_AVAILABLE = True
|
||||
except ImportError:
|
||||
OPENAI_AVAILABLE = False
|
||||
# Use find_spec for fast availability checks (no actual import)
|
||||
# This avoids the ~3200ms langchain_openai import at module load
|
||||
OPENAI_AVAILABLE = importlib.util.find_spec("openai") is not None
|
||||
GOOGLE_GENAI_AVAILABLE = importlib.util.find_spec("google.generativeai") is not None
|
||||
ANTHROPIC_AVAILABLE = importlib.util.find_spec("anthropic") is not None
|
||||
COHERE_AVAILABLE = importlib.util.find_spec("cohere") is not None
|
||||
|
||||
try:
|
||||
from langchain_google_genai import ChatGoogleGenerativeAI # pip install langchain-google-genai
|
||||
GOOGLE_GENAI_AVAILABLE = True
|
||||
except ImportError:
|
||||
GOOGLE_GENAI_AVAILABLE = False
|
||||
# Lazy import helpers for provider SDKs
|
||||
def _get_openai_client():
|
||||
"""Lazy import OpenAI client."""
|
||||
from openai import OpenAI
|
||||
return OpenAI
|
||||
|
||||
try:
|
||||
from langchain_anthropic import ChatAnthropic # pip install langchain-anthropic
|
||||
ANTHROPIC_AVAILABLE = True
|
||||
except ImportError:
|
||||
ANTHROPIC_AVAILABLE = False
|
||||
def _get_google_genai():
|
||||
"""Lazy import Google Generative AI."""
|
||||
import google.generativeai as genai
|
||||
return genai
|
||||
|
||||
try:
|
||||
from langchain_cohere import ChatCohere # pip install langchain-cohere
|
||||
COHERE_AVAILABLE = True
|
||||
except ImportError:
|
||||
COHERE_AVAILABLE = False
|
||||
def _get_anthropic_client():
|
||||
"""Lazy import Anthropic client."""
|
||||
from anthropic import Anthropic
|
||||
return Anthropic
|
||||
|
||||
def _get_cohere_client():
|
||||
"""Lazy import Cohere client."""
|
||||
import cohere
|
||||
return cohere
|
||||
|
||||
class PraisonAIModel:
|
||||
def __init__(self, model=None, api_key_var=None, base_url=None, api_key=None):
|
||||
@@ -100,56 +107,47 @@ class PraisonAIModel:
|
||||
|
||||
def get_model(self):
|
||||
"""
|
||||
Returns an instance of the langchain Chat client with the configured parameters.
|
||||
Returns an instance of the native SDK client with the configured parameters.
|
||||
|
||||
PERFORMANCE: Uses direct SDK clients instead of LangChain wrappers.
|
||||
This saves ~2600ms on import (OpenAI SDK ~600ms vs langchain_openai ~3200ms).
|
||||
|
||||
Returns:
|
||||
Chat: An instance of the langchain Chat client.
|
||||
Client: An instance of the native SDK client (OpenAI, Anthropic, etc.)
|
||||
"""
|
||||
if self.model.startswith("google/"):
|
||||
if GOOGLE_GENAI_AVAILABLE:
|
||||
return ChatGoogleGenerativeAI(
|
||||
model=self.model_name,
|
||||
google_api_key=self.api_key
|
||||
)
|
||||
genai = _get_google_genai()
|
||||
genai.configure(api_key=self.api_key)
|
||||
return genai.GenerativeModel(self.model_name)
|
||||
else:
|
||||
raise ImportError(
|
||||
"Required Langchain Integration 'langchain-google-genai' not found. "
|
||||
"Please install with 'pip install langchain-google-genai'"
|
||||
"Required package 'google-generativeai' not found. "
|
||||
"Please install with 'pip install google-generativeai'"
|
||||
)
|
||||
elif self.model.startswith("cohere/"):
|
||||
if COHERE_AVAILABLE:
|
||||
return ChatCohere(
|
||||
model=self.model_name,
|
||||
cohere_api_key=self.api_key,
|
||||
)
|
||||
cohere = _get_cohere_client()
|
||||
return cohere.Client(api_key=self.api_key)
|
||||
else:
|
||||
raise ImportError(
|
||||
"Required Langchain Integration 'langchain-cohere' not found. "
|
||||
"Please install with 'pip install langchain-cohere'"
|
||||
"Required package 'cohere' not found. "
|
||||
"Please install with 'pip install cohere'"
|
||||
)
|
||||
elif self.model.startswith("anthropic/"):
|
||||
if ANTHROPIC_AVAILABLE:
|
||||
return ChatAnthropic(
|
||||
model=self.model_name,
|
||||
anthropic_api_key=self.api_key,
|
||||
)
|
||||
Anthropic = _get_anthropic_client()
|
||||
return Anthropic(api_key=self.api_key)
|
||||
else:
|
||||
raise ImportError(
|
||||
"Required Langchain Integration 'langchain-anthropic' not found. "
|
||||
"Please install with 'pip install langchain-anthropic'"
|
||||
"Required package 'anthropic' not found. "
|
||||
"Please install with 'pip install anthropic'"
|
||||
)
|
||||
elif OPENAI_AVAILABLE:
|
||||
chat_kwargs = {
|
||||
"model": self.model_name,
|
||||
"api_key": self.api_key,
|
||||
"base_url": self.base_url,
|
||||
}
|
||||
# Certain OpenAI models (e.g., gpt-5-* family) only allow temperature=1
|
||||
if isinstance(self.model_name, str) and self.model_name.startswith("gpt-5"):
|
||||
chat_kwargs["temperature"] = 1
|
||||
return ChatOpenAI(**chat_kwargs)
|
||||
OpenAI = _get_openai_client()
|
||||
return OpenAI(api_key=self.api_key, base_url=self.base_url)
|
||||
else:
|
||||
raise ImportError(
|
||||
"Required Langchain Integration 'langchain-openai' not found. "
|
||||
"Please install with 'pip install langchain-openai'"
|
||||
"Required package 'openai' not found. "
|
||||
"Please install with 'pip install openai'"
|
||||
)
|
||||
+462
-160
@@ -1,34 +1,24 @@
|
||||
# Standard library imports
|
||||
"""PraisonAI Chat - Optimized Chainlit Application
|
||||
|
||||
This is the optimized chat application with:
|
||||
- Lazy imports for fast startup
|
||||
- Agent reuse between messages (persistent agent per session)
|
||||
- Default ACP/LSP tools with trust mode
|
||||
- Session management
|
||||
- Profiling hooks (optional via PRAISON_CHAT_PROFILE=1)
|
||||
|
||||
Performance improvements over original chat.py:
|
||||
- Deferred database initialization
|
||||
- Lazy loading of PIL, Tavily, crawl4ai, litellm
|
||||
- Agent reuse (no re-creation per message)
|
||||
- Interactive tools loaded once per session
|
||||
"""
|
||||
|
||||
# Standard library imports (minimal at top level)
|
||||
import os
|
||||
from datetime import datetime
|
||||
from typing import Dict, Optional
|
||||
import logging
|
||||
import json
|
||||
import asyncio
|
||||
import io
|
||||
import base64
|
||||
import importlib.util
|
||||
import inspect
|
||||
|
||||
# Third-party imports
|
||||
from dotenv import load_dotenv
|
||||
from PIL import Image
|
||||
from tavily import TavilyClient
|
||||
from crawl4ai import AsyncWebCrawler
|
||||
|
||||
# Local application/library imports
|
||||
import chainlit as cl
|
||||
from chainlit.input_widget import TextInput
|
||||
from chainlit.types import ThreadDict
|
||||
import chainlit.data as cl_data
|
||||
from litellm import acompletion
|
||||
from literalai.helper import utc_now
|
||||
from db import DatabaseManager
|
||||
|
||||
# Load environment variables
|
||||
load_dotenv()
|
||||
|
||||
# Logging configuration
|
||||
# Set up minimal logging first
|
||||
logger = logging.getLogger(__name__)
|
||||
log_level = os.getenv("LOGLEVEL", "INFO").upper() or "INFO"
|
||||
logger.handlers = []
|
||||
@@ -39,52 +29,204 @@ console_handler.setFormatter(console_formatter)
|
||||
logger.addHandler(console_handler)
|
||||
logger.setLevel(log_level)
|
||||
|
||||
# Chainlit must be imported early (required by decorators)
|
||||
import chainlit as cl
|
||||
from chainlit.input_widget import TextInput, Switch
|
||||
from chainlit.types import ThreadDict
|
||||
import chainlit.data as cl_data
|
||||
|
||||
# Profiling support (optional)
|
||||
PROFILING_ENABLED = os.getenv("PRAISON_CHAT_PROFILE", "").lower() in ("1", "true", "yes")
|
||||
_profile_data = {}
|
||||
|
||||
def _profile_start(name: str):
|
||||
"""Start profiling a section."""
|
||||
if PROFILING_ENABLED:
|
||||
import time
|
||||
_profile_data[name] = {"start": time.perf_counter()}
|
||||
|
||||
def _profile_end(name: str):
|
||||
"""End profiling a section and log."""
|
||||
if PROFILING_ENABLED and name in _profile_data:
|
||||
import time
|
||||
elapsed = time.perf_counter() - _profile_data[name]["start"]
|
||||
_profile_data[name]["elapsed"] = elapsed
|
||||
logger.info(f"[PROFILE] {name}: {elapsed*1000:.2f}ms")
|
||||
|
||||
# Lazy import cache
|
||||
_cached_modules = {}
|
||||
|
||||
def _get_json():
|
||||
if 'json' not in _cached_modules:
|
||||
import json
|
||||
_cached_modules['json'] = json
|
||||
return _cached_modules['json']
|
||||
|
||||
def _get_asyncio():
|
||||
if 'asyncio' not in _cached_modules:
|
||||
import asyncio
|
||||
_cached_modules['asyncio'] = asyncio
|
||||
return _cached_modules['asyncio']
|
||||
|
||||
def _get_datetime():
|
||||
if 'datetime' not in _cached_modules:
|
||||
from datetime import datetime
|
||||
_cached_modules['datetime'] = datetime
|
||||
return _cached_modules['datetime']
|
||||
|
||||
def _get_io():
|
||||
if 'io' not in _cached_modules:
|
||||
import io
|
||||
_cached_modules['io'] = io
|
||||
return _cached_modules['io']
|
||||
|
||||
def _get_base64():
|
||||
if 'base64' not in _cached_modules:
|
||||
import base64
|
||||
_cached_modules['base64'] = base64
|
||||
return _cached_modules['base64']
|
||||
|
||||
def _get_importlib():
|
||||
if 'importlib' not in _cached_modules:
|
||||
import importlib.util
|
||||
_cached_modules['importlib'] = importlib
|
||||
return _cached_modules['importlib']
|
||||
|
||||
def _get_inspect():
|
||||
if 'inspect' not in _cached_modules:
|
||||
import inspect
|
||||
_cached_modules['inspect'] = inspect
|
||||
return _cached_modules['inspect']
|
||||
|
||||
def _get_pil_image():
|
||||
if 'PIL.Image' not in _cached_modules:
|
||||
_profile_start("import_pil")
|
||||
from PIL import Image
|
||||
_cached_modules['PIL.Image'] = Image
|
||||
_profile_end("import_pil")
|
||||
return _cached_modules['PIL.Image']
|
||||
|
||||
def _get_acompletion():
|
||||
if 'acompletion' not in _cached_modules:
|
||||
_profile_start("import_litellm")
|
||||
from litellm import acompletion
|
||||
_cached_modules['acompletion'] = acompletion
|
||||
_profile_end("import_litellm")
|
||||
return _cached_modules['acompletion']
|
||||
|
||||
def _get_tavily_client():
|
||||
if 'TavilyClient' not in _cached_modules:
|
||||
try:
|
||||
_profile_start("import_tavily")
|
||||
from tavily import TavilyClient
|
||||
_cached_modules['TavilyClient'] = TavilyClient
|
||||
_profile_end("import_tavily")
|
||||
except ImportError:
|
||||
_cached_modules['TavilyClient'] = None
|
||||
return _cached_modules['TavilyClient']
|
||||
|
||||
def _get_async_web_crawler():
|
||||
if 'AsyncWebCrawler' not in _cached_modules:
|
||||
try:
|
||||
_profile_start("import_crawl4ai")
|
||||
from crawl4ai import AsyncWebCrawler
|
||||
_cached_modules['AsyncWebCrawler'] = AsyncWebCrawler
|
||||
_profile_end("import_crawl4ai")
|
||||
except ImportError:
|
||||
_cached_modules['AsyncWebCrawler'] = None
|
||||
return _cached_modules['AsyncWebCrawler']
|
||||
|
||||
def _get_praisonai_agent():
|
||||
"""Lazy load PraisonAI Agent for reuse."""
|
||||
if 'Agent' not in _cached_modules:
|
||||
try:
|
||||
_profile_start("import_praisonai_agent")
|
||||
from praisonaiagents import Agent
|
||||
_cached_modules['Agent'] = Agent
|
||||
_profile_end("import_praisonai_agent")
|
||||
except ImportError:
|
||||
_cached_modules['Agent'] = None
|
||||
return _cached_modules['Agent']
|
||||
|
||||
def _get_interactive_tools():
|
||||
"""Lazy load interactive tools (ACP, LSP, basic) with trust mode."""
|
||||
if 'interactive_tools' not in _cached_modules:
|
||||
try:
|
||||
_profile_start("load_interactive_tools")
|
||||
from praisonai.cli.features.interactive_tools import get_interactive_tools, ToolConfig
|
||||
config = ToolConfig.from_env()
|
||||
config.workspace = os.getcwd()
|
||||
config.approval_mode = "auto" # Trust mode - auto-approve all tool executions
|
||||
tools = get_interactive_tools(config=config)
|
||||
_cached_modules['interactive_tools'] = tools
|
||||
_profile_end("load_interactive_tools")
|
||||
logger.info(f"Loaded {len(tools)} interactive tools (ACP, LSP, basic) with trust mode")
|
||||
except ImportError as e:
|
||||
logger.debug(f"Interactive tools not available: {e}")
|
||||
_cached_modules['interactive_tools'] = []
|
||||
return _cached_modules['interactive_tools']
|
||||
|
||||
# Deferred database initialization
|
||||
_db_manager = None
|
||||
|
||||
def _get_db_manager():
|
||||
"""Lazy initialize database manager."""
|
||||
global _db_manager
|
||||
if _db_manager is None:
|
||||
_profile_start("init_database")
|
||||
# Import from the same directory as this module
|
||||
import sys
|
||||
ui_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
if ui_dir not in sys.path:
|
||||
sys.path.insert(0, ui_dir)
|
||||
from db import DatabaseManager
|
||||
_db_manager = DatabaseManager()
|
||||
_db_manager.initialize()
|
||||
cl_data._data_layer = _db_manager
|
||||
_profile_end("init_database")
|
||||
return _db_manager
|
||||
|
||||
# Load environment variables lazily
|
||||
def _ensure_env_loaded():
|
||||
if 'env_loaded' not in _cached_modules:
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
_cached_modules['env_loaded'] = True
|
||||
|
||||
# Auth secret setup (required early)
|
||||
CHAINLIT_AUTH_SECRET = os.getenv("CHAINLIT_AUTH_SECRET")
|
||||
if not CHAINLIT_AUTH_SECRET:
|
||||
os.environ["CHAINLIT_AUTH_SECRET"] = "p8BPhQChpg@J>jBz$wGxqLX2V>yTVgP*7Ky9H$aV:axW~ANNX-7_T:o@lnyCBu^U"
|
||||
CHAINLIT_AUTH_SECRET = os.getenv("CHAINLIT_AUTH_SECRET")
|
||||
|
||||
now = utc_now()
|
||||
create_step_counter = 0
|
||||
|
||||
# Initialize database
|
||||
db_manager = DatabaseManager()
|
||||
db_manager.initialize()
|
||||
|
||||
def save_setting(key: str, value: str):
|
||||
"""Save a setting to the database"""
|
||||
asyncio = _get_asyncio()
|
||||
db_manager = _get_db_manager()
|
||||
asyncio.run(db_manager.save_setting(key, value))
|
||||
|
||||
def load_setting(key: str) -> str:
|
||||
"""Load a setting from the database"""
|
||||
asyncio = _get_asyncio()
|
||||
db_manager = _get_db_manager()
|
||||
return asyncio.run(db_manager.load_setting(key))
|
||||
|
||||
cl_data._data_layer = db_manager
|
||||
|
||||
def load_custom_tools():
|
||||
"""
|
||||
Load custom tools from tools.py if it exists.
|
||||
|
||||
Resolution order:
|
||||
1. PRAISONAI_TOOLS_PATH environment variable (file or directory)
|
||||
2. ./tools.py in current working directory
|
||||
3. No tools (silent, no warning)
|
||||
"""
|
||||
"""Load custom tools from tools.py if it exists."""
|
||||
custom_tools = {}
|
||||
importlib = _get_importlib()
|
||||
inspect = _get_inspect()
|
||||
|
||||
# Determine tools path
|
||||
tools_path = os.getenv("PRAISONAI_TOOLS_PATH")
|
||||
if tools_path:
|
||||
if not os.path.exists(tools_path):
|
||||
logger.warning(f"PRAISONAI_TOOLS_PATH set but path does not exist: {tools_path}")
|
||||
return custom_tools
|
||||
else:
|
||||
# Check current working directory for tools.py
|
||||
cwd_tools = os.path.join(os.getcwd(), "tools.py")
|
||||
if os.path.exists(cwd_tools):
|
||||
tools_path = cwd_tools
|
||||
else:
|
||||
# No tools configured - this is fine, return silently
|
||||
logger.debug("No tools.py found in current directory (this is normal)")
|
||||
return custom_tools
|
||||
|
||||
@@ -97,27 +239,23 @@ def load_custom_tools():
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
# Load all functions from tools.py
|
||||
for name, obj in inspect.getmembers(module):
|
||||
if not name.startswith('_') and callable(obj) and not inspect.isclass(obj):
|
||||
# Store function in globals for access
|
||||
globals()[name] = obj
|
||||
|
||||
# Get function signature to build parameters
|
||||
sig = inspect.signature(obj)
|
||||
params_properties = {}
|
||||
required_params = []
|
||||
|
||||
for param_name, param in sig.parameters.items():
|
||||
if param_name != 'self': # Skip self parameter
|
||||
# Get type annotation if available
|
||||
param_type = "string" # Default type
|
||||
if param.annotation != inspect.Parameter.empty:
|
||||
if param.annotation == int:
|
||||
if param_name != 'self':
|
||||
param_type = "string"
|
||||
if param.annotation is not inspect.Parameter.empty:
|
||||
if param.annotation is int:
|
||||
param_type = "integer"
|
||||
elif param.annotation == float:
|
||||
elif param.annotation is float:
|
||||
param_type = "number"
|
||||
elif param.annotation == bool:
|
||||
elif param.annotation is bool:
|
||||
param_type = "boolean"
|
||||
|
||||
params_properties[param_name] = {
|
||||
@@ -125,11 +263,9 @@ def load_custom_tools():
|
||||
"description": f"Parameter {param_name}"
|
||||
}
|
||||
|
||||
# Add to required if no default value
|
||||
if param.default == inspect.Parameter.empty:
|
||||
if param.default is inspect.Parameter.empty:
|
||||
required_params.append(param_name)
|
||||
|
||||
# Build tool definition
|
||||
tool_def = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
@@ -149,20 +285,63 @@ def load_custom_tools():
|
||||
if custom_tools:
|
||||
logger.info(f"Loaded {len(custom_tools)} custom tools from {tools_path}")
|
||||
except FileNotFoundError:
|
||||
# File doesn't exist - this is fine, no warning needed
|
||||
logger.debug(f"Tools file not found: {tools_path}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Error loading custom tools from {tools_path}: {e}")
|
||||
|
||||
return custom_tools
|
||||
|
||||
# Load custom tools
|
||||
custom_tools_dict = load_custom_tools()
|
||||
# Deferred tool loading
|
||||
_custom_tools_dict = None
|
||||
_tavily_client = None
|
||||
_tools_list = None
|
||||
|
||||
tavily_api_key = os.getenv("TAVILY_API_KEY")
|
||||
tavily_client = TavilyClient(api_key=tavily_api_key) if tavily_api_key else None
|
||||
def _get_custom_tools():
|
||||
global _custom_tools_dict
|
||||
if _custom_tools_dict is None:
|
||||
_custom_tools_dict = load_custom_tools()
|
||||
return _custom_tools_dict
|
||||
|
||||
def _get_tavily():
|
||||
global _tavily_client
|
||||
if _tavily_client is None:
|
||||
tavily_api_key = os.getenv("TAVILY_API_KEY")
|
||||
if tavily_api_key:
|
||||
TavilyClient = _get_tavily_client()
|
||||
if TavilyClient:
|
||||
_tavily_client = TavilyClient(api_key=tavily_api_key)
|
||||
return _tavily_client
|
||||
|
||||
def _get_tools_list():
|
||||
"""Get the combined tools list (Tavily + custom tools)."""
|
||||
global _tools_list
|
||||
if _tools_list is None:
|
||||
_tools_list = []
|
||||
tavily_api_key = os.getenv("TAVILY_API_KEY")
|
||||
if tavily_api_key:
|
||||
_tools_list.append({
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "tavily_web_search",
|
||||
"description": "Search the web using Tavily API and crawl the resulting URLs",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string", "description": "Search query"}
|
||||
},
|
||||
"required": ["query"]
|
||||
}
|
||||
}
|
||||
})
|
||||
custom_tools = _get_custom_tools()
|
||||
_tools_list.extend(list(custom_tools.values()))
|
||||
return _tools_list
|
||||
|
||||
async def tavily_web_search(query):
|
||||
"""Search the web using Tavily API."""
|
||||
json = _get_json()
|
||||
tavily_client = _get_tavily()
|
||||
|
||||
if not tavily_client:
|
||||
return json.dumps({
|
||||
"query": query,
|
||||
@@ -172,62 +351,39 @@ async def tavily_web_search(query):
|
||||
response = tavily_client.search(query)
|
||||
logger.debug(f"Tavily search response: {response}")
|
||||
|
||||
async with AsyncWebCrawler() as crawler:
|
||||
results = []
|
||||
for result in response.get('results', []):
|
||||
url = result.get('url')
|
||||
if url:
|
||||
try:
|
||||
crawl_result = await crawler.arun(url=url)
|
||||
results.append({
|
||||
"content": result.get('content'),
|
||||
"url": url,
|
||||
"full_content": crawl_result.markdown
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Error crawling {url}: {str(e)}")
|
||||
results.append({
|
||||
"content": result.get('content'),
|
||||
"url": url,
|
||||
"full_content": "Error: Unable to crawl this URL"
|
||||
})
|
||||
AsyncWebCrawler = _get_async_web_crawler()
|
||||
if AsyncWebCrawler:
|
||||
async with AsyncWebCrawler() as crawler:
|
||||
results = []
|
||||
for result in response.get('results', []):
|
||||
url = result.get('url')
|
||||
if url:
|
||||
try:
|
||||
crawl_result = await crawler.arun(url=url)
|
||||
results.append({
|
||||
"content": result.get('content'),
|
||||
"url": url,
|
||||
"full_content": crawl_result.markdown
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Error crawling {url}: {str(e)}")
|
||||
results.append({
|
||||
"content": result.get('content'),
|
||||
"url": url,
|
||||
"full_content": "Error: Unable to crawl this URL"
|
||||
})
|
||||
else:
|
||||
results = [{"content": r.get('content'), "url": r.get('url')} for r in response.get('results', [])]
|
||||
|
||||
return json.dumps({
|
||||
"query": query,
|
||||
"results": results
|
||||
})
|
||||
|
||||
# Build tools list with Tavily and custom tools
|
||||
tools = []
|
||||
|
||||
# Add Tavily tool if API key is available
|
||||
if tavily_api_key:
|
||||
tools.append({
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "tavily_web_search",
|
||||
"description": "Search the web using Tavily API and crawl the resulting URLs",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string", "description": "Search query"}
|
||||
},
|
||||
"required": ["query"]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
# Add custom tools from tools.py
|
||||
tools.extend(list(custom_tools_dict.values()))
|
||||
|
||||
# Authentication configuration
|
||||
AUTH_PASSWORD_ENABLED = os.getenv("AUTH_PASSWORD_ENABLED", "true").lower() == "true" # Password authentication enabled by default
|
||||
AUTH_OAUTH_ENABLED = os.getenv("AUTH_OAUTH_ENABLED", "false").lower() == "true" # OAuth authentication disabled by default
|
||||
|
||||
expected_username = os.getenv("CHAINLIT_USERNAME", "admin")
|
||||
expected_password = os.getenv("CHAINLIT_PASSWORD", "admin")
|
||||
|
||||
# Warn if using default credentials
|
||||
if expected_username == "admin" and expected_password == "admin":
|
||||
logger.warning("⚠️ Using default admin credentials. Set CHAINLIT_USERNAME and CHAINLIT_PASSWORD environment variables for production.")
|
||||
|
||||
@@ -241,38 +397,122 @@ def auth_callback(username: str, password: str):
|
||||
logger.warning(f"Login failed for user: {username}")
|
||||
return None
|
||||
|
||||
def _get_or_create_agent(model_name: str, tools_enabled: bool = True):
|
||||
"""Get or create a reusable agent for the session."""
|
||||
Agent = _get_praisonai_agent()
|
||||
if Agent is None:
|
||||
return None
|
||||
|
||||
# Get cached agent from session
|
||||
cached_agent = cl.user_session.get("_cached_agent")
|
||||
cached_model = cl.user_session.get("_cached_agent_model")
|
||||
|
||||
# Reuse if model matches
|
||||
if cached_agent is not None and cached_model == model_name:
|
||||
return cached_agent
|
||||
|
||||
# Create new agent with interactive tools
|
||||
_profile_start("create_agent")
|
||||
tools = []
|
||||
if tools_enabled:
|
||||
tools = _get_interactive_tools()
|
||||
# Add Tavily if available
|
||||
if os.getenv("TAVILY_API_KEY"):
|
||||
tools.append(tavily_web_search)
|
||||
|
||||
agent = Agent(
|
||||
name="PraisonAI Assistant",
|
||||
instructions="""You are a helpful AI assistant with access to powerful tools.
|
||||
|
||||
async def send_count():
|
||||
await cl.Message(
|
||||
f"Create step counter: {create_step_counter}", disable_feedback=True
|
||||
).send()
|
||||
Available capabilities:
|
||||
- File operations (read, write, create, edit, delete files)
|
||||
- Code intelligence (find symbols, definitions, references)
|
||||
- Command execution (run shell commands)
|
||||
- Web search (if Tavily API key is set)
|
||||
|
||||
Use tools when needed to help the user. For file modifications, use the ACP tools which provide safe, reviewable changes.
|
||||
Always be helpful, accurate, and concise.""",
|
||||
llm=model_name,
|
||||
tools=tools if tools else None,
|
||||
verbose=False,
|
||||
)
|
||||
|
||||
# Cache the agent
|
||||
cl.user_session.set("_cached_agent", agent)
|
||||
cl.user_session.set("_cached_agent_model", model_name)
|
||||
_profile_end("create_agent")
|
||||
|
||||
return agent
|
||||
|
||||
@cl.on_chat_start
|
||||
async def start():
|
||||
model_name = load_setting("model_name") or os.getenv("MODEL_NAME", "gpt-5-nano")
|
||||
_profile_start("on_chat_start")
|
||||
_ensure_env_loaded()
|
||||
|
||||
model_name = load_setting("model_name") or os.getenv("MODEL_NAME", "gpt-4o-mini")
|
||||
tools_enabled = (load_setting("tools_enabled") or "true").lower() == "true"
|
||||
|
||||
cl.user_session.set("model_name", model_name)
|
||||
logger.debug(f"Model name: {model_name}")
|
||||
cl.user_session.set("tools_enabled", tools_enabled)
|
||||
logger.debug(f"Model name: {model_name}, Tools enabled: {tools_enabled}")
|
||||
|
||||
# Pre-create agent for faster first response
|
||||
_get_or_create_agent(model_name, tools_enabled)
|
||||
|
||||
settings = cl.ChatSettings(
|
||||
[
|
||||
TextInput(
|
||||
id="model_name",
|
||||
label="Enter the Model Name",
|
||||
placeholder="e.g., gpt-5-nano",
|
||||
placeholder="e.g., gpt-4o-mini",
|
||||
initial=model_name
|
||||
),
|
||||
Switch(
|
||||
id="tools_enabled",
|
||||
label="Enable Tools (ACP, LSP, Web Search)",
|
||||
initial=tools_enabled
|
||||
)
|
||||
]
|
||||
)
|
||||
cl.user_session.set("settings", settings)
|
||||
await settings.send()
|
||||
|
||||
# Show loaded tools info
|
||||
tools = _get_interactive_tools()
|
||||
tool_names = [t.__name__ for t in tools[:5]]
|
||||
if len(tools) > 5:
|
||||
tool_names.append(f"... and {len(tools) - 5} more")
|
||||
|
||||
await cl.Message(
|
||||
content=f"🚀 **PraisonAI Chat Ready**\n\n"
|
||||
f"**Model:** {model_name}\n"
|
||||
f"**Tools:** {len(tools)} loaded ({', '.join(tool_names)})\n"
|
||||
f"**Trust Mode:** Enabled (auto-approve tool executions)\n\n"
|
||||
f"Type your message to get started!"
|
||||
).send()
|
||||
|
||||
_profile_end("on_chat_start")
|
||||
|
||||
@cl.on_settings_update
|
||||
async def setup_agent(settings):
|
||||
json = _get_json()
|
||||
logger.debug(settings)
|
||||
cl.user_session.set("settings", settings)
|
||||
|
||||
model_name = settings["model_name"]
|
||||
tools_enabled = settings.get("tools_enabled", True)
|
||||
|
||||
cl.user_session.set("model_name", model_name)
|
||||
cl.user_session.set("tools_enabled", tools_enabled)
|
||||
|
||||
# Invalidate cached agent if model changed
|
||||
cached_model = cl.user_session.get("_cached_agent_model")
|
||||
if cached_model != model_name:
|
||||
cl.user_session.set("_cached_agent", None)
|
||||
cl.user_session.set("_cached_agent_model", None)
|
||||
|
||||
save_setting("model_name", model_name)
|
||||
save_setting("tools_enabled", str(tools_enabled).lower())
|
||||
|
||||
thread_id = cl.user_session.get("thread_id")
|
||||
if thread_id:
|
||||
@@ -285,17 +525,26 @@ async def setup_agent(settings):
|
||||
except json.JSONDecodeError:
|
||||
metadata = {}
|
||||
metadata["model_name"] = model_name
|
||||
metadata["tools_enabled"] = tools_enabled
|
||||
await cl_data.update_thread(thread_id, metadata=metadata)
|
||||
cl.user_session.set("metadata", metadata)
|
||||
|
||||
@cl.on_message
|
||||
async def main(message: cl.Message):
|
||||
model_name = load_setting("model_name") or os.getenv("MODEL_NAME", "gpt-5-nano")
|
||||
_profile_start("on_message_total")
|
||||
json = _get_json()
|
||||
asyncio = _get_asyncio()
|
||||
datetime = _get_datetime()
|
||||
|
||||
model_name = cl.user_session.get("model_name") or load_setting("model_name") or os.getenv("MODEL_NAME", "gpt-4o-mini")
|
||||
tools_enabled = cl.user_session.get("tools_enabled", True)
|
||||
message_history = cl.user_session.get("message_history", [])
|
||||
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
# Handle image uploads
|
||||
image = None
|
||||
if message.elements and isinstance(message.elements[0], cl.Image):
|
||||
Image = _get_pil_image()
|
||||
image_element = message.elements[0]
|
||||
try:
|
||||
image = Image.open(image_element.path)
|
||||
@@ -306,8 +555,7 @@ async def main(message: cl.Message):
|
||||
await cl.Message(content="Error processing the image. Please try again.").send()
|
||||
return
|
||||
|
||||
user_message = f"""
|
||||
Answer the question and use tools if needed:
|
||||
user_message = f"""Answer the question and use tools if needed:
|
||||
|
||||
Current Date and Time: {now}
|
||||
|
||||
@@ -320,6 +568,58 @@ User Question: {message.content}
|
||||
message_history.append({"role": "user", "content": user_message})
|
||||
msg = cl.Message(content="")
|
||||
|
||||
# Try PraisonAI Agent first (faster, with tool reuse)
|
||||
agent = _get_or_create_agent(model_name, tools_enabled) if tools_enabled else None
|
||||
|
||||
if agent is not None:
|
||||
_profile_start("agent_response")
|
||||
await msg.send()
|
||||
|
||||
try:
|
||||
# Use async chat for streaming
|
||||
result = await agent.achat(message.content)
|
||||
|
||||
# Get response text
|
||||
if hasattr(result, 'raw'):
|
||||
response_text = result.raw
|
||||
else:
|
||||
response_text = str(result)
|
||||
|
||||
# Stream in word chunks for better UX
|
||||
words = response_text.split(' ')
|
||||
for i, word in enumerate(words):
|
||||
token = word + (' ' if i < len(words) - 1 else '')
|
||||
await msg.stream_token(token)
|
||||
|
||||
msg.content = response_text
|
||||
await msg.update()
|
||||
|
||||
message_history.append({"role": "assistant", "content": response_text})
|
||||
cl.user_session.set("message_history", message_history)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Agent error: {e}")
|
||||
# Fallback to litellm
|
||||
await _handle_with_litellm(message, user_message, model_name, message_history, msg, image)
|
||||
|
||||
_profile_end("agent_response")
|
||||
else:
|
||||
# Fallback to litellm
|
||||
await _handle_with_litellm(message, user_message, model_name, message_history, msg, image)
|
||||
|
||||
_profile_end("on_message_total")
|
||||
|
||||
async def _handle_with_litellm(message, user_message, model_name, message_history, msg, image):
|
||||
"""Fallback handler using litellm for backward compatibility."""
|
||||
json = _get_json()
|
||||
asyncio = _get_asyncio()
|
||||
io = _get_io()
|
||||
base64 = _get_base64()
|
||||
acompletion = _get_acompletion()
|
||||
tools = _get_tools_list()
|
||||
|
||||
_profile_start("litellm_response")
|
||||
|
||||
completion_params = {
|
||||
"model": model_name,
|
||||
"messages": message_history,
|
||||
@@ -338,7 +638,6 @@ User Question: {message.content}
|
||||
]
|
||||
}
|
||||
|
||||
# Pass tools if we have any (Tavily or custom)
|
||||
if tools:
|
||||
completion_params["tools"] = tools
|
||||
completion_params["tool_choice"] = "auto"
|
||||
@@ -356,7 +655,6 @@ User Question: {message.content}
|
||||
|
||||
if 'content' in delta and delta['content'] is not None:
|
||||
token = delta['content']
|
||||
# Send message on first token to avoid delay
|
||||
if not msg_sent:
|
||||
await msg.send()
|
||||
msg_sent = True
|
||||
@@ -386,28 +684,25 @@ User Question: {message.content}
|
||||
if current_tool_call:
|
||||
tool_calls.append(current_tool_call)
|
||||
|
||||
# Ensure message is sent even if no content (e.g., tool calls only)
|
||||
if not msg_sent:
|
||||
await msg.send()
|
||||
|
||||
logger.debug(f"Full response: {full_response}")
|
||||
logger.debug(f"Tool calls: {tool_calls}")
|
||||
message_history.append({"role": "assistant", "content": full_response})
|
||||
logger.debug(f"Message history: {message_history}")
|
||||
cl.user_session.set("message_history", message_history)
|
||||
await msg.update()
|
||||
|
||||
if tool_calls and tools: # Check if we have any tools and tool calls
|
||||
# Handle tool calls
|
||||
if tool_calls and tools:
|
||||
custom_tools = _get_custom_tools()
|
||||
available_functions = {}
|
||||
|
||||
# Add Tavily function if available
|
||||
if tavily_api_key:
|
||||
if os.getenv("TAVILY_API_KEY"):
|
||||
available_functions["tavily_web_search"] = tavily_web_search
|
||||
|
||||
# Add all custom tool functions from globals
|
||||
for tool_name in custom_tools_dict:
|
||||
for tool_name in custom_tools:
|
||||
if tool_name in globals():
|
||||
available_functions[tool_name] = globals()[tool_name]
|
||||
|
||||
messages = message_history + [{"role": "assistant", "content": None, "function_call": {
|
||||
"name": tool_calls[0]['function']['name'],
|
||||
"arguments": tool_calls[0]['function']['arguments']
|
||||
@@ -422,49 +717,37 @@ User Question: {message.content}
|
||||
try:
|
||||
function_args = json.loads(function_args)
|
||||
|
||||
# Call function based on whether it's async or sync
|
||||
if asyncio.iscoroutinefunction(function_to_call):
|
||||
# For async functions like tavily_web_search
|
||||
if function_name == "tavily_web_search":
|
||||
function_response = await function_to_call(
|
||||
query=function_args.get("query"),
|
||||
)
|
||||
function_response = await function_to_call(query=function_args.get("query"))
|
||||
else:
|
||||
# For custom async functions, pass all arguments
|
||||
function_response = await function_to_call(**function_args)
|
||||
else:
|
||||
# For sync functions (most custom tools)
|
||||
function_response = function_to_call(**function_args)
|
||||
|
||||
# Convert response to string if needed
|
||||
if not isinstance(function_response, str):
|
||||
function_response = json.dumps(function_response)
|
||||
|
||||
messages.append(
|
||||
{
|
||||
"role": "function",
|
||||
"name": function_name,
|
||||
"content": function_response,
|
||||
}
|
||||
)
|
||||
messages.append({
|
||||
"role": "function",
|
||||
"name": function_name,
|
||||
"content": function_response,
|
||||
})
|
||||
except json.JSONDecodeError:
|
||||
logger.error(f"Failed to parse function arguments: {function_args}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error calling function {function_name}: {str(e)}")
|
||||
messages.append(
|
||||
{
|
||||
"role": "function",
|
||||
"name": function_name,
|
||||
"content": f"Error: {str(e)}",
|
||||
}
|
||||
)
|
||||
messages.append({
|
||||
"role": "function",
|
||||
"name": function_name,
|
||||
"content": f"Error: {str(e)}",
|
||||
})
|
||||
|
||||
second_response = await acompletion(
|
||||
model=model_name,
|
||||
stream=True,
|
||||
messages=messages,
|
||||
)
|
||||
logger.debug(f"Second LLM response: {second_response}")
|
||||
|
||||
full_response = ""
|
||||
async for part in second_response:
|
||||
@@ -480,25 +763,41 @@ User Question: {message.content}
|
||||
else:
|
||||
msg.content = full_response
|
||||
await msg.update()
|
||||
|
||||
_profile_end("litellm_response")
|
||||
|
||||
@cl.on_chat_resume
|
||||
async def on_chat_resume(thread: ThreadDict):
|
||||
json = _get_json()
|
||||
io = _get_io()
|
||||
base64 = _get_base64()
|
||||
Image = _get_pil_image()
|
||||
|
||||
logger.info(f"Resuming chat: {thread['id']}")
|
||||
model_name = load_setting("model_name") or os.getenv("MODEL_NAME", "gpt-5-nano")
|
||||
model_name = load_setting("model_name") or os.getenv("MODEL_NAME", "gpt-4o-mini")
|
||||
tools_enabled = (load_setting("tools_enabled") or "true").lower() == "true"
|
||||
|
||||
logger.debug(f"Model name: {model_name}")
|
||||
settings = cl.ChatSettings(
|
||||
[
|
||||
TextInput(
|
||||
id="model_name",
|
||||
label="Enter the Model Name",
|
||||
placeholder="e.g., gpt-5-nano",
|
||||
placeholder="e.g., gpt-4o-mini",
|
||||
initial=model_name
|
||||
),
|
||||
Switch(
|
||||
id="tools_enabled",
|
||||
label="Enable Tools (ACP, LSP, Web Search)",
|
||||
initial=tools_enabled
|
||||
)
|
||||
]
|
||||
)
|
||||
await settings.send()
|
||||
thread_id = thread["id"]
|
||||
cl.user_session.set("thread_id", thread_id)
|
||||
|
||||
cl.user_session.set("thread_id", thread["id"])
|
||||
cl.user_session.set("model_name", model_name)
|
||||
cl.user_session.set("tools_enabled", tools_enabled)
|
||||
|
||||
metadata = thread.get("metadata", {})
|
||||
if isinstance(metadata, str):
|
||||
@@ -524,6 +823,9 @@ async def on_chat_resume(thread: ThreadDict):
|
||||
logger.warning(f"Message without recognized type: {m}")
|
||||
|
||||
cl.user_session.set("message_history", message_history)
|
||||
|
||||
# Pre-create agent for faster first response
|
||||
_get_or_create_agent(model_name, tools_enabled)
|
||||
|
||||
image_data = metadata.get("image")
|
||||
if image_data:
|
||||
|
||||
+638
-539
File diff suppressed because it is too large.
Load diff
@@ -1 +1 @@
|
||||
__version__ = "3.0.2"
|
||||
__version__ = "3.1.2"
|
||||
@@ -12,7 +12,7 @@ dependencies = [
|
||||
"rich>=13.7",
|
||||
"markdown>=3.5",
|
||||
"pyparsing>=3.0.0",
|
||||
"praisonaiagents>=0.6.0",
|
||||
"praisonaiagents>=0.6.2",
|
||||
"python-dotenv>=0.19.0",
|
||||
"instructor>=1.3.3",
|
||||
"PyYAML>=6.0",
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
"""
|
||||
Unit tests for ACP Safe Edit Pipeline.
|
||||
"""
|
||||
|
||||
import tempfile
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
|
||||
from praisonai.acp.safe_edit import (
|
||||
EditProposal,
|
||||
EditStatus,
|
||||
SafeEditPipeline,
|
||||
get_safe_edit_pipeline,
|
||||
)
|
||||
|
||||
|
||||
class TestEditProposal:
|
||||
"""Tests for EditProposal class."""
|
||||
|
||||
def test_create_proposal(self):
|
||||
"""Test creating an edit proposal."""
|
||||
proposal = EditProposal(
|
||||
proposal_id="test-123",
|
||||
file_path=Path("/tmp/test.txt"),
|
||||
original_content="Hello",
|
||||
proposed_content="Hello, World!",
|
||||
description="Add greeting",
|
||||
)
|
||||
|
||||
assert proposal.proposal_id == "test-123"
|
||||
assert proposal.status == EditStatus.PENDING
|
||||
assert len(proposal.diff_lines) > 0
|
||||
|
||||
def test_diff_generation(self):
|
||||
"""Test diff is generated correctly."""
|
||||
proposal = EditProposal(
|
||||
proposal_id="test-123",
|
||||
file_path=Path("/tmp/test.txt"),
|
||||
original_content="line1\nline2\nline3",
|
||||
proposed_content="line1\nmodified\nline3",
|
||||
description="Modify line 2",
|
||||
)
|
||||
|
||||
diff_text = proposal.diff_text
|
||||
assert "-line2" in diff_text
|
||||
assert "+modified" in diff_text
|
||||
|
||||
def test_is_new_file(self):
|
||||
"""Test new file detection."""
|
||||
proposal = EditProposal(
|
||||
proposal_id="test-123",
|
||||
file_path=Path("/tmp/new.txt"),
|
||||
original_content="",
|
||||
proposed_content="New content",
|
||||
description="Create new file",
|
||||
)
|
||||
|
||||
assert proposal.is_new_file is True
|
||||
assert proposal.is_deletion is False
|
||||
|
||||
def test_is_deletion(self):
|
||||
"""Test deletion detection."""
|
||||
proposal = EditProposal(
|
||||
proposal_id="test-123",
|
||||
file_path=Path("/tmp/delete.txt"),
|
||||
original_content="Content to delete",
|
||||
proposed_content="",
|
||||
description="Delete file",
|
||||
)
|
||||
|
||||
assert proposal.is_deletion is True
|
||||
assert proposal.is_new_file is False
|
||||
|
||||
def test_to_dict(self):
|
||||
"""Test converting proposal to dictionary."""
|
||||
proposal = EditProposal(
|
||||
proposal_id="test-123",
|
||||
file_path=Path("/tmp/test.txt"),
|
||||
original_content="Hello",
|
||||
proposed_content="Hello, World!",
|
||||
description="Add greeting",
|
||||
)
|
||||
|
||||
data = proposal.to_dict()
|
||||
|
||||
assert data["proposal_id"] == "test-123"
|
||||
assert data["description"] == "Add greeting"
|
||||
assert data["status"] == "pending"
|
||||
assert "diff" in data
|
||||
|
||||
|
||||
class TestSafeEditPipeline:
|
||||
"""Tests for SafeEditPipeline class."""
|
||||
|
||||
@pytest.fixture
|
||||
def temp_workspace(self):
|
||||
"""Create a temporary workspace."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
yield Path(tmpdir)
|
||||
|
||||
@pytest.fixture
|
||||
def pipeline(self, temp_workspace):
|
||||
"""Create a pipeline with temp workspace."""
|
||||
return SafeEditPipeline(workspace=temp_workspace)
|
||||
|
||||
def test_create_pipeline(self, temp_workspace):
|
||||
"""Test creating a pipeline."""
|
||||
pipeline = SafeEditPipeline(workspace=temp_workspace)
|
||||
|
||||
assert pipeline.workspace == temp_workspace
|
||||
assert pipeline.auto_approve is False
|
||||
|
||||
def test_propose_edit(self, pipeline, temp_workspace):
|
||||
"""Test proposing an edit."""
|
||||
# Create a file to edit
|
||||
test_file = temp_workspace / "test.txt"
|
||||
test_file.write_text("Original content")
|
||||
|
||||
proposal = pipeline.propose_edit(
|
||||
file_path=test_file,
|
||||
new_content="Modified content",
|
||||
description="Test edit",
|
||||
)
|
||||
|
||||
assert proposal.status == EditStatus.PENDING
|
||||
assert proposal.original_content == "Original content"
|
||||
assert proposal.proposed_content == "Modified content"
|
||||
|
||||
def test_propose_create(self, pipeline, temp_workspace):
|
||||
"""Test proposing a new file."""
|
||||
new_file = temp_workspace / "new.txt"
|
||||
|
||||
proposal = pipeline.propose_create(
|
||||
file_path=new_file,
|
||||
content="New file content",
|
||||
description="Create new file",
|
||||
)
|
||||
|
||||
assert proposal.is_new_file is True
|
||||
assert proposal.status == EditStatus.PENDING
|
||||
|
||||
def test_propose_delete(self, pipeline, temp_workspace):
|
||||
"""Test proposing a deletion."""
|
||||
# Create a file to delete
|
||||
test_file = temp_workspace / "delete.txt"
|
||||
test_file.write_text("Content to delete")
|
||||
|
||||
proposal = pipeline.propose_delete(
|
||||
file_path=test_file,
|
||||
description="Delete file",
|
||||
)
|
||||
|
||||
assert proposal.is_deletion is True
|
||||
assert proposal.status == EditStatus.PENDING
|
||||
|
||||
def test_approve_proposal(self, pipeline, temp_workspace):
|
||||
"""Test approving a proposal."""
|
||||
test_file = temp_workspace / "test.txt"
|
||||
test_file.write_text("Original")
|
||||
|
||||
proposal = pipeline.propose_edit(
|
||||
file_path=test_file,
|
||||
new_content="Modified",
|
||||
description="Test",
|
||||
)
|
||||
|
||||
result = pipeline.approve(proposal.proposal_id)
|
||||
|
||||
assert result is True
|
||||
assert proposal.status == EditStatus.APPROVED
|
||||
assert proposal.approved_at is not None
|
||||
|
||||
def test_reject_proposal(self, pipeline, temp_workspace):
|
||||
"""Test rejecting a proposal."""
|
||||
test_file = temp_workspace / "test.txt"
|
||||
test_file.write_text("Original")
|
||||
|
||||
proposal = pipeline.propose_edit(
|
||||
file_path=test_file,
|
||||
new_content="Modified",
|
||||
description="Test",
|
||||
)
|
||||
|
||||
result = pipeline.reject(proposal.proposal_id, "Not needed")
|
||||
|
||||
assert result is True
|
||||
assert proposal.status == EditStatus.REJECTED
|
||||
assert proposal.error == "Not needed"
|
||||
|
||||
def test_apply_approved_edit(self, pipeline, temp_workspace):
|
||||
"""Test applying an approved edit."""
|
||||
test_file = temp_workspace / "test.txt"
|
||||
test_file.write_text("Original content")
|
||||
|
||||
proposal = pipeline.propose_edit(
|
||||
file_path=test_file,
|
||||
new_content="Modified content",
|
||||
description="Test",
|
||||
)
|
||||
|
||||
pipeline.approve(proposal.proposal_id)
|
||||
result = pipeline.apply_edit(proposal.proposal_id)
|
||||
|
||||
assert result is True
|
||||
assert proposal.status == EditStatus.APPLIED
|
||||
assert test_file.read_text() == "Modified content"
|
||||
|
||||
def test_apply_unapproved_fails(self, pipeline, temp_workspace):
|
||||
"""Test that applying unapproved edit fails."""
|
||||
test_file = temp_workspace / "test.txt"
|
||||
test_file.write_text("Original")
|
||||
|
||||
proposal = pipeline.propose_edit(
|
||||
file_path=test_file,
|
||||
new_content="Modified",
|
||||
description="Test",
|
||||
)
|
||||
|
||||
# Don't approve
|
||||
result = pipeline.apply_edit(proposal.proposal_id)
|
||||
|
||||
assert result is False
|
||||
assert test_file.read_text() == "Original" # Unchanged
|
||||
|
||||
def test_apply_creates_new_file(self, pipeline, temp_workspace):
|
||||
"""Test applying a new file creation."""
|
||||
new_file = temp_workspace / "subdir" / "new.txt"
|
||||
|
||||
proposal = pipeline.propose_create(
|
||||
file_path=new_file,
|
||||
content="New content",
|
||||
description="Create file",
|
||||
)
|
||||
|
||||
pipeline.approve(proposal.proposal_id)
|
||||
result = pipeline.apply_edit(proposal.proposal_id)
|
||||
|
||||
assert result is True
|
||||
assert new_file.exists()
|
||||
assert new_file.read_text() == "New content"
|
||||
|
||||
def test_apply_deletes_file(self, pipeline, temp_workspace):
|
||||
"""Test applying a file deletion."""
|
||||
test_file = temp_workspace / "delete.txt"
|
||||
test_file.write_text("To delete")
|
||||
|
||||
proposal = pipeline.propose_delete(
|
||||
file_path=test_file,
|
||||
description="Delete",
|
||||
)
|
||||
|
||||
pipeline.approve(proposal.proposal_id)
|
||||
result = pipeline.apply_edit(proposal.proposal_id)
|
||||
|
||||
assert result is True
|
||||
assert not test_file.exists()
|
||||
|
||||
def test_path_outside_workspace_rejected(self, pipeline, temp_workspace):
|
||||
"""Test that paths outside workspace are rejected."""
|
||||
with pytest.raises(ValueError, match="outside workspace"):
|
||||
pipeline.propose_edit(
|
||||
file_path=Path("/etc/passwd"),
|
||||
new_content="hacked",
|
||||
description="Bad edit",
|
||||
)
|
||||
|
||||
def test_list_proposals(self, pipeline, temp_workspace):
|
||||
"""Test listing proposals."""
|
||||
test_file = temp_workspace / "test.txt"
|
||||
test_file.write_text("Original")
|
||||
|
||||
pipeline.propose_edit(test_file, "Edit 1", "First")
|
||||
pipeline.propose_edit(test_file, "Edit 2", "Second")
|
||||
|
||||
proposals = pipeline.list_proposals()
|
||||
|
||||
assert len(proposals) == 2
|
||||
|
||||
def test_list_proposals_by_status(self, pipeline, temp_workspace):
|
||||
"""Test listing proposals filtered by status."""
|
||||
test_file = temp_workspace / "test.txt"
|
||||
test_file.write_text("Original")
|
||||
|
||||
p1 = pipeline.propose_edit(test_file, "Edit 1", "First")
|
||||
pipeline.propose_edit(test_file, "Edit 2", "Second")
|
||||
|
||||
pipeline.approve(p1.proposal_id)
|
||||
|
||||
pending = pipeline.list_proposals(status=EditStatus.PENDING)
|
||||
approved = pipeline.list_proposals(status=EditStatus.APPROVED)
|
||||
|
||||
assert len(pending) == 1
|
||||
assert len(approved) == 1
|
||||
|
||||
def test_auto_approve_mode(self, temp_workspace):
|
||||
"""Test auto-approve mode."""
|
||||
pipeline = SafeEditPipeline(workspace=temp_workspace, auto_approve=True)
|
||||
|
||||
test_file = temp_workspace / "test.txt"
|
||||
test_file.write_text("Original")
|
||||
|
||||
proposal = pipeline.propose_and_apply(
|
||||
file_path=test_file,
|
||||
new_content="Auto-modified",
|
||||
description="Auto edit",
|
||||
)
|
||||
|
||||
assert proposal.status == EditStatus.APPLIED
|
||||
assert test_file.read_text() == "Auto-modified"
|
||||
|
||||
def test_approval_callback(self, temp_workspace):
|
||||
"""Test approval callback."""
|
||||
approved_proposals = []
|
||||
|
||||
def callback(proposal):
|
||||
approved_proposals.append(proposal.proposal_id)
|
||||
return True
|
||||
|
||||
pipeline = SafeEditPipeline(
|
||||
workspace=temp_workspace,
|
||||
approval_callback=callback,
|
||||
)
|
||||
|
||||
test_file = temp_workspace / "test.txt"
|
||||
test_file.write_text("Original")
|
||||
|
||||
proposal = pipeline.propose_and_apply(
|
||||
file_path=test_file,
|
||||
new_content="Callback-modified",
|
||||
description="Callback edit",
|
||||
)
|
||||
|
||||
assert proposal.proposal_id in approved_proposals
|
||||
assert proposal.status == EditStatus.APPLIED
|
||||
|
||||
def test_clear_proposals(self, pipeline, temp_workspace):
|
||||
"""Test clearing proposals."""
|
||||
test_file = temp_workspace / "test.txt"
|
||||
test_file.write_text("Original")
|
||||
|
||||
pipeline.propose_edit(test_file, "Edit 1", "First")
|
||||
pipeline.propose_edit(test_file, "Edit 2", "Second")
|
||||
|
||||
count = pipeline.clear_proposals()
|
||||
|
||||
assert count == 2
|
||||
assert len(pipeline.list_proposals()) == 0
|
||||
|
||||
def test_format_diff_for_display(self, pipeline, temp_workspace):
|
||||
"""Test formatting diff for display."""
|
||||
test_file = temp_workspace / "test.txt"
|
||||
test_file.write_text("line1\nline2\nline3")
|
||||
|
||||
proposal = pipeline.propose_edit(
|
||||
file_path=test_file,
|
||||
new_content="line1\nmodified\nline3",
|
||||
description="Modify line 2",
|
||||
)
|
||||
|
||||
display = pipeline.format_diff_for_display(proposal)
|
||||
|
||||
assert "Edit Proposal" in display
|
||||
assert "Modify line 2" in display
|
||||
assert "Diff" in display
|
||||
|
||||
|
||||
class TestGlobalPipeline:
|
||||
"""Tests for global pipeline instance."""
|
||||
|
||||
def test_get_safe_edit_pipeline(self):
|
||||
"""Test getting global pipeline."""
|
||||
pipeline1 = get_safe_edit_pipeline()
|
||||
pipeline2 = get_safe_edit_pipeline()
|
||||
|
||||
# Should return same instance
|
||||
assert pipeline1 is pipeline2
|
||||
@@ -0,0 +1,276 @@
|
||||
"""
|
||||
Unit tests for UnifiedSession and UnifiedSessionStore.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
|
||||
from praisonai.cli.session.unified import (
|
||||
UnifiedSession,
|
||||
UnifiedSessionStore,
|
||||
get_session_store,
|
||||
)
|
||||
|
||||
|
||||
class TestUnifiedSession:
|
||||
"""Tests for UnifiedSession class."""
|
||||
|
||||
def test_create_session(self):
|
||||
"""Test creating a new session."""
|
||||
session = UnifiedSession(session_id="test-123")
|
||||
|
||||
assert session.session_id == "test-123"
|
||||
assert session.messages == []
|
||||
assert session.total_input_tokens == 0
|
||||
assert session.total_output_tokens == 0
|
||||
assert session.request_count == 0
|
||||
|
||||
def test_add_user_message(self):
|
||||
"""Test adding a user message."""
|
||||
session = UnifiedSession(session_id="test-123")
|
||||
session.add_user_message("Hello, world!")
|
||||
|
||||
assert len(session.messages) == 1
|
||||
assert session.messages[0]["role"] == "user"
|
||||
assert session.messages[0]["content"] == "Hello, world!"
|
||||
assert "timestamp" in session.messages[0]
|
||||
|
||||
def test_add_assistant_message(self):
|
||||
"""Test adding an assistant message."""
|
||||
session = UnifiedSession(session_id="test-123")
|
||||
session.add_assistant_message("Hello! How can I help?")
|
||||
|
||||
assert len(session.messages) == 1
|
||||
assert session.messages[0]["role"] == "assistant"
|
||||
assert session.messages[0]["content"] == "Hello! How can I help?"
|
||||
|
||||
def test_get_chat_history(self):
|
||||
"""Test getting chat history in LLM format."""
|
||||
session = UnifiedSession(session_id="test-123")
|
||||
session.add_user_message("Hello")
|
||||
session.add_assistant_message("Hi there!")
|
||||
session.add_user_message("How are you?")
|
||||
|
||||
history = session.get_chat_history()
|
||||
|
||||
assert len(history) == 3
|
||||
assert history[0] == {"role": "user", "content": "Hello"}
|
||||
assert history[1] == {"role": "assistant", "content": "Hi there!"}
|
||||
assert history[2] == {"role": "user", "content": "How are you?"}
|
||||
|
||||
def test_get_chat_history_with_limit(self):
|
||||
"""Test getting limited chat history."""
|
||||
session = UnifiedSession(session_id="test-123")
|
||||
for i in range(10):
|
||||
session.add_user_message(f"Message {i}")
|
||||
|
||||
history = session.get_chat_history(max_messages=3)
|
||||
|
||||
assert len(history) == 3
|
||||
assert history[0]["content"] == "Message 7"
|
||||
assert history[2]["content"] == "Message 9"
|
||||
|
||||
def test_update_stats(self):
|
||||
"""Test updating token statistics."""
|
||||
session = UnifiedSession(session_id="test-123")
|
||||
session.update_stats(100, 50, 0.01)
|
||||
session.update_stats(200, 100, 0.02)
|
||||
|
||||
assert session.total_input_tokens == 300
|
||||
assert session.total_output_tokens == 150
|
||||
assert session.total_cost == 0.03
|
||||
assert session.request_count == 2
|
||||
|
||||
def test_clear_messages(self):
|
||||
"""Test clearing messages."""
|
||||
session = UnifiedSession(session_id="test-123")
|
||||
session.add_user_message("Hello")
|
||||
session.add_assistant_message("Hi")
|
||||
session.clear_messages()
|
||||
|
||||
assert len(session.messages) == 0
|
||||
|
||||
def test_message_count(self):
|
||||
"""Test message count property."""
|
||||
session = UnifiedSession(session_id="test-123")
|
||||
assert session.message_count == 0
|
||||
|
||||
session.add_user_message("Hello")
|
||||
session.add_assistant_message("Hi")
|
||||
|
||||
assert session.message_count == 2
|
||||
|
||||
def test_user_message_count(self):
|
||||
"""Test user message count property."""
|
||||
session = UnifiedSession(session_id="test-123")
|
||||
session.add_user_message("Hello")
|
||||
session.add_assistant_message("Hi")
|
||||
session.add_user_message("How are you?")
|
||||
|
||||
assert session.user_message_count == 2
|
||||
|
||||
def test_to_dict(self):
|
||||
"""Test converting session to dictionary."""
|
||||
session = UnifiedSession(session_id="test-123")
|
||||
session.add_user_message("Hello")
|
||||
|
||||
data = session.to_dict()
|
||||
|
||||
assert data["session_id"] == "test-123"
|
||||
assert len(data["messages"]) == 1
|
||||
assert "created_at" in data
|
||||
assert "updated_at" in data
|
||||
|
||||
def test_from_dict(self):
|
||||
"""Test creating session from dictionary."""
|
||||
data = {
|
||||
"session_id": "test-456",
|
||||
"workspace": "/tmp/test",
|
||||
"created_at": "2024-01-01T00:00:00",
|
||||
"updated_at": "2024-01-01T00:00:00",
|
||||
"messages": [{"role": "user", "content": "Hello", "timestamp": "2024-01-01T00:00:00"}],
|
||||
"metadata": {},
|
||||
"total_input_tokens": 100,
|
||||
"total_output_tokens": 50,
|
||||
"total_cost": 0.01,
|
||||
"request_count": 1,
|
||||
"current_model": "gpt-4o-mini",
|
||||
}
|
||||
|
||||
session = UnifiedSession.from_dict(data)
|
||||
|
||||
assert session.session_id == "test-456"
|
||||
assert session.total_input_tokens == 100
|
||||
assert len(session.messages) == 1
|
||||
|
||||
|
||||
class TestUnifiedSessionStore:
|
||||
"""Tests for UnifiedSessionStore class."""
|
||||
|
||||
@pytest.fixture
|
||||
def temp_session_dir(self):
|
||||
"""Create a temporary session directory."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
yield Path(tmpdir)
|
||||
|
||||
def test_create_store(self, temp_session_dir):
|
||||
"""Test creating a session store."""
|
||||
store = UnifiedSessionStore(session_dir=temp_session_dir)
|
||||
|
||||
assert store.session_dir == temp_session_dir
|
||||
assert temp_session_dir.exists()
|
||||
|
||||
def test_save_and_load_session(self, temp_session_dir):
|
||||
"""Test saving and loading a session."""
|
||||
store = UnifiedSessionStore(session_dir=temp_session_dir)
|
||||
|
||||
session = UnifiedSession(session_id="test-123")
|
||||
session.add_user_message("Hello")
|
||||
session.add_assistant_message("Hi there!")
|
||||
|
||||
store.save(session)
|
||||
|
||||
# Clear cache to force reload from disk
|
||||
store._cache.clear()
|
||||
|
||||
loaded = store.load("test-123")
|
||||
|
||||
assert loaded is not None
|
||||
assert loaded.session_id == "test-123"
|
||||
assert len(loaded.messages) == 2
|
||||
assert loaded.messages[0]["content"] == "Hello"
|
||||
|
||||
def test_get_or_create_new(self, temp_session_dir):
|
||||
"""Test get_or_create with new session."""
|
||||
store = UnifiedSessionStore(session_dir=temp_session_dir)
|
||||
|
||||
session = store.get_or_create("new-session")
|
||||
|
||||
assert session.session_id == "new-session"
|
||||
assert (temp_session_dir / "new-session.json").exists()
|
||||
|
||||
def test_get_or_create_existing(self, temp_session_dir):
|
||||
"""Test get_or_create with existing session."""
|
||||
store = UnifiedSessionStore(session_dir=temp_session_dir)
|
||||
|
||||
# Create and save a session
|
||||
original = UnifiedSession(session_id="existing")
|
||||
original.add_user_message("Original message")
|
||||
store.save(original)
|
||||
|
||||
# Clear cache
|
||||
store._cache.clear()
|
||||
|
||||
# Get or create should return existing
|
||||
session = store.get_or_create("existing")
|
||||
|
||||
assert session.session_id == "existing"
|
||||
assert len(session.messages) == 1
|
||||
assert session.messages[0]["content"] == "Original message"
|
||||
|
||||
def test_delete_session(self, temp_session_dir):
|
||||
"""Test deleting a session."""
|
||||
store = UnifiedSessionStore(session_dir=temp_session_dir)
|
||||
|
||||
session = store.get_or_create("to-delete")
|
||||
assert (temp_session_dir / "to-delete.json").exists()
|
||||
|
||||
result = store.delete("to-delete")
|
||||
|
||||
assert result is True
|
||||
assert not (temp_session_dir / "to-delete.json").exists()
|
||||
|
||||
def test_list_sessions(self, temp_session_dir):
|
||||
"""Test listing sessions."""
|
||||
store = UnifiedSessionStore(session_dir=temp_session_dir)
|
||||
|
||||
# Create multiple sessions
|
||||
for i in range(3):
|
||||
session = store.get_or_create(f"session-{i}")
|
||||
session.add_user_message(f"Message {i}")
|
||||
store.save(session)
|
||||
|
||||
sessions = store.list_sessions()
|
||||
|
||||
assert len(sessions) == 3
|
||||
assert all("session_id" in s for s in sessions)
|
||||
assert all("message_count" in s for s in sessions)
|
||||
|
||||
def test_last_session(self, temp_session_dir):
|
||||
"""Test last session tracking."""
|
||||
store = UnifiedSessionStore(session_dir=temp_session_dir)
|
||||
|
||||
# Create sessions
|
||||
store.get_or_create("first")
|
||||
store.get_or_create("second")
|
||||
|
||||
last_id = store.get_last_session_id()
|
||||
assert last_id == "second"
|
||||
|
||||
last_session = store.get_last_session()
|
||||
assert last_session is not None
|
||||
assert last_session.session_id == "second"
|
||||
|
||||
def test_load_nonexistent(self, temp_session_dir):
|
||||
"""Test loading a nonexistent session."""
|
||||
store = UnifiedSessionStore(session_dir=temp_session_dir)
|
||||
|
||||
session = store.load("nonexistent")
|
||||
|
||||
assert session is None
|
||||
|
||||
|
||||
class TestGlobalSessionStore:
|
||||
"""Tests for global session store."""
|
||||
|
||||
def test_get_session_store(self):
|
||||
"""Test getting global session store."""
|
||||
store1 = get_session_store()
|
||||
store2 = get_session_store()
|
||||
|
||||
# Should return same instance
|
||||
assert store1 is store2
|
||||
Generated
+4
-4
@@ -4276,7 +4276,7 @@ requires-dist = [
|
||||
{ name = "praisonai-tools", marker = "extra == 'autogen'", specifier = ">=0.1.0" },
|
||||
{ name = "praisonai-tools", marker = "extra == 'autogen-v4'", specifier = ">=0.1.0" },
|
||||
{ name = "praisonai-tools", marker = "extra == 'crewai'", specifier = ">=0.1.0" },
|
||||
{ name = "praisonaiagents", specifier = ">=0.6.0" },
|
||||
{ name = "praisonaiagents", specifier = ">=0.6.2" },
|
||||
{ name = "pyautogen", marker = "extra == 'autogen'", specifier = "==0.2.29" },
|
||||
{ name = "pydantic", marker = "extra == 'chat'", specifier = "<=2.10.1" },
|
||||
{ name = "pydantic", marker = "extra == 'code'", specifier = "<=2.10.1" },
|
||||
@@ -4324,7 +4324,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "praisonaiagents"
|
||||
version = "0.6.0"
|
||||
version = "0.6.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
@@ -4333,9 +4333,9 @@ dependencies = [
|
||||
{ name = "pydantic" },
|
||||
{ name = "rich" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/4b/3c/a25740d7e3da4ebea0d83319d412650a016d91b6eb2f2653df1b28a3882f/praisonaiagents-0.6.0.tar.gz", hash = "sha256:f83528f6f1c0adea17cea25a1ea93f660e46ba43002e354717501e82112d9a96", size = 780850, upload-time = "2025-12-31T18:50:35.35Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ea/d0/42418977331fb9a019331c49ee2eaf66c6dc6967725592a6a8bfb7e529ca/praisonaiagents-0.6.2.tar.gz", hash = "sha256:36d48b4c8b2915671033a05bb3983c4e613acdcc2fd8b3547271a6102f0c0928", size = 785586, upload-time = "2026-01-02T05:42:43.766Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/18/f4f8360024ef26806831dde4ba3dad23950a4d94dde86085dde510ef1833/praisonaiagents-0.6.0-py3-none-any.whl", hash = "sha256:416c4550b6460b0866c1d59b284aacb86f23d849b38e75542f7056e530e84d80", size = 724880, upload-time = "2025-12-31T18:50:33.21Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/aa/b3a6daeca388a975d4d21fc3e93847be2379726c5e7428d4efc28a31238d/praisonaiagents-0.6.2-py3-none-any.whl", hash = "sha256:440e45851dab829e9019b0ab35d80a2fa5d7c83a5aadb7269412cc119ede7890", size = 730473, upload-time = "2026-01-02T05:42:41.989Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Reference in new issue
Block a user