Compare commits

...
10 Commits
Author SHA1 Message Date
Mervin Praison f5d4db4262 Release v3.1.2 2026-01-02 08:17:39 +00:00
Mervin Praison 87d691c616 Release v3.1.1 2026-01-02 07:54:02 +00:00
Mervin Praison 8ea15dee0f Release v3.1.0 2026-01-02 07:35:57 +00:00
Mervin Praison 4f56842e08 Release v3.0.9 2026-01-02 07:22:27 +00:00
Mervin Praison a24836aaee Release v3.0.8 2026-01-02 06:59:48 +00:00
Mervin Praison be9ce27615 Release v3.0.7 2026-01-02 06:43:56 +00:00
Mervin Praison 0159a1e082 Release v3.0.6 2026-01-02 06:18:14 +00:00
Mervin Praison 71a5076c07 Release v3.0.5 2026-01-02 06:06:01 +00:00
Mervin Praison 9815a9f8a8 Release v3.0.4 2026-01-02 05:45:18 +00:00
Mervin Praison 2ae44a05b0 Release v3.0.3 2026-01-02 05:17:33 +00:00
67 changed files with 8578 additions and 1086 deletions

No files matched your search

+1 -1
View File
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=3.0.2" \
"praisonai>=3.1.2" \
"praisonai[chat]" \
"embedchain[github,youtube]"
+1 -1
View File
@@ -20,7 +20,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=3.0.2" \
"praisonai>=3.1.2" \
"praisonai[ui]" \
"praisonai[chat]" \
"praisonai[realtime]" \
+1 -1
View File
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=3.0.2" \
"praisonai>=3.1.2" \
"praisonai[ui]" \
"praisonai[crewai]"
+146
View File
@@ -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)
+22 -13
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -2422,7 +2422,7 @@ wheels = [
[[package]]
name = "praisonaiagents"
version = "0.6.0"
version = "0.6.2"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },
+2 -2
View File
@@ -3,8 +3,8 @@ class Praisonai < Formula
desc "AI tools for various AI applications"
homepage "https://github.com/MervinPraison/PraisonAI"
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/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]"
+442
View File
@@ -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
+60 -2
View File
@@ -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
+7 -1
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -57,7 +57,7 @@ class CloudDeployer:
file.write("FROM python:3.11-slim\n")
file.write("WORKDIR /app\n")
file.write("COPY . .\n")
file.write("RUN pip install flask praisonai==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}")
+11 -1
View File
@@ -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"]
+50 -52
View File
@@ -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
View File
@@ -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:
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -1 +1 @@
__version__ = "3.0.2"
__version__ = "3.1.2"
+1 -1
View File
@@ -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
+4 -4
View File
@@ -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]]