mirror of
https://github.com/MervinPraison/PraisonAI.git
synced 2026-11-11 03:31:27 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1cad345415 | ||
|
|
802e7e52f9 | ||
|
|
53704f234c | ||
|
|
b575994d91 | ||
|
|
7ed8596261 | ||
|
|
deef65c4bb | ||
|
|
d51327effd | ||
|
|
30ce8857e8 |
No files matched your search
@@ -252,20 +252,20 @@ npm install praisonai
|
||||
| 🪝 Hooks | [Example](#9-hooks) | [📖](https://docs.praison.ai/features/hooks) |
|
||||
| 📈 Telemetry | [Example](examples/python/telemetry/production-telemetry-example.py) | [📖](https://docs.praison.ai/features/telemetry) |
|
||||
| 📹 Camera Integration | [Example](examples/python/camera/) | [📖](https://docs.praison.ai/features/camera-integration) |
|
||||
| 📄 Project Docs (.praison/docs/) | [Example](#docs-cli) | [📖](https://docs.praison.ai/cli/docs) |
|
||||
| 🔌 MCP Config Management | [Example](#mcp-config-cli) | [📖](https://docs.praison.ai/cli/mcp) |
|
||||
| 💬 AI Commit Messages | [Example](#ai-commit-cli) | [📖](https://docs.praison.ai/cli/commit) |
|
||||
| @ @Mentions in Prompts | [Example](#mentions-in-prompts) | [📖](https://docs.praison.ai/cli/mentions) |
|
||||
| 💾 Auto-Save Sessions | [Example](#session-management-python) | [📖](https://docs.praison.ai/cli/session) |
|
||||
| 📜 History in Context | [Example](#session-management-python) | [📖](https://docs.praison.ai/cli/session) |
|
||||
| 📄 Project Docs (.praison/docs/) | [Example](#docs-cli) | [📖](https://docs.praison.ai/docs/cli/docs) |
|
||||
| 🔌 MCP Config Management | [Example](#mcp-config-cli) | [📖](https://docs.praison.ai/docs/cli/mcp) |
|
||||
| 💬 AI Commit Messages | [Example](#ai-commit-cli) | [📖](https://docs.praison.ai/docs/cli/commit) |
|
||||
| @ @Mentions in Prompts | [Example](#mentions-in-prompts) | [📖](https://docs.praison.ai/docs/cli/mentions) |
|
||||
| 💾 Auto-Save Sessions | [Example](#session-management-python) | [📖](https://docs.praison.ai/docs/cli/session) |
|
||||
| 📜 History in Context | [Example](#session-management-python) | [📖](https://docs.praison.ai/docs/cli/session) |
|
||||
| **🖥️ CLI Features** | | |
|
||||
| ↳ Slash Commands | [Example](examples/python/cli/slash_commands_example.py) | [📖](https://docs.praison.ai/cli/slash-commands) |
|
||||
| ↳ Autonomy Modes | [Example](examples/python/cli/autonomy_modes_example.py) | [📖](https://docs.praison.ai/cli/autonomy-modes) |
|
||||
| ↳ Cost Tracking | [Example](examples/python/cli/cost_tracking_example.py) | [📖](https://docs.praison.ai/cli/cost-tracking) |
|
||||
| ↳ Repository Map | [Example](examples/python/cli/repo_map_example.py) | [📖](https://docs.praison.ai/cli/repo-map) |
|
||||
| ↳ Interactive TUI | [Example](examples/python/cli/interactive_tui_example.py) | [📖](https://docs.praison.ai/cli/interactive-tui) |
|
||||
| ↳ Git Integration | [Example](examples/python/cli/git_integration_example.py) | [📖](https://docs.praison.ai/cli/git-integration) |
|
||||
| ↳ Sandbox Execution | [Example](examples/python/cli/sandbox_execution_example.py) | [📖](https://docs.praison.ai/cli/sandbox-execution) |
|
||||
| ↳ Slash Commands | [Example](examples/python/cli/slash_commands_example.py) | [📖](https://docs.praison.ai/docs/cli/slash-commands) |
|
||||
| ↳ Autonomy Modes | [Example](examples/python/cli/autonomy_modes_example.py) | [📖](https://docs.praison.ai/docs/cli/autonomy-modes) |
|
||||
| ↳ Cost Tracking | [Example](examples/python/cli/cost_tracking_example.py) | [📖](https://docs.praison.ai/docs/cli/cost-tracking) |
|
||||
| ↳ Repository Map | [Example](examples/python/cli/repo_map_example.py) | [📖](https://docs.praison.ai/docs/cli/repo-map) |
|
||||
| ↳ Interactive TUI | [Example](examples/python/cli/interactive_tui_example.py) | [📖](https://docs.praison.ai/docs/cli/interactive-tui) |
|
||||
| ↳ Git Integration | [Example](examples/python/cli/git_integration_example.py) | [📖](https://docs.praison.ai/docs/cli/git-integration) |
|
||||
| ↳ Sandbox Execution | [Example](examples/python/cli/sandbox_execution_example.py) | [📖](https://docs.praison.ai/docs/cli/sandbox-execution) |
|
||||
|
||||
---
|
||||
|
||||
@@ -972,6 +972,31 @@ export OPENAI_API_KEY=xxxxxxxxxxxxxxxxxxxxxx
|
||||
praisonai --auto create a movie script about Robots in Mars
|
||||
```
|
||||
|
||||
### Interactive Mode CLI:
|
||||
```bash
|
||||
# Start interactive terminal mode (inspired by Gemini CLI, Codex CLI, Claude Code)
|
||||
praisonai --interactive
|
||||
praisonai -i
|
||||
|
||||
# Features:
|
||||
# - Streaming responses (no boxes)
|
||||
# - Built-in tools: read_file, write_file, list_files, execute_command, internet_search
|
||||
# - Slash commands: /help, /exit, /tools, /clear
|
||||
|
||||
# Chat mode - single prompt with interactive style (for testing/scripting)
|
||||
praisonai "list files in current folder" --chat-mode
|
||||
praisonai "search the web for AI news" --chat-mode
|
||||
praisonai "read README.md" --chat-mode
|
||||
```
|
||||
|
||||
### Chat UI (Web Interface):
|
||||
```bash
|
||||
# Start web-based Chainlit chat interface (requires praisonai[chat])
|
||||
pip install "praisonai[chat]"
|
||||
praisonai chat
|
||||
# Opens browser at http://localhost:8084
|
||||
```
|
||||
|
||||
### Query Rewriting (works with any command):
|
||||
```bash
|
||||
# Rewrite query for better results (uses QueryRewriterAgent)
|
||||
@@ -1446,13 +1471,31 @@ praisonai mcp help
|
||||
|
||||
### AI Commit CLI:
|
||||
```bash
|
||||
# Generate AI commit message for staged changes
|
||||
# Full auto mode: stage all, security check, commit, and push
|
||||
praisonai commit -a
|
||||
|
||||
# Interactive mode (requires git add first)
|
||||
praisonai commit
|
||||
|
||||
# Generate, commit, and push
|
||||
# Interactive with auto-push
|
||||
praisonai commit --push
|
||||
|
||||
# Skip security check (not recommended)
|
||||
praisonai commit -a --no-verify
|
||||
```
|
||||
|
||||
**Features:**
|
||||
- 🤖 AI-generated conventional commit messages
|
||||
- 🔒 Built-in security scanning (API keys, passwords, secrets, sensitive files)
|
||||
- 📦 Auto-staging with `-a` flag
|
||||
- 🚀 Auto-push in full auto mode
|
||||
- ✏️ Edit message before commit in interactive mode
|
||||
|
||||
**Security Detection:**
|
||||
- API keys, secrets, tokens (AWS, GitHub, GitLab, Slack)
|
||||
- Passwords and private keys
|
||||
- Sensitive files (`.env`, `id_rsa`, `.pem`, `.key`, etc.)
|
||||
|
||||
### Serve CLI (API Server):
|
||||
```bash
|
||||
# Start API server for agents defined in YAML
|
||||
@@ -1757,32 +1800,32 @@ app.include_router(a2a.get_router())
|
||||
|
||||
| Feature | Docs |
|
||||
|---------|:----:|
|
||||
| 🔄 Query Rewrite - RAG optimization | [📖](https://docs.praison.ai/cli/query-rewrite) |
|
||||
| 🔬 Deep Research - Automated research | [📖](https://docs.praison.ai/cli/deep-research) |
|
||||
| 📋 Planning - Step-by-step execution | [📖](https://docs.praison.ai/cli/planning) |
|
||||
| 💾 Memory - Persistent agent memory | [📖](https://docs.praison.ai/cli/memory) |
|
||||
| 📜 Rules - Auto-discovered instructions | [📖](https://docs.praison.ai/cli/rules) |
|
||||
| 🔄 Workflow - Multi-step workflows | [📖](https://docs.praison.ai/cli/workflow) |
|
||||
| 🪝 Hooks - Event-driven actions | [📖](https://docs.praison.ai/cli/hooks) |
|
||||
| 🧠 Claude Memory - Anthropic memory tool | [📖](https://docs.praison.ai/cli/claude-memory) |
|
||||
| 🛡️ Guardrail - Output validation | [📖](https://docs.praison.ai/cli/guardrail) |
|
||||
| 📊 Metrics - Token usage tracking | [📖](https://docs.praison.ai/cli/metrics) |
|
||||
| 🖼️ Image - Vision processing | [📖](https://docs.praison.ai/cli/image) |
|
||||
| 📡 Telemetry - Usage monitoring | [📖](https://docs.praison.ai/cli/telemetry) |
|
||||
| 🔌 MCP - Model Context Protocol | [📖](https://docs.praison.ai/cli/mcp) |
|
||||
| ⚡ Fast Context - Codebase search | [📖](https://docs.praison.ai/cli/fast-context) |
|
||||
| 📚 Knowledge - RAG management | [📖](https://docs.praison.ai/cli/knowledge) |
|
||||
| 💬 Session - Conversation management | [📖](https://docs.praison.ai/cli/session) |
|
||||
| 🔧 Tools - Tool discovery | [📖](https://docs.praison.ai/cli/tools) |
|
||||
| 🤝 Handoff - Agent delegation | [📖](https://docs.praison.ai/cli/handoff) |
|
||||
| 🧠 Auto Memory - Memory extraction | [📖](https://docs.praison.ai/cli/auto-memory) |
|
||||
| 📋 Todo - Task management | [📖](https://docs.praison.ai/cli/todo) |
|
||||
| 🎯 Router - Smart model selection | [📖](https://docs.praison.ai/cli/router) |
|
||||
| 📈 Flow Display - Visual workflow | [📖](https://docs.praison.ai/cli/flow-display) |
|
||||
| ✨ Prompt Expansion - Detailed prompts | [📖](https://docs.praison.ai/cli/prompt-expansion) |
|
||||
| 🌐 Web Search - Real-time search | [📖](https://docs.praison.ai/cli/web-search) |
|
||||
| 📥 Web Fetch - URL content retrieval | [📖](https://docs.praison.ai/cli/web-fetch) |
|
||||
| 💾 Prompt Caching - Cost reduction | [📖](https://docs.praison.ai/cli/prompt-caching) |
|
||||
| 🔄 Query Rewrite - RAG optimization | [📖](https://docs.praison.ai/docs/cli/query-rewrite) |
|
||||
| 🔬 Deep Research - Automated research | [📖](https://docs.praison.ai/docs/cli/deep-research) |
|
||||
| 📋 Planning - Step-by-step execution | [📖](https://docs.praison.ai/docs/cli/planning) |
|
||||
| 💾 Memory - Persistent agent memory | [📖](https://docs.praison.ai/docs/cli/memory) |
|
||||
| 📜 Rules - Auto-discovered instructions | [📖](https://docs.praison.ai/docs/cli/rules) |
|
||||
| 🔄 Workflow - Multi-step workflows | [📖](https://docs.praison.ai/docs/cli/workflow) |
|
||||
| 🪝 Hooks - Event-driven actions | [📖](https://docs.praison.ai/docs/cli/hooks) |
|
||||
| 🧠 Claude Memory - Anthropic memory tool | [📖](https://docs.praison.ai/docs/cli/claude-memory) |
|
||||
| 🛡️ Guardrail - Output validation | [📖](https://docs.praison.ai/docs/cli/guardrail) |
|
||||
| 📊 Metrics - Token usage tracking | [📖](https://docs.praison.ai/docs/cli/metrics) |
|
||||
| 🖼️ Image - Vision processing | [📖](https://docs.praison.ai/docs/cli/image) |
|
||||
| 📡 Telemetry - Usage monitoring | [📖](https://docs.praison.ai/docs/cli/telemetry) |
|
||||
| 🔌 MCP - Model Context Protocol | [📖](https://docs.praison.ai/docs/cli/mcp) |
|
||||
| ⚡ Fast Context - Codebase search | [📖](https://docs.praison.ai/docs/cli/fast-context) |
|
||||
| 📚 Knowledge - RAG management | [📖](https://docs.praison.ai/docs/cli/knowledge) |
|
||||
| 💬 Session - Conversation management | [📖](https://docs.praison.ai/docs/cli/session) |
|
||||
| 🔧 Tools - Tool discovery | [📖](https://docs.praison.ai/docs/cli/tools) |
|
||||
| 🤝 Handoff - Agent delegation | [📖](https://docs.praison.ai/docs/cli/handoff) |
|
||||
| 🧠 Auto Memory - Memory extraction | [📖](https://docs.praison.ai/docs/cli/auto-memory) |
|
||||
| 📋 Todo - Task management | [📖](https://docs.praison.ai/docs/cli/todo) |
|
||||
| 🎯 Router - Smart model selection | [📖](https://docs.praison.ai/docs/cli/router) |
|
||||
| 📈 Flow Display - Visual workflow | [📖](https://docs.praison.ai/docs/cli/flow-display) |
|
||||
| ✨ Prompt Expansion - Detailed prompts | [📖](https://docs.praison.ai/docs/cli/prompt-expansion) |
|
||||
| 🌐 Web Search - Real-time search | [📖](https://docs.praison.ai/docs/cli/web-search) |
|
||||
| 📥 Web Fetch - URL content retrieval | [📖](https://docs.praison.ai/docs/cli/web-fetch) |
|
||||
| 💾 Prompt Caching - Cost reduction | [📖](https://docs.praison.ai/docs/cli/prompt-caching) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
|
||||
# Install Python packages (using latest versions)
|
||||
RUN pip install --no-cache-dir \
|
||||
praisonai_tools \
|
||||
"praisonai>=2.3.45" \
|
||||
"praisonai>=2.3.50" \
|
||||
"praisonai[chat]" \
|
||||
"embedchain[github,youtube]"
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ RUN mkdir -p /root/.praison
|
||||
# Install Python packages (using latest versions)
|
||||
RUN pip install --no-cache-dir \
|
||||
praisonai_tools \
|
||||
"praisonai>=2.3.45" \
|
||||
"praisonai>=2.3.50" \
|
||||
"praisonai[ui]" \
|
||||
"praisonai[chat]" \
|
||||
"praisonai[realtime]" \
|
||||
|
||||
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
|
||||
# Install Python packages (using latest versions)
|
||||
RUN pip install --no-cache-dir \
|
||||
praisonai_tools \
|
||||
"praisonai>=2.3.45" \
|
||||
"praisonai>=2.3.50" \
|
||||
"praisonai[ui]" \
|
||||
"praisonai[crewai]"
|
||||
|
||||
|
||||
@@ -29,7 +29,9 @@ SUPPRESSED_PATTERNS = [
|
||||
"PydanticSerializationUnexpectedValue",
|
||||
"Expected 9 fields but got 5 for type `Message`",
|
||||
"Expected `StreamingChoices` but got `Choices`",
|
||||
"serialized value may not be as expected"
|
||||
"serialized value may not be as expected",
|
||||
"Mixing V1 models and V2 models",
|
||||
"Please upgrade `Settings` to V2"
|
||||
]
|
||||
|
||||
@functools.wraps(_original_warn)
|
||||
|
||||
@@ -14,6 +14,7 @@ Usage:
|
||||
context, cleaned_prompt = parser.process("@file:main.py explain this code")
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import logging
|
||||
from pathlib import Path
|
||||
@@ -43,10 +44,14 @@ class MentionsParser:
|
||||
"url": re.compile(r'@url:(https?://[^\s]+)'),
|
||||
}
|
||||
|
||||
# Default max file chars: 500K (~125K tokens) - fits GPT-4o (128K), Claude 3.5 (200K)
|
||||
DEFAULT_MAX_FILE_CHARS = 500000
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
workspace_path: Optional[str] = None,
|
||||
verbose: int = 0
|
||||
verbose: int = 0,
|
||||
max_file_chars: Optional[int] = None
|
||||
):
|
||||
"""
|
||||
Initialize MentionsParser.
|
||||
@@ -54,10 +59,24 @@ class MentionsParser:
|
||||
Args:
|
||||
workspace_path: Path to workspace/project root
|
||||
verbose: Verbosity level
|
||||
max_file_chars: Maximum characters to include from files.
|
||||
Default: 500000 (500K chars, ~125K tokens).
|
||||
Set to 0 for no limit.
|
||||
Can be overridden via PRAISON_MAX_FILE_CHARS env var.
|
||||
"""
|
||||
self.workspace_path = Path(workspace_path) if workspace_path else Path.cwd()
|
||||
self.verbose = verbose
|
||||
|
||||
# Configure max file chars: constructor > env var > default
|
||||
if max_file_chars is not None:
|
||||
self.max_file_chars = max_file_chars
|
||||
else:
|
||||
env_val = os.environ.get('PRAISON_MAX_FILE_CHARS')
|
||||
if env_val is not None:
|
||||
self.max_file_chars = int(env_val)
|
||||
else:
|
||||
self.max_file_chars = self.DEFAULT_MAX_FILE_CHARS
|
||||
|
||||
# Lazy-loaded managers
|
||||
self._docs_manager = None
|
||||
self._rules_manager = None
|
||||
@@ -158,9 +177,15 @@ class MentionsParser:
|
||||
|
||||
content = full_path.read_text(encoding="utf-8")
|
||||
|
||||
# Limit content size
|
||||
if len(content) > 50000:
|
||||
content = content[:50000] + "\n... (truncated)"
|
||||
# Limit content size (0 means no limit)
|
||||
if self.max_file_chars > 0 and len(content) > self.max_file_chars:
|
||||
original_size = len(content)
|
||||
content = content[:self.max_file_chars] + "\n... (truncated)"
|
||||
self._log(
|
||||
f"File {file_path} truncated from {original_size:,} to {self.max_file_chars:,} chars. "
|
||||
f"Set PRAISON_MAX_FILE_CHARS=0 for no limit.",
|
||||
logging.WARNING
|
||||
)
|
||||
|
||||
# Detect language for code fence
|
||||
ext = full_path.suffix.lower()
|
||||
@@ -253,9 +278,9 @@ class MentionsParser:
|
||||
with urllib.request.urlopen(req, timeout=10) as response:
|
||||
content = response.read().decode('utf-8', errors='ignore')
|
||||
|
||||
# Limit content size
|
||||
if len(content) > 50000:
|
||||
content = content[:50000] + "\n... (truncated)"
|
||||
# Limit content size (0 means no limit)
|
||||
if self.max_file_chars > 0 and len(content) > self.max_file_chars:
|
||||
content = content[:self.max_file_chars] + "\n... (truncated)"
|
||||
|
||||
# Try to extract text from HTML
|
||||
if '<html' in content.lower():
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
"""
|
||||
Unit tests for configurable max_file_chars in MentionsParser.
|
||||
|
||||
TDD approach: Write tests first, then implement.
|
||||
"""
|
||||
|
||||
import os
|
||||
import pytest
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
class TestMentionsParserConfigurableLimit:
|
||||
"""Test suite for configurable file truncation limit."""
|
||||
|
||||
@pytest.fixture
|
||||
def temp_workspace(self):
|
||||
"""Create a temporary workspace with test files."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
yield Path(tmpdir)
|
||||
|
||||
@pytest.fixture
|
||||
def small_file(self, temp_workspace):
|
||||
"""Create a small test file (1KB)."""
|
||||
file_path = temp_workspace / "small.py"
|
||||
content = "# Small file\n" + "x = 1\n" * 100 # ~700 chars
|
||||
file_path.write_text(content)
|
||||
return file_path
|
||||
|
||||
@pytest.fixture
|
||||
def large_file(self, temp_workspace):
|
||||
"""Create a large test file (100KB)."""
|
||||
file_path = temp_workspace / "large.py"
|
||||
content = "# Large file\n" + "x = 'a' * 100\n" * 7000 # ~100KB
|
||||
file_path.write_text(content)
|
||||
return file_path
|
||||
|
||||
@pytest.fixture
|
||||
def huge_file(self, temp_workspace):
|
||||
"""Create a huge test file (600KB)."""
|
||||
file_path = temp_workspace / "huge.py"
|
||||
content = "# Huge file\n" + "x = 'a' * 100\n" * 42000 # ~600KB
|
||||
file_path.write_text(content)
|
||||
return file_path
|
||||
|
||||
# ===== Test 1.1: Default value (500K) =====
|
||||
def test_default_max_file_chars_is_500k(self, temp_workspace):
|
||||
"""Default max_file_chars should be 500,000."""
|
||||
from praisonaiagents.tools.mentions import MentionsParser
|
||||
|
||||
parser = MentionsParser(workspace_path=str(temp_workspace))
|
||||
assert parser.max_file_chars == 500000
|
||||
|
||||
# ===== Test 1.2: Custom value via constructor =====
|
||||
def test_custom_max_file_chars_via_constructor(self, temp_workspace):
|
||||
"""max_file_chars should be configurable via constructor."""
|
||||
from praisonaiagents.tools.mentions import MentionsParser
|
||||
|
||||
parser = MentionsParser(
|
||||
workspace_path=str(temp_workspace),
|
||||
max_file_chars=100000
|
||||
)
|
||||
assert parser.max_file_chars == 100000
|
||||
|
||||
def test_custom_max_file_chars_zero_means_no_limit(self, temp_workspace):
|
||||
"""max_file_chars=0 should mean no truncation."""
|
||||
from praisonaiagents.tools.mentions import MentionsParser
|
||||
|
||||
parser = MentionsParser(
|
||||
workspace_path=str(temp_workspace),
|
||||
max_file_chars=0
|
||||
)
|
||||
assert parser.max_file_chars == 0
|
||||
|
||||
# ===== Test 1.3: Environment variable override =====
|
||||
def test_env_var_override(self, temp_workspace):
|
||||
"""PRAISON_MAX_FILE_CHARS env var should override default."""
|
||||
from praisonaiagents.tools.mentions import MentionsParser
|
||||
|
||||
with patch.dict(os.environ, {'PRAISON_MAX_FILE_CHARS': '200000'}):
|
||||
# Need to reimport to pick up env var
|
||||
parser = MentionsParser(workspace_path=str(temp_workspace))
|
||||
assert parser.max_file_chars == 200000
|
||||
|
||||
def test_constructor_takes_precedence_over_env_var(self, temp_workspace):
|
||||
"""Constructor value should take precedence over env var."""
|
||||
from praisonaiagents.tools.mentions import MentionsParser
|
||||
|
||||
with patch.dict(os.environ, {'PRAISON_MAX_FILE_CHARS': '200000'}):
|
||||
parser = MentionsParser(
|
||||
workspace_path=str(temp_workspace),
|
||||
max_file_chars=100000
|
||||
)
|
||||
assert parser.max_file_chars == 100000
|
||||
|
||||
# ===== Test 1.4: No-limit mode =====
|
||||
def test_no_truncation_when_limit_is_zero(self, temp_workspace, huge_file):
|
||||
"""File should not be truncated when max_file_chars=0."""
|
||||
from praisonaiagents.tools.mentions import MentionsParser
|
||||
|
||||
parser = MentionsParser(
|
||||
workspace_path=str(temp_workspace),
|
||||
max_file_chars=0
|
||||
)
|
||||
|
||||
context, _ = parser.process(f"@file:{huge_file.name} explain")
|
||||
|
||||
# Should contain full content, no truncation message
|
||||
assert "(truncated)" not in context
|
||||
# Should have the full file content
|
||||
original_size = huge_file.read_text().__len__()
|
||||
# Context includes header and code fence, so check it's close
|
||||
assert len(context) >= original_size
|
||||
|
||||
# ===== Test 1.5: Truncation behavior =====
|
||||
def test_truncation_at_custom_limit(self, temp_workspace, large_file):
|
||||
"""File should be truncated at custom limit."""
|
||||
from praisonaiagents.tools.mentions import MentionsParser
|
||||
|
||||
parser = MentionsParser(
|
||||
workspace_path=str(temp_workspace),
|
||||
max_file_chars=10000 # 10KB limit
|
||||
)
|
||||
|
||||
context, _ = parser.process(f"@file:{large_file.name} explain")
|
||||
|
||||
# Should be truncated
|
||||
assert "(truncated)" in context
|
||||
# Content should be around the limit (plus header/fence overhead)
|
||||
assert len(context) < 15000 # Some overhead for markdown
|
||||
|
||||
def test_no_truncation_when_file_under_limit(self, temp_workspace, small_file):
|
||||
"""Small files should not be truncated."""
|
||||
from praisonaiagents.tools.mentions import MentionsParser
|
||||
|
||||
parser = MentionsParser(
|
||||
workspace_path=str(temp_workspace),
|
||||
max_file_chars=500000
|
||||
)
|
||||
|
||||
context, _ = parser.process(f"@file:{small_file.name} explain")
|
||||
|
||||
# Should NOT be truncated
|
||||
assert "(truncated)" not in context
|
||||
|
||||
def test_truncation_warning_logged(self, temp_workspace, large_file, caplog):
|
||||
"""Warning should be logged when file is truncated."""
|
||||
from praisonaiagents.tools.mentions import MentionsParser
|
||||
import logging
|
||||
|
||||
parser = MentionsParser(
|
||||
workspace_path=str(temp_workspace),
|
||||
max_file_chars=10000,
|
||||
verbose=1
|
||||
)
|
||||
|
||||
with caplog.at_level(logging.WARNING):
|
||||
parser.process(f"@file:{large_file.name} explain")
|
||||
|
||||
# Should log a warning about truncation
|
||||
assert any("truncat" in record.message.lower() for record in caplog.records)
|
||||
|
||||
# ===== Test: URL truncation also uses configurable limit =====
|
||||
def test_url_truncation_uses_same_limit(self, temp_workspace):
|
||||
"""URL content should also respect max_file_chars."""
|
||||
from praisonaiagents.tools.mentions import MentionsParser
|
||||
|
||||
parser = MentionsParser(
|
||||
workspace_path=str(temp_workspace),
|
||||
max_file_chars=100000
|
||||
)
|
||||
|
||||
# URL truncation should use the same limit
|
||||
assert parser.max_file_chars == 100000
|
||||
|
||||
# ===== Test: Backward compatibility =====
|
||||
def test_backward_compatible_no_args(self, temp_workspace):
|
||||
"""Parser should work without max_file_chars argument."""
|
||||
from praisonaiagents.tools.mentions import MentionsParser
|
||||
|
||||
# Should not raise any errors
|
||||
parser = MentionsParser(workspace_path=str(temp_workspace))
|
||||
assert hasattr(parser, 'max_file_chars')
|
||||
|
||||
# ===== Test: Performance - no impact on small files =====
|
||||
def test_performance_small_file(self, temp_workspace, small_file):
|
||||
"""Processing small files should be fast."""
|
||||
from praisonaiagents.tools.mentions import MentionsParser
|
||||
import time
|
||||
|
||||
parser = MentionsParser(workspace_path=str(temp_workspace))
|
||||
|
||||
start = time.time()
|
||||
for _ in range(100):
|
||||
parser.process(f"@file:{small_file.name} explain")
|
||||
elapsed = time.time() - start
|
||||
|
||||
# 100 iterations should complete in under 1 second
|
||||
assert elapsed < 1.0, f"Performance regression: {elapsed}s for 100 iterations"
|
||||
|
||||
|
||||
class TestProcessMentionsFunction:
|
||||
"""Test the convenience function."""
|
||||
|
||||
def test_process_mentions_uses_default_limit(self):
|
||||
"""process_mentions function should use default limit."""
|
||||
from praisonaiagents.tools.mentions import process_mentions
|
||||
|
||||
# The function should create a parser with default settings
|
||||
# Just verify it works without errors
|
||||
context, cleaned = process_mentions("hello world")
|
||||
assert cleaned == "hello world"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
+84
-41
@@ -252,20 +252,20 @@ npm install praisonai
|
||||
| 🪝 Hooks | [Example](#9-hooks) | [📖](https://docs.praison.ai/features/hooks) |
|
||||
| 📈 Telemetry | [Example](examples/python/telemetry/production-telemetry-example.py) | [📖](https://docs.praison.ai/features/telemetry) |
|
||||
| 📹 Camera Integration | [Example](examples/python/camera/) | [📖](https://docs.praison.ai/features/camera-integration) |
|
||||
| 📄 Project Docs (.praison/docs/) | [Example](#docs-cli) | [📖](https://docs.praison.ai/cli/docs) |
|
||||
| 🔌 MCP Config Management | [Example](#mcp-config-cli) | [📖](https://docs.praison.ai/cli/mcp) |
|
||||
| 💬 AI Commit Messages | [Example](#ai-commit-cli) | [📖](https://docs.praison.ai/cli/commit) |
|
||||
| @ @Mentions in Prompts | [Example](#mentions-in-prompts) | [📖](https://docs.praison.ai/cli/mentions) |
|
||||
| 💾 Auto-Save Sessions | [Example](#session-management-python) | [📖](https://docs.praison.ai/cli/session) |
|
||||
| 📜 History in Context | [Example](#session-management-python) | [📖](https://docs.praison.ai/cli/session) |
|
||||
| 📄 Project Docs (.praison/docs/) | [Example](#docs-cli) | [📖](https://docs.praison.ai/docs/cli/docs) |
|
||||
| 🔌 MCP Config Management | [Example](#mcp-config-cli) | [📖](https://docs.praison.ai/docs/cli/mcp) |
|
||||
| 💬 AI Commit Messages | [Example](#ai-commit-cli) | [📖](https://docs.praison.ai/docs/cli/commit) |
|
||||
| @ @Mentions in Prompts | [Example](#mentions-in-prompts) | [📖](https://docs.praison.ai/docs/cli/mentions) |
|
||||
| 💾 Auto-Save Sessions | [Example](#session-management-python) | [📖](https://docs.praison.ai/docs/cli/session) |
|
||||
| 📜 History in Context | [Example](#session-management-python) | [📖](https://docs.praison.ai/docs/cli/session) |
|
||||
| **🖥️ CLI Features** | | |
|
||||
| ↳ Slash Commands | [Example](examples/python/cli/slash_commands_example.py) | [📖](https://docs.praison.ai/cli/slash-commands) |
|
||||
| ↳ Autonomy Modes | [Example](examples/python/cli/autonomy_modes_example.py) | [📖](https://docs.praison.ai/cli/autonomy-modes) |
|
||||
| ↳ Cost Tracking | [Example](examples/python/cli/cost_tracking_example.py) | [📖](https://docs.praison.ai/cli/cost-tracking) |
|
||||
| ↳ Repository Map | [Example](examples/python/cli/repo_map_example.py) | [📖](https://docs.praison.ai/cli/repo-map) |
|
||||
| ↳ Interactive TUI | [Example](examples/python/cli/interactive_tui_example.py) | [📖](https://docs.praison.ai/cli/interactive-tui) |
|
||||
| ↳ Git Integration | [Example](examples/python/cli/git_integration_example.py) | [📖](https://docs.praison.ai/cli/git-integration) |
|
||||
| ↳ Sandbox Execution | [Example](examples/python/cli/sandbox_execution_example.py) | [📖](https://docs.praison.ai/cli/sandbox-execution) |
|
||||
| ↳ Slash Commands | [Example](examples/python/cli/slash_commands_example.py) | [📖](https://docs.praison.ai/docs/cli/slash-commands) |
|
||||
| ↳ Autonomy Modes | [Example](examples/python/cli/autonomy_modes_example.py) | [📖](https://docs.praison.ai/docs/cli/autonomy-modes) |
|
||||
| ↳ Cost Tracking | [Example](examples/python/cli/cost_tracking_example.py) | [📖](https://docs.praison.ai/docs/cli/cost-tracking) |
|
||||
| ↳ Repository Map | [Example](examples/python/cli/repo_map_example.py) | [📖](https://docs.praison.ai/docs/cli/repo-map) |
|
||||
| ↳ Interactive TUI | [Example](examples/python/cli/interactive_tui_example.py) | [📖](https://docs.praison.ai/docs/cli/interactive-tui) |
|
||||
| ↳ Git Integration | [Example](examples/python/cli/git_integration_example.py) | [📖](https://docs.praison.ai/docs/cli/git-integration) |
|
||||
| ↳ Sandbox Execution | [Example](examples/python/cli/sandbox_execution_example.py) | [📖](https://docs.praison.ai/docs/cli/sandbox-execution) |
|
||||
|
||||
---
|
||||
|
||||
@@ -972,6 +972,31 @@ export OPENAI_API_KEY=xxxxxxxxxxxxxxxxxxxxxx
|
||||
praisonai --auto create a movie script about Robots in Mars
|
||||
```
|
||||
|
||||
### Interactive Mode CLI:
|
||||
```bash
|
||||
# Start interactive terminal mode (inspired by Gemini CLI, Codex CLI, Claude Code)
|
||||
praisonai --interactive
|
||||
praisonai -i
|
||||
|
||||
# Features:
|
||||
# - Streaming responses (no boxes)
|
||||
# - Built-in tools: read_file, write_file, list_files, execute_command, internet_search
|
||||
# - Slash commands: /help, /exit, /tools, /clear
|
||||
|
||||
# Chat mode - single prompt with interactive style (for testing/scripting)
|
||||
praisonai "list files in current folder" --chat-mode
|
||||
praisonai "search the web for AI news" --chat-mode
|
||||
praisonai "read README.md" --chat-mode
|
||||
```
|
||||
|
||||
### Chat UI (Web Interface):
|
||||
```bash
|
||||
# Start web-based Chainlit chat interface (requires praisonai[chat])
|
||||
pip install "praisonai[chat]"
|
||||
praisonai chat
|
||||
# Opens browser at http://localhost:8084
|
||||
```
|
||||
|
||||
### Query Rewriting (works with any command):
|
||||
```bash
|
||||
# Rewrite query for better results (uses QueryRewriterAgent)
|
||||
@@ -1446,13 +1471,31 @@ praisonai mcp help
|
||||
|
||||
### AI Commit CLI:
|
||||
```bash
|
||||
# Generate AI commit message for staged changes
|
||||
# Full auto mode: stage all, security check, commit, and push
|
||||
praisonai commit -a
|
||||
|
||||
# Interactive mode (requires git add first)
|
||||
praisonai commit
|
||||
|
||||
# Generate, commit, and push
|
||||
# Interactive with auto-push
|
||||
praisonai commit --push
|
||||
|
||||
# Skip security check (not recommended)
|
||||
praisonai commit -a --no-verify
|
||||
```
|
||||
|
||||
**Features:**
|
||||
- 🤖 AI-generated conventional commit messages
|
||||
- 🔒 Built-in security scanning (API keys, passwords, secrets, sensitive files)
|
||||
- 📦 Auto-staging with `-a` flag
|
||||
- 🚀 Auto-push in full auto mode
|
||||
- ✏️ Edit message before commit in interactive mode
|
||||
|
||||
**Security Detection:**
|
||||
- API keys, secrets, tokens (AWS, GitHub, GitLab, Slack)
|
||||
- Passwords and private keys
|
||||
- Sensitive files (`.env`, `id_rsa`, `.pem`, `.key`, etc.)
|
||||
|
||||
### Serve CLI (API Server):
|
||||
```bash
|
||||
# Start API server for agents defined in YAML
|
||||
@@ -1757,32 +1800,32 @@ app.include_router(a2a.get_router())
|
||||
|
||||
| Feature | Docs |
|
||||
|---------|:----:|
|
||||
| 🔄 Query Rewrite - RAG optimization | [📖](https://docs.praison.ai/cli/query-rewrite) |
|
||||
| 🔬 Deep Research - Automated research | [📖](https://docs.praison.ai/cli/deep-research) |
|
||||
| 📋 Planning - Step-by-step execution | [📖](https://docs.praison.ai/cli/planning) |
|
||||
| 💾 Memory - Persistent agent memory | [📖](https://docs.praison.ai/cli/memory) |
|
||||
| 📜 Rules - Auto-discovered instructions | [📖](https://docs.praison.ai/cli/rules) |
|
||||
| 🔄 Workflow - Multi-step workflows | [📖](https://docs.praison.ai/cli/workflow) |
|
||||
| 🪝 Hooks - Event-driven actions | [📖](https://docs.praison.ai/cli/hooks) |
|
||||
| 🧠 Claude Memory - Anthropic memory tool | [📖](https://docs.praison.ai/cli/claude-memory) |
|
||||
| 🛡️ Guardrail - Output validation | [📖](https://docs.praison.ai/cli/guardrail) |
|
||||
| 📊 Metrics - Token usage tracking | [📖](https://docs.praison.ai/cli/metrics) |
|
||||
| 🖼️ Image - Vision processing | [📖](https://docs.praison.ai/cli/image) |
|
||||
| 📡 Telemetry - Usage monitoring | [📖](https://docs.praison.ai/cli/telemetry) |
|
||||
| 🔌 MCP - Model Context Protocol | [📖](https://docs.praison.ai/cli/mcp) |
|
||||
| ⚡ Fast Context - Codebase search | [📖](https://docs.praison.ai/cli/fast-context) |
|
||||
| 📚 Knowledge - RAG management | [📖](https://docs.praison.ai/cli/knowledge) |
|
||||
| 💬 Session - Conversation management | [📖](https://docs.praison.ai/cli/session) |
|
||||
| 🔧 Tools - Tool discovery | [📖](https://docs.praison.ai/cli/tools) |
|
||||
| 🤝 Handoff - Agent delegation | [📖](https://docs.praison.ai/cli/handoff) |
|
||||
| 🧠 Auto Memory - Memory extraction | [📖](https://docs.praison.ai/cli/auto-memory) |
|
||||
| 📋 Todo - Task management | [📖](https://docs.praison.ai/cli/todo) |
|
||||
| 🎯 Router - Smart model selection | [📖](https://docs.praison.ai/cli/router) |
|
||||
| 📈 Flow Display - Visual workflow | [📖](https://docs.praison.ai/cli/flow-display) |
|
||||
| ✨ Prompt Expansion - Detailed prompts | [📖](https://docs.praison.ai/cli/prompt-expansion) |
|
||||
| 🌐 Web Search - Real-time search | [📖](https://docs.praison.ai/cli/web-search) |
|
||||
| 📥 Web Fetch - URL content retrieval | [📖](https://docs.praison.ai/cli/web-fetch) |
|
||||
| 💾 Prompt Caching - Cost reduction | [📖](https://docs.praison.ai/cli/prompt-caching) |
|
||||
| 🔄 Query Rewrite - RAG optimization | [📖](https://docs.praison.ai/docs/cli/query-rewrite) |
|
||||
| 🔬 Deep Research - Automated research | [📖](https://docs.praison.ai/docs/cli/deep-research) |
|
||||
| 📋 Planning - Step-by-step execution | [📖](https://docs.praison.ai/docs/cli/planning) |
|
||||
| 💾 Memory - Persistent agent memory | [📖](https://docs.praison.ai/docs/cli/memory) |
|
||||
| 📜 Rules - Auto-discovered instructions | [📖](https://docs.praison.ai/docs/cli/rules) |
|
||||
| 🔄 Workflow - Multi-step workflows | [📖](https://docs.praison.ai/docs/cli/workflow) |
|
||||
| 🪝 Hooks - Event-driven actions | [📖](https://docs.praison.ai/docs/cli/hooks) |
|
||||
| 🧠 Claude Memory - Anthropic memory tool | [📖](https://docs.praison.ai/docs/cli/claude-memory) |
|
||||
| 🛡️ Guardrail - Output validation | [📖](https://docs.praison.ai/docs/cli/guardrail) |
|
||||
| 📊 Metrics - Token usage tracking | [📖](https://docs.praison.ai/docs/cli/metrics) |
|
||||
| 🖼️ Image - Vision processing | [📖](https://docs.praison.ai/docs/cli/image) |
|
||||
| 📡 Telemetry - Usage monitoring | [📖](https://docs.praison.ai/docs/cli/telemetry) |
|
||||
| 🔌 MCP - Model Context Protocol | [📖](https://docs.praison.ai/docs/cli/mcp) |
|
||||
| ⚡ Fast Context - Codebase search | [📖](https://docs.praison.ai/docs/cli/fast-context) |
|
||||
| 📚 Knowledge - RAG management | [📖](https://docs.praison.ai/docs/cli/knowledge) |
|
||||
| 💬 Session - Conversation management | [📖](https://docs.praison.ai/docs/cli/session) |
|
||||
| 🔧 Tools - Tool discovery | [📖](https://docs.praison.ai/docs/cli/tools) |
|
||||
| 🤝 Handoff - Agent delegation | [📖](https://docs.praison.ai/docs/cli/handoff) |
|
||||
| 🧠 Auto Memory - Memory extraction | [📖](https://docs.praison.ai/docs/cli/auto-memory) |
|
||||
| 📋 Todo - Task management | [📖](https://docs.praison.ai/docs/cli/todo) |
|
||||
| 🎯 Router - Smart model selection | [📖](https://docs.praison.ai/docs/cli/router) |
|
||||
| 📈 Flow Display - Visual workflow | [📖](https://docs.praison.ai/docs/cli/flow-display) |
|
||||
| ✨ Prompt Expansion - Detailed prompts | [📖](https://docs.praison.ai/docs/cli/prompt-expansion) |
|
||||
| 🌐 Web Search - Real-time search | [📖](https://docs.praison.ai/docs/cli/web-search) |
|
||||
| 📥 Web Fetch - URL content retrieval | [📖](https://docs.praison.ai/docs/cli/web-fetch) |
|
||||
| 💾 Prompt Caching - Cost reduction | [📖](https://docs.praison.ai/docs/cli/prompt-caching) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -3,8 +3,8 @@ class Praisonai < Formula
|
||||
|
||||
desc "AI tools for various AI applications"
|
||||
homepage "https://github.com/MervinPraison/PraisonAI"
|
||||
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v2.3.45.tar.gz"
|
||||
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v2.3.45.tar.gz | shasum -a 256`.split.first
|
||||
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v2.3.50.tar.gz"
|
||||
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v2.3.50.tar.gz | shasum -a 256`.split.first
|
||||
license "MIT"
|
||||
|
||||
depends_on "[email protected]"
|
||||
|
||||
@@ -25,6 +25,7 @@ Features:
|
||||
- slash_commands: Interactive slash commands (/help, /cost, /plan, etc.)
|
||||
- autonomy_mode: Autonomy levels (suggest, auto-edit, full-auto)
|
||||
- cost_tracker: Real-time cost and token tracking
|
||||
- message_queue: Message queuing while agent is processing
|
||||
"""
|
||||
|
||||
# Type hints available for IDE support
|
||||
@@ -56,6 +57,7 @@ __all__ = [
|
||||
'InteractiveTUIHandler',
|
||||
'GitIntegrationHandler',
|
||||
'SandboxExecutorHandler',
|
||||
'MessageQueueHandler',
|
||||
]
|
||||
|
||||
def __getattr__(name):
|
||||
@@ -133,4 +135,7 @@ def __getattr__(name):
|
||||
elif name == 'SandboxExecutorHandler':
|
||||
from .sandbox_executor import SandboxExecutorHandler
|
||||
return SandboxExecutorHandler
|
||||
elif name == 'MessageQueueHandler':
|
||||
from .message_queue import MessageQueueHandler
|
||||
return MessageQueueHandler
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
@@ -0,0 +1,395 @@
|
||||
"""
|
||||
Message Queue for Interactive Mode.
|
||||
|
||||
Provides FIFO message queuing while the AI agent is processing,
|
||||
inspired by Claude Code, Windsurf Cascade, Cursor, and Gemini CLI.
|
||||
|
||||
Features:
|
||||
- Queue messages while agent is processing
|
||||
- Auto-process queue when agent becomes idle
|
||||
- Visual indicators for queue status
|
||||
- Thread-safe operations
|
||||
"""
|
||||
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Callable, List, Optional
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Enums
|
||||
# ============================================================================
|
||||
|
||||
class ProcessingState(Enum):
|
||||
"""State of the agent processing."""
|
||||
IDLE = "idle"
|
||||
PROCESSING = "processing"
|
||||
WAITING_APPROVAL = "waiting_approval"
|
||||
|
||||
|
||||
class MessagePriority(Enum):
|
||||
"""Priority levels for queued messages."""
|
||||
LOW = "low"
|
||||
NORMAL = "normal"
|
||||
HIGH = "high"
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Data Classes
|
||||
# ============================================================================
|
||||
|
||||
@dataclass
|
||||
class QueuedMessage:
|
||||
"""A message in the queue with metadata."""
|
||||
content: str
|
||||
timestamp: float = field(default_factory=time.time)
|
||||
priority: MessagePriority = MessagePriority.NORMAL
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# MessageQueue - Thread-safe FIFO queue
|
||||
# ============================================================================
|
||||
|
||||
class MessageQueue:
|
||||
"""
|
||||
Thread-safe FIFO message queue.
|
||||
|
||||
Provides basic queue operations with thread safety for concurrent access.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._messages: List[str] = []
|
||||
self._lock = threading.Lock()
|
||||
|
||||
@property
|
||||
def is_empty(self) -> bool:
|
||||
"""Check if queue is empty."""
|
||||
with self._lock:
|
||||
return len(self._messages) == 0
|
||||
|
||||
@property
|
||||
def count(self) -> int:
|
||||
"""Get number of messages in queue."""
|
||||
with self._lock:
|
||||
return len(self._messages)
|
||||
|
||||
def add(self, message: str) -> bool:
|
||||
"""
|
||||
Add a message to the end of the queue.
|
||||
|
||||
Args:
|
||||
message: The message to add
|
||||
|
||||
Returns:
|
||||
True if message was added, False if empty/whitespace
|
||||
"""
|
||||
trimmed = message.strip() if message else ""
|
||||
if not trimmed:
|
||||
return False
|
||||
|
||||
with self._lock:
|
||||
self._messages.append(trimmed)
|
||||
return True
|
||||
|
||||
def pop(self) -> Optional[str]:
|
||||
"""
|
||||
Remove and return the first message (FIFO).
|
||||
|
||||
Returns:
|
||||
The first message, or None if queue is empty
|
||||
"""
|
||||
with self._lock:
|
||||
if self._messages:
|
||||
return self._messages.pop(0)
|
||||
return None
|
||||
|
||||
def peek(self) -> Optional[str]:
|
||||
"""
|
||||
View the first message without removing it.
|
||||
|
||||
Returns:
|
||||
The first message, or None if queue is empty
|
||||
"""
|
||||
with self._lock:
|
||||
if self._messages:
|
||||
return self._messages[0]
|
||||
return None
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Remove all messages from the queue."""
|
||||
with self._lock:
|
||||
self._messages.clear()
|
||||
|
||||
def get_all(self) -> List[str]:
|
||||
"""
|
||||
Get all messages as a list (does not remove them).
|
||||
|
||||
Returns:
|
||||
Copy of all messages in queue order
|
||||
"""
|
||||
with self._lock:
|
||||
return self._messages.copy()
|
||||
|
||||
def remove_at(self, index: int) -> Optional[str]:
|
||||
"""
|
||||
Remove message at specific index.
|
||||
|
||||
Args:
|
||||
index: The index to remove (0-based)
|
||||
|
||||
Returns:
|
||||
The removed message, or None if index is invalid
|
||||
"""
|
||||
with self._lock:
|
||||
if 0 <= index < len(self._messages):
|
||||
return self._messages.pop(index)
|
||||
return None
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# StateManager - Processing state management
|
||||
# ============================================================================
|
||||
|
||||
class StateManager:
|
||||
"""
|
||||
Manages the processing state with optional callbacks.
|
||||
|
||||
Tracks whether the agent is idle, processing, or waiting for approval.
|
||||
"""
|
||||
|
||||
def __init__(self, on_state_change: Optional[Callable[[ProcessingState, ProcessingState], None]] = None):
|
||||
self._state = ProcessingState.IDLE
|
||||
self._lock = threading.Lock()
|
||||
self._on_state_change = on_state_change
|
||||
|
||||
@property
|
||||
def current_state(self) -> ProcessingState:
|
||||
"""Get current processing state."""
|
||||
with self._lock:
|
||||
return self._state
|
||||
|
||||
@property
|
||||
def is_idle(self) -> bool:
|
||||
"""Check if currently idle."""
|
||||
return self.current_state == ProcessingState.IDLE
|
||||
|
||||
@property
|
||||
def is_processing(self) -> bool:
|
||||
"""Check if currently processing."""
|
||||
return self.current_state == ProcessingState.PROCESSING
|
||||
|
||||
def set_state(self, new_state: ProcessingState) -> None:
|
||||
"""
|
||||
Set the processing state.
|
||||
|
||||
Args:
|
||||
new_state: The new state to set
|
||||
"""
|
||||
with self._lock:
|
||||
old_state = self._state
|
||||
self._state = new_state
|
||||
|
||||
# Call callback outside lock to prevent deadlock
|
||||
if self._on_state_change and old_state != new_state:
|
||||
self._on_state_change(old_state, new_state)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# QueueDisplay - Visual formatting
|
||||
# ============================================================================
|
||||
|
||||
class QueueDisplay:
|
||||
"""
|
||||
Formats queue and status for display.
|
||||
|
||||
Provides visual indicators for queue status, inspired by Codex CLI.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
queue: MessageQueue,
|
||||
state_manager: Optional[StateManager] = None,
|
||||
max_message_length: int = 50
|
||||
):
|
||||
self._queue = queue
|
||||
self._state_manager = state_manager
|
||||
self._max_length = max_message_length
|
||||
|
||||
def format_queue(self) -> str:
|
||||
"""
|
||||
Format queued messages for display.
|
||||
|
||||
Returns:
|
||||
Formatted string with queued messages
|
||||
"""
|
||||
messages = self._queue.get_all()
|
||||
if not messages:
|
||||
return ""
|
||||
|
||||
lines = []
|
||||
for msg in messages:
|
||||
# Truncate long messages
|
||||
display_msg = msg
|
||||
if len(msg) > self._max_length:
|
||||
display_msg = msg[:self._max_length - 3] + "..."
|
||||
lines.append(f" ↳ {display_msg}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def format_status(self) -> str:
|
||||
"""
|
||||
Format current processing status.
|
||||
|
||||
Returns:
|
||||
Status string (e.g., "⏳ Processing..." or "")
|
||||
"""
|
||||
if not self._state_manager:
|
||||
return ""
|
||||
|
||||
state = self._state_manager.current_state
|
||||
if state == ProcessingState.PROCESSING:
|
||||
return "⏳ Processing..."
|
||||
elif state == ProcessingState.WAITING_APPROVAL:
|
||||
return "🔒 Waiting for approval..."
|
||||
return ""
|
||||
|
||||
def format_queue_count(self) -> str:
|
||||
"""
|
||||
Format queue count indicator.
|
||||
|
||||
Returns:
|
||||
Count string (e.g., "📋 Queued (2)")
|
||||
"""
|
||||
count = self._queue.count
|
||||
if count == 0:
|
||||
return ""
|
||||
return f"📋 Queued ({count})"
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# MessageQueueHandler - Main handler class
|
||||
# ============================================================================
|
||||
|
||||
class MessageQueueHandler:
|
||||
"""
|
||||
Main handler for message queue functionality.
|
||||
|
||||
Coordinates queue, state, and processing.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
processor: Optional[Callable[[str], str]] = None,
|
||||
on_response: Optional[Callable[[str], None]] = None
|
||||
):
|
||||
self._processor = processor
|
||||
self._on_response = on_response
|
||||
self._queue = MessageQueue()
|
||||
self._state_manager = StateManager(on_state_change=self._on_state_change)
|
||||
self._processing_lock = threading.Lock()
|
||||
|
||||
@property
|
||||
def queue(self) -> MessageQueue:
|
||||
"""Get the message queue."""
|
||||
return self._queue
|
||||
|
||||
@property
|
||||
def state_manager(self) -> StateManager:
|
||||
"""Get the state manager."""
|
||||
return self._state_manager
|
||||
|
||||
def submit(self, message: str) -> bool:
|
||||
"""
|
||||
Submit a message for processing.
|
||||
|
||||
If idle, processes immediately. If processing, queues the message.
|
||||
|
||||
Args:
|
||||
message: The message to submit
|
||||
|
||||
Returns:
|
||||
True if processed/queued, False if empty message
|
||||
"""
|
||||
trimmed = message.strip() if message else ""
|
||||
if not trimmed:
|
||||
return False
|
||||
|
||||
if self._state_manager.is_idle:
|
||||
# Process immediately
|
||||
self._process_message(trimmed)
|
||||
else:
|
||||
# Queue for later
|
||||
self._queue.add(trimmed)
|
||||
|
||||
return True
|
||||
|
||||
def on_processing_complete(self) -> None:
|
||||
"""
|
||||
Called when current processing completes.
|
||||
|
||||
Sets state to idle and processes next queued message if any.
|
||||
"""
|
||||
self._state_manager.set_state(ProcessingState.IDLE)
|
||||
self._process_next_in_queue()
|
||||
|
||||
def get_status(self) -> dict:
|
||||
"""
|
||||
Get current status.
|
||||
|
||||
Returns:
|
||||
Dict with queue_count and state
|
||||
"""
|
||||
return {
|
||||
'queue_count': self._queue.count,
|
||||
'state': self._state_manager.current_state.value,
|
||||
'messages': self._queue.get_all()
|
||||
}
|
||||
|
||||
def clear_queue(self) -> None:
|
||||
"""Clear all queued messages."""
|
||||
self._queue.clear()
|
||||
|
||||
def _process_message(self, message: str) -> None:
|
||||
"""Process a single message."""
|
||||
if not self._processor:
|
||||
return
|
||||
|
||||
with self._processing_lock:
|
||||
self._state_manager.set_state(ProcessingState.PROCESSING)
|
||||
try:
|
||||
response = self._processor(message)
|
||||
if self._on_response and response:
|
||||
self._on_response(response)
|
||||
finally:
|
||||
self._state_manager.set_state(ProcessingState.IDLE)
|
||||
|
||||
def _process_next_in_queue(self) -> None:
|
||||
"""Process the next message in queue if any."""
|
||||
next_msg = self._queue.pop()
|
||||
if next_msg:
|
||||
self._process_message(next_msg)
|
||||
|
||||
def _on_state_change(self, old_state: ProcessingState, new_state: ProcessingState) -> None:
|
||||
"""Handle state changes."""
|
||||
# Auto-process queue when becoming idle
|
||||
if new_state == ProcessingState.IDLE and old_state == ProcessingState.PROCESSING:
|
||||
# Don't auto-process here to avoid recursion
|
||||
# The on_processing_complete method handles this
|
||||
pass
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Exports
|
||||
# ============================================================================
|
||||
|
||||
__all__ = [
|
||||
'ProcessingState',
|
||||
'MessagePriority',
|
||||
'QueuedMessage',
|
||||
'MessageQueue',
|
||||
'StateManager',
|
||||
'QueueDisplay',
|
||||
'MessageQueueHandler',
|
||||
]
|
||||
@@ -126,6 +126,7 @@ class PraisonAI:
|
||||
Initialize the PraisonAI object with default parameters.
|
||||
"""
|
||||
self.agent_yaml = agent_yaml
|
||||
self._interactive_mode = False # Flag for interactive TUI mode
|
||||
# Create config_list with AutoGen compatibility
|
||||
# Support multiple environment variable patterns for better compatibility
|
||||
# Priority order: MODEL_NAME > OPENAI_MODEL_NAME for model selection
|
||||
@@ -209,6 +210,17 @@ class PraisonAI:
|
||||
invocation_cmd = "praisonai"
|
||||
version_string = f"PraisonAI version {__version__}"
|
||||
|
||||
# Handle --interactive flag - start interactive TUI mode
|
||||
if getattr(args, 'interactive', False):
|
||||
self._start_interactive_mode(args)
|
||||
return None
|
||||
|
||||
# Handle --chat-mode flag - run single prompt in interactive style (non-interactive)
|
||||
if getattr(args, 'chat_mode', False):
|
||||
prompt = args.command or getattr(args, 'direct_prompt', None)
|
||||
if prompt:
|
||||
return self._run_chat_mode(prompt, args)
|
||||
|
||||
self.framework = args.framework or self.framework
|
||||
|
||||
# Update config_list model if --model flag is provided
|
||||
@@ -690,6 +702,16 @@ class PraisonAI:
|
||||
parser.add_argument("--port", type=int, default=8005, help="Server port (default: 8005)")
|
||||
parser.add_argument("--host", type=str, default="127.0.0.1", help="Server host (default: 127.0.0.1)")
|
||||
|
||||
# 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", action="store_true", dest="chat_mode", help="Run single prompt in interactive style (non-interactive, for testing)")
|
||||
|
||||
# Autonomy Mode - control AI action approval
|
||||
parser.add_argument("--autonomy", type=str, choices=["suggest", "auto_edit", "full_auto"], help="Set autonomy mode for AI actions")
|
||||
|
||||
# Sandbox Execution - secure command execution
|
||||
parser.add_argument("--sandbox", type=str, choices=["off", "basic", "strict"], help="Enable sandboxed command execution")
|
||||
|
||||
# If we're in a test environment, parse with empty args to avoid pytest interference
|
||||
if in_test_env:
|
||||
args, unknown_args = parser.parse_known_args([])
|
||||
@@ -2421,6 +2443,90 @@ class PraisonAI:
|
||||
except Exception as e:
|
||||
print(f"[red]ERROR: MCP command failed: {e}[/red]")
|
||||
|
||||
# Compiled regex patterns for sensitive data detection (compiled once, zero runtime cost)
|
||||
_SENSITIVE_PATTERNS = None
|
||||
|
||||
@classmethod
|
||||
def _get_sensitive_patterns(cls):
|
||||
"""Lazy-load and compile sensitive patterns only when needed."""
|
||||
if cls._SENSITIVE_PATTERNS is None:
|
||||
import re
|
||||
cls._SENSITIVE_PATTERNS = [
|
||||
# API Keys and Tokens
|
||||
(re.compile(r'(?i)(api[_-]?key|apikey)\s*[=:]\s*["\']?[a-zA-Z0-9_\-]{20,}'), "API Key"),
|
||||
(re.compile(r'(?i)(secret[_-]?key|secretkey)\s*[=:]\s*["\']?[a-zA-Z0-9_\-]{20,}'), "Secret Key"),
|
||||
(re.compile(r'(?i)(access[_-]?token|accesstoken)\s*[=:]\s*["\']?[a-zA-Z0-9_\-]{20,}'), "Access Token"),
|
||||
(re.compile(r'(?i)(auth[_-]?token|authtoken)\s*[=:]\s*["\']?[a-zA-Z0-9_\-]{20,}'), "Auth Token"),
|
||||
# AWS
|
||||
(re.compile(r'AKIA[0-9A-Z]{16}'), "AWS Access Key ID"),
|
||||
(re.compile(r'(?i)aws[_-]?secret[_-]?access[_-]?key\s*[=:]\s*["\']?[a-zA-Z0-9/+=]{40}'), "AWS Secret Key"),
|
||||
# Passwords
|
||||
(re.compile(r'(?i)(password|passwd|pwd)\s*[=:]\s*["\']?[^\s"\']{8,}'), "Password"),
|
||||
(re.compile(r'(?i)db[_-]?password\s*[=:]\s*["\']?[^\s"\']+'), "Database Password"),
|
||||
# Private Keys
|
||||
(re.compile(r'-----BEGIN (RSA |DSA |EC |OPENSSH )?PRIVATE KEY-----'), "Private Key"),
|
||||
(re.compile(r'-----BEGIN PGP PRIVATE KEY BLOCK-----'), "PGP Private Key"),
|
||||
# GitHub/GitLab tokens
|
||||
(re.compile(r'ghp_[a-zA-Z0-9]{36}'), "GitHub Personal Access Token"),
|
||||
(re.compile(r'gho_[a-zA-Z0-9]{36}'), "GitHub OAuth Token"),
|
||||
(re.compile(r'glpat-[a-zA-Z0-9\-]{20,}'), "GitLab Personal Access Token"),
|
||||
# Slack
|
||||
(re.compile(r'xox[baprs]-[0-9]{10,13}-[0-9]{10,13}-[a-zA-Z0-9]{24}'), "Slack Token"),
|
||||
# Generic secrets
|
||||
(re.compile(r'(?i)(client[_-]?secret)\s*[=:]\s*["\']?[a-zA-Z0-9_\-]{20,}'), "Client Secret"),
|
||||
(re.compile(r'(?i)(private[_-]?key)\s*[=:]\s*["\']?[a-zA-Z0-9_\-/+=]{20,}'), "Private Key Value"),
|
||||
]
|
||||
return cls._SENSITIVE_PATTERNS
|
||||
|
||||
# Sensitive file patterns (simple string matching - very fast)
|
||||
_SENSITIVE_FILES = {'.env', '.env.local', '.env.production', '.env.development',
|
||||
'id_rsa', 'id_dsa', 'id_ecdsa', 'id_ed25519',
|
||||
'.pem', '.key', '.p12', '.pfx', 'credentials', 'secrets.json',
|
||||
'secrets.yaml', 'secrets.yml', '.htpasswd', '.netrc'}
|
||||
_SENSITIVE_EXTENSIONS = {'.pem', '.key', '.p12', '.pfx', '.jks', '.keystore'}
|
||||
|
||||
def _check_sensitive_content(self, diff_content: str, staged_files: list) -> list:
|
||||
"""
|
||||
Check for sensitive content in staged changes.
|
||||
|
||||
Args:
|
||||
diff_content: The git diff content
|
||||
staged_files: List of staged file names
|
||||
|
||||
Returns:
|
||||
List of (file, issue_type, match) tuples for detected issues
|
||||
"""
|
||||
issues = []
|
||||
|
||||
# Quick check for sensitive files by name/extension
|
||||
for file_path in staged_files:
|
||||
file_name = file_path.split('/')[-1].lower()
|
||||
# Check exact file names
|
||||
if file_name in self._SENSITIVE_FILES:
|
||||
issues.append((file_path, "Sensitive File", file_name))
|
||||
continue
|
||||
# Check extensions
|
||||
for ext in self._SENSITIVE_EXTENSIONS:
|
||||
if file_name.endswith(ext):
|
||||
issues.append((file_path, "Sensitive Extension", ext))
|
||||
break
|
||||
|
||||
# Only scan diff content if it's not too large (performance guard)
|
||||
if len(diff_content) < 50000:
|
||||
patterns = self._get_sensitive_patterns()
|
||||
# Scan only added lines (lines starting with +)
|
||||
for line in diff_content.split('\n'):
|
||||
if line.startswith('+') and not line.startswith('+++'):
|
||||
for pattern, issue_type in patterns:
|
||||
match = pattern.search(line)
|
||||
if match:
|
||||
# Truncate match for display
|
||||
matched_text = match.group(0)[:50] + '...' if len(match.group(0)) > 50 else match.group(0)
|
||||
issues.append(("diff", issue_type, matched_text))
|
||||
break # One issue per line is enough
|
||||
|
||||
return issues
|
||||
|
||||
def handle_commit_command(self, args: list):
|
||||
"""
|
||||
Handle AI commit message generation.
|
||||
@@ -2428,7 +2534,10 @@ class PraisonAI:
|
||||
Generates a commit message based on staged changes using AI.
|
||||
|
||||
Args:
|
||||
args: Additional arguments (e.g., --push to auto-push)
|
||||
args: Additional arguments:
|
||||
--push: Auto-push after commit
|
||||
-a, --auto: Full auto mode (stage, commit, push) - aborts on security issues
|
||||
--no-verify: Skip sensitive content check
|
||||
"""
|
||||
try:
|
||||
import subprocess
|
||||
@@ -2442,6 +2551,12 @@ class PraisonAI:
|
||||
print("[red]ERROR: Not in a git repository[/red]")
|
||||
return
|
||||
|
||||
# Handle auto mode
|
||||
auto_mode = '-a' in args or '--auto' in args
|
||||
if auto_mode:
|
||||
print("[cyan]Auto-staging all changes...[/cyan]")
|
||||
subprocess.run(["git", "add", "-A"], capture_output=True)
|
||||
|
||||
# Get staged diff
|
||||
result = subprocess.run(
|
||||
["git", "diff", "--cached", "--stat"],
|
||||
@@ -2450,7 +2565,7 @@ class PraisonAI:
|
||||
)
|
||||
|
||||
if not result.stdout.strip():
|
||||
print("[yellow]No staged changes. Use 'git add' to stage files first.[/yellow]")
|
||||
print("[yellow]No staged changes. Use 'git add' to stage files first, or use -a/--auto.[/yellow]")
|
||||
return
|
||||
|
||||
# Get detailed diff for context
|
||||
@@ -2463,6 +2578,46 @@ class PraisonAI:
|
||||
# Limit diff size for context
|
||||
diff_content = diff_result.stdout[:8000] if len(diff_result.stdout) > 8000 else diff_result.stdout
|
||||
|
||||
# Security check for sensitive content (unless --no-verify)
|
||||
if '--no-verify' not in args:
|
||||
# Extract staged file names from stat output
|
||||
staged_files = [line.split('|')[0].strip() for line in result.stdout.strip().split('\n') if '|' in line]
|
||||
issues = self._check_sensitive_content(diff_result.stdout, staged_files)
|
||||
|
||||
if issues:
|
||||
print("\n[bold red]⚠️ SECURITY WARNING: Sensitive content detected![/bold red]")
|
||||
for file_path, issue_type, match in issues:
|
||||
print(f" [red]• {issue_type}[/red] in [yellow]{file_path}[/yellow]: {match}")
|
||||
print("\n[yellow]Options:[/yellow]")
|
||||
print(" [c] Continue anyway (not recommended)")
|
||||
print(" [a] Abort commit")
|
||||
print(" [i] Ignore and add to .gitignore")
|
||||
|
||||
# In auto mode, abort on security issues
|
||||
if auto_mode:
|
||||
print("[red]Auto mode aborted due to security concerns. Use --no-verify to skip.[/red]")
|
||||
return
|
||||
|
||||
sec_choice = input("\nYour choice [c/a/i]: ").strip().lower()
|
||||
if sec_choice == 'a':
|
||||
print("[yellow]Commit aborted due to security concerns.[/yellow]")
|
||||
return
|
||||
elif sec_choice == 'i':
|
||||
# Add sensitive files to .gitignore
|
||||
sensitive_file_paths = [f for f, t, _ in issues if t in ("Sensitive File", "Sensitive Extension")]
|
||||
if sensitive_file_paths:
|
||||
with open('.gitignore', 'a') as gi:
|
||||
gi.write('\n# Auto-added by praisonai commit\n')
|
||||
for fp in sensitive_file_paths:
|
||||
gi.write(f'{fp}\n')
|
||||
print(f"[green]Added {len(sensitive_file_paths)} file(s) to .gitignore[/green]")
|
||||
# Unstage the sensitive files
|
||||
subprocess.run(["git", "reset", "HEAD", "--"] + sensitive_file_paths, capture_output=True)
|
||||
print("[cyan]Unstaged sensitive files. Please re-run commit.[/cyan]")
|
||||
return
|
||||
# else continue
|
||||
print("[yellow]Proceeding despite security warnings...[/yellow]")
|
||||
|
||||
print("[bold]Staged changes:[/bold]")
|
||||
print(result.stdout)
|
||||
print("\n[bold]Generating commit message...[/bold]")
|
||||
@@ -2508,6 +2663,14 @@ Provide ONLY the commit message, no explanations."""
|
||||
print("\n[bold green]Suggested commit message:[/bold green]")
|
||||
print(f"[cyan]{commit_message}[/cyan]")
|
||||
|
||||
# In auto mode, skip confirmation and commit + push
|
||||
if auto_mode:
|
||||
subprocess.run(["git", "commit", "-m", commit_message], check=True)
|
||||
print("[green]✅ Committed successfully![/green]")
|
||||
subprocess.run(["git", "push"], check=True)
|
||||
print("[green]✅ Pushed to remote![/green]")
|
||||
return
|
||||
|
||||
# Ask for confirmation
|
||||
print("\n[bold]Options:[/bold]")
|
||||
print(" [y] Use this message and commit")
|
||||
@@ -2752,6 +2915,10 @@ Provide ONLY the commit message, no explanations."""
|
||||
"backstory": "You are a helpful AI assistant"
|
||||
}
|
||||
|
||||
# Suppress verbose output in interactive mode (no Agent Info, Task, Response panels)
|
||||
if getattr(self, '_interactive_mode', False):
|
||||
agent_config["verbose"] = False
|
||||
|
||||
# Add llm if specified
|
||||
if hasattr(self, 'args') and self.args.llm:
|
||||
# Check if max_tokens is specified - pass as dict config
|
||||
@@ -3605,6 +3772,666 @@ Now, {final_instruction.lower()}:"""
|
||||
print(f"[red]ERROR: Research failed: {e}[/red]")
|
||||
sys.exit(1)
|
||||
|
||||
def _start_interactive_mode(self, args):
|
||||
"""
|
||||
Start interactive TUI mode with streaming responses and tool support.
|
||||
|
||||
UX inspired by Gemini CLI, Codex CLI, and Claude Code:
|
||||
- Streaming text output (no boxes)
|
||||
- Tool status indicators
|
||||
- Built-in tools (file ops, shell, web search)
|
||||
- Autocomplete for slash commands and @file mentions
|
||||
- /compact for context compression
|
||||
- /model for model switching
|
||||
- /stats for token/cost tracking
|
||||
- /undo for reverting changes
|
||||
"""
|
||||
try:
|
||||
from rich.console import Console
|
||||
import os
|
||||
|
||||
console = Console()
|
||||
|
||||
# Set interactive mode flag
|
||||
self._interactive_mode = True
|
||||
|
||||
# Load interactive tools
|
||||
tools_list = self._load_interactive_tools()
|
||||
|
||||
# Import message queue components
|
||||
from praisonai.cli.features.message_queue import (
|
||||
MessageQueue, StateManager, QueueDisplay, ProcessingState
|
||||
)
|
||||
import threading
|
||||
|
||||
# Create message queue and state manager
|
||||
message_queue = MessageQueue()
|
||||
state_manager = StateManager()
|
||||
queue_display = QueueDisplay(message_queue, state_manager)
|
||||
processing_lock = threading.Lock()
|
||||
|
||||
# Session state
|
||||
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,
|
||||
'undo_stack': [], # Stack of (prompt, response) for undo
|
||||
'message_queue': message_queue,
|
||||
'state_manager': state_manager,
|
||||
'queue_display': queue_display,
|
||||
'processing_lock': processing_lock,
|
||||
}
|
||||
|
||||
# Try to use prompt_toolkit for autocomplete
|
||||
prompt_session = None
|
||||
try:
|
||||
from prompt_toolkit import PromptSession
|
||||
from prompt_toolkit.completion import WordCompleter
|
||||
from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
|
||||
from prompt_toolkit.history import InMemoryHistory
|
||||
|
||||
# Create completer for slash commands
|
||||
commands = ['help', 'exit', 'quit', 'clear', 'tools', 'profile', 'model', 'stats', 'compact', 'undo', 'queue', 'q']
|
||||
slash_completer = WordCompleter(
|
||||
[f'/{cmd}' for cmd in commands],
|
||||
ignore_case=True
|
||||
)
|
||||
|
||||
prompt_session = PromptSession(
|
||||
message="❯ ",
|
||||
completer=slash_completer,
|
||||
auto_suggest=AutoSuggestFromHistory(),
|
||||
history=InMemoryHistory()
|
||||
)
|
||||
except ImportError:
|
||||
pass # Fall back to simple input
|
||||
|
||||
# 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("[dim]Use @file.txt to include file content | Queue messages while processing[/dim]\n")
|
||||
|
||||
running = True
|
||||
while running:
|
||||
try:
|
||||
# Get user input (with autocomplete if available)
|
||||
if prompt_session:
|
||||
user_input = prompt_session.prompt().strip()
|
||||
else:
|
||||
user_input = input("❯ ").strip()
|
||||
|
||||
if not user_input:
|
||||
continue
|
||||
|
||||
# Handle slash commands
|
||||
if user_input.startswith("/"):
|
||||
cmd_parts = user_input[1:].split(maxsplit=1)
|
||||
cmd = cmd_parts[0].lower() if cmd_parts else ""
|
||||
cmd_args = cmd_parts[1] if len(cmd_parts) > 1 else ""
|
||||
|
||||
if cmd in ["exit", "quit", "q"]:
|
||||
console.print("[dim]Goodbye![/dim]")
|
||||
running = False
|
||||
continue
|
||||
elif cmd == "help":
|
||||
self._print_interactive_help(console)
|
||||
continue
|
||||
elif cmd == "clear":
|
||||
console.clear()
|
||||
continue
|
||||
elif cmd == "tools":
|
||||
console.print(f"[cyan]Available tools: {len(tools_list)}[/cyan]")
|
||||
for tool in tools_list:
|
||||
name = getattr(tool, '__name__', str(tool))
|
||||
console.print(f" • {name}")
|
||||
continue
|
||||
elif cmd == "profile":
|
||||
session_state['show_profiling'] = not session_state['show_profiling']
|
||||
status = "enabled" if session_state['show_profiling'] else "disabled"
|
||||
console.print(f"[cyan]Profiling {status}[/cyan]")
|
||||
continue
|
||||
elif cmd == "model":
|
||||
self._handle_model_command(console, cmd_args, session_state)
|
||||
continue
|
||||
elif cmd == "stats":
|
||||
self._handle_stats_command(console, session_state)
|
||||
continue
|
||||
elif cmd == "compact":
|
||||
self._handle_compact_command(console, session_state)
|
||||
continue
|
||||
elif cmd == "undo":
|
||||
self._handle_undo_command(console, session_state)
|
||||
continue
|
||||
elif cmd == "queue":
|
||||
self._handle_queue_command(console, cmd_args, session_state)
|
||||
continue
|
||||
else:
|
||||
console.print(f"[yellow]Unknown command: /{cmd}. Type /help for available commands.[/yellow]")
|
||||
continue
|
||||
|
||||
# Process @file mentions before sending to LLM
|
||||
processed_input = self._process_at_mentions(user_input, console)
|
||||
|
||||
# Process the prompt with profiling if enabled
|
||||
self._process_interactive_prompt(
|
||||
processed_input,
|
||||
tools_list,
|
||||
console,
|
||||
show_profiling=session_state['show_profiling'],
|
||||
session_state=session_state
|
||||
)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
console.print("\n[dim]Use /exit to quit[/dim]")
|
||||
except EOFError:
|
||||
running = False
|
||||
|
||||
except ImportError as e:
|
||||
print(f"[red]ERROR: Interactive mode requires rich: {e}[/red]")
|
||||
print("Install with: pip install rich")
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
print(f"[red]ERROR: Interactive mode failed: {e}[/red]")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
||||
|
||||
def _load_interactive_tools(self):
|
||||
"""Load tools for interactive mode."""
|
||||
tools_list = []
|
||||
try:
|
||||
from praisonaiagents.tools import (
|
||||
read_file as tool_read_file,
|
||||
write_file as tool_write_file,
|
||||
list_files as tool_list_files,
|
||||
execute_command,
|
||||
internet_search
|
||||
)
|
||||
tools_list = [tool_read_file, tool_write_file, tool_list_files, execute_command, internet_search]
|
||||
except ImportError:
|
||||
# Try individual imports
|
||||
try:
|
||||
from praisonaiagents.tools import read_file as tool_read_file
|
||||
tools_list.append(tool_read_file)
|
||||
except ImportError:
|
||||
pass
|
||||
try:
|
||||
from praisonaiagents.tools import write_file as tool_write_file
|
||||
tools_list.append(tool_write_file)
|
||||
except ImportError:
|
||||
pass
|
||||
try:
|
||||
from praisonaiagents.tools import list_files as tool_list_files
|
||||
tools_list.append(tool_list_files)
|
||||
except ImportError:
|
||||
pass
|
||||
try:
|
||||
from praisonaiagents.tools import execute_command
|
||||
tools_list.append(execute_command)
|
||||
except ImportError:
|
||||
pass
|
||||
try:
|
||||
from praisonaiagents.tools import internet_search
|
||||
tools_list.append(internet_search)
|
||||
except ImportError:
|
||||
pass
|
||||
return tools_list
|
||||
|
||||
def _print_interactive_help(self, console):
|
||||
"""Print help for interactive mode."""
|
||||
console.print("\n[bold]Commands:[/bold]")
|
||||
console.print(" /help - Show this help")
|
||||
console.print(" /exit - Exit interactive mode")
|
||||
console.print(" /clear - Clear screen")
|
||||
console.print(" /tools - List available tools")
|
||||
console.print(" /profile - Toggle profiling (show timing breakdown)")
|
||||
console.print(" /model [name] - Show or change current model")
|
||||
console.print(" /stats - Show session statistics (tokens, cost)")
|
||||
console.print(" /compact - Compress conversation history")
|
||||
console.print(" /undo - Undo last response")
|
||||
console.print(" /queue - Show queued messages")
|
||||
console.print(" /queue clear - Clear message queue")
|
||||
console.print("\n[bold]@ Mentions:[/bold]")
|
||||
console.print(" @file.txt - Include file content in prompt")
|
||||
console.print(" @src/ - Include directory listing")
|
||||
console.print("\n[bold]Features:[/bold]")
|
||||
console.print(" • File operations (read, write, list)")
|
||||
console.print(" • Shell command execution")
|
||||
console.print(" • Web search")
|
||||
console.print(" • Context compression for long sessions")
|
||||
console.print(" • Queue messages while agent is processing")
|
||||
console.print("")
|
||||
|
||||
def _process_at_mentions(self, user_input, console):
|
||||
"""
|
||||
Process @file mentions in user input.
|
||||
|
||||
Supports:
|
||||
- @file.txt - Include file content
|
||||
- @path/to/file - Include file content
|
||||
- @directory/ - Include directory listing
|
||||
|
||||
Inspired by Gemini CLI and Claude Code @file syntax.
|
||||
"""
|
||||
import re
|
||||
import os
|
||||
|
||||
# Pattern to match @path (but not email addresses)
|
||||
# Must be at start or preceded by whitespace
|
||||
pattern = r'(?:^|\s)@([^\s@]+)'
|
||||
|
||||
matches = re.findall(pattern, user_input)
|
||||
if not matches:
|
||||
return user_input
|
||||
|
||||
processed_input = user_input
|
||||
file_contents = []
|
||||
|
||||
for path in matches:
|
||||
# Clean up the path
|
||||
clean_path = path.strip()
|
||||
|
||||
# Expand ~ to home directory
|
||||
if clean_path.startswith('~'):
|
||||
clean_path = os.path.expanduser(clean_path)
|
||||
|
||||
# Make absolute if relative
|
||||
if not os.path.isabs(clean_path):
|
||||
clean_path = os.path.abspath(clean_path)
|
||||
|
||||
try:
|
||||
if os.path.isfile(clean_path):
|
||||
# Read file content
|
||||
with open(clean_path, 'r', encoding='utf-8', errors='ignore') as f:
|
||||
content = f.read()
|
||||
|
||||
# Truncate if too large (>50KB)
|
||||
if len(content) > 50000:
|
||||
content = content[:50000] + "\n... [truncated, file too large]"
|
||||
|
||||
file_contents.append(f"\n--- Content of {path} ---\n{content}\n--- End of {path} ---\n")
|
||||
console.print(f"[dim]📄 Included: {path} ({len(content)} chars)[/dim]")
|
||||
|
||||
elif os.path.isdir(clean_path):
|
||||
# List directory contents
|
||||
try:
|
||||
entries = os.listdir(clean_path)
|
||||
# Filter out hidden files and common ignore patterns
|
||||
entries = [e for e in entries if not e.startswith('.') and e not in ['node_modules', '__pycache__', 'venv', '.git']]
|
||||
entries.sort()
|
||||
|
||||
listing = "\n".join(f" {e}" for e in entries[:50])
|
||||
if len(entries) > 50:
|
||||
listing += f"\n ... and {len(entries) - 50} more files"
|
||||
|
||||
file_contents.append(f"\n--- Directory listing of {path} ---\n{listing}\n--- End of {path} ---\n")
|
||||
console.print(f"[dim]📁 Listed: {path} ({len(entries)} items)[/dim]")
|
||||
except PermissionError:
|
||||
console.print(f"[yellow]⚠ Permission denied: {path}[/yellow]")
|
||||
else:
|
||||
console.print(f"[yellow]⚠ Not found: {path}[/yellow]")
|
||||
|
||||
except Exception as e:
|
||||
console.print(f"[yellow]⚠ Error reading {path}: {e}[/yellow]")
|
||||
|
||||
# Remove @mentions from the original input and append file contents
|
||||
for path in matches:
|
||||
processed_input = processed_input.replace(f"@{path}", "")
|
||||
|
||||
processed_input = processed_input.strip()
|
||||
if file_contents:
|
||||
processed_input = processed_input + "\n" + "\n".join(file_contents)
|
||||
|
||||
return processed_input
|
||||
|
||||
def _handle_model_command(self, console, args, session_state):
|
||||
"""Handle /model command - show or change current model."""
|
||||
if not args:
|
||||
# Show current model
|
||||
console.print(f"[cyan]Current model: {session_state['current_model']}[/cyan]")
|
||||
console.print("\n[dim]Available models (examples):[/dim]")
|
||||
console.print(" • gpt-4o, gpt-4o-mini")
|
||||
console.print(" • claude-3-5-sonnet, claude-3-haiku")
|
||||
console.print(" • gemini-2.0-flash, gemini-1.5-pro")
|
||||
console.print("\n[dim]Usage: /model <model-name>[/dim]")
|
||||
else:
|
||||
# Change model
|
||||
new_model = args.strip()
|
||||
old_model = session_state['current_model']
|
||||
session_state['current_model'] = new_model
|
||||
console.print(f"[green]✓ Model changed: {old_model} → {new_model}[/green]")
|
||||
|
||||
def _handle_stats_command(self, console, session_state):
|
||||
"""Handle /stats command - show session statistics."""
|
||||
from praisonai.cli.features.cost_tracker import get_pricing
|
||||
|
||||
console.print("\n[bold cyan]Session Statistics[/bold cyan]")
|
||||
console.print(f" Model: {session_state['current_model']}")
|
||||
console.print(f" Requests: {session_state['request_count']}")
|
||||
console.print(f" Input tokens: {session_state['total_input_tokens']:,}")
|
||||
console.print(f" Output tokens: {session_state['total_output_tokens']:,}")
|
||||
console.print(f" Total tokens: {session_state['total_input_tokens'] + session_state['total_output_tokens']:,}")
|
||||
|
||||
# Calculate estimated cost
|
||||
try:
|
||||
pricing = get_pricing(session_state['current_model'])
|
||||
cost = pricing.calculate_cost(
|
||||
session_state['total_input_tokens'],
|
||||
session_state['total_output_tokens']
|
||||
)
|
||||
console.print(f" Estimated cost: ${cost:.4f}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Show conversation history size
|
||||
history_len = len(session_state['conversation_history'])
|
||||
console.print(f" History turns: {history_len}")
|
||||
console.print("")
|
||||
|
||||
def _handle_compact_command(self, console, session_state):
|
||||
"""
|
||||
Handle /compact command - compress conversation history.
|
||||
|
||||
Inspired by Claude Code's /compact and Gemini CLI's /compress.
|
||||
Uses LLM to summarize older conversation turns while keeping recent ones.
|
||||
"""
|
||||
history = session_state['conversation_history']
|
||||
|
||||
if len(history) < 4:
|
||||
console.print("[yellow]Not enough conversation history to compact (need at least 4 turns)[/yellow]")
|
||||
return
|
||||
|
||||
console.print("[dim]Compacting conversation history...[/dim]")
|
||||
|
||||
try:
|
||||
from praisonaiagents import Agent
|
||||
|
||||
# Keep the last 2 turns (4 messages: 2 user + 2 assistant)
|
||||
keep_count = 4
|
||||
to_compress = history[:-keep_count] if len(history) > keep_count else []
|
||||
to_keep = history[-keep_count:] if len(history) > keep_count else history
|
||||
|
||||
if not to_compress:
|
||||
console.print("[yellow]Not enough old history to compress[/yellow]")
|
||||
return
|
||||
|
||||
# Format history for summarization
|
||||
history_text = ""
|
||||
for i, msg in enumerate(to_compress):
|
||||
role = msg.get('role', 'unknown')
|
||||
content = msg.get('content', '')
|
||||
if len(content) > 500:
|
||||
content = content[:500] + "..."
|
||||
history_text += f"{role}: {content}\n\n"
|
||||
|
||||
# Create summarization prompt
|
||||
summary_prompt = f"""Summarize the following conversation history into a concise state snapshot.
|
||||
Focus on:
|
||||
- Key facts and decisions made
|
||||
- Important context that should be remembered
|
||||
- Any ongoing tasks or goals
|
||||
|
||||
Conversation to summarize:
|
||||
{history_text}
|
||||
|
||||
Provide a concise summary (max 200 words):"""
|
||||
|
||||
# Use a lightweight agent for summarization
|
||||
summarizer = Agent(
|
||||
name="Summarizer",
|
||||
role="Conversation Summarizer",
|
||||
goal="Create concise summaries of conversations",
|
||||
backstory="You summarize conversations while preserving key information.",
|
||||
verbose=False,
|
||||
llm=session_state['current_model']
|
||||
)
|
||||
|
||||
summary = summarizer.chat(summary_prompt, stream=False)
|
||||
|
||||
# Replace old history with summary + recent turns
|
||||
new_history = [
|
||||
{"role": "system", "content": f"[Previous conversation summary]: {summary}"},
|
||||
*to_keep
|
||||
]
|
||||
|
||||
old_count = len(history)
|
||||
session_state['conversation_history'] = new_history
|
||||
new_count = len(new_history)
|
||||
|
||||
console.print(f"[green]✓ Compacted {old_count} turns → {new_count} turns[/green]")
|
||||
console.print(f"[dim]Summary: {str(summary)[:100]}...[/dim]")
|
||||
|
||||
except Exception as e:
|
||||
console.print(f"[red]Error compacting: {e}[/red]")
|
||||
|
||||
def _handle_undo_command(self, console, session_state):
|
||||
"""
|
||||
Handle /undo command - undo the last response.
|
||||
|
||||
Inspired by Codex CLI's /undo and Gemini CLI's /restore.
|
||||
Removes the last user prompt and assistant response from history.
|
||||
"""
|
||||
history = session_state['conversation_history']
|
||||
undo_stack = session_state['undo_stack']
|
||||
|
||||
if len(history) < 2:
|
||||
console.print("[yellow]Nothing to undo[/yellow]")
|
||||
return
|
||||
|
||||
# Remove last assistant response and user prompt
|
||||
if len(history) >= 2:
|
||||
removed_assistant = history.pop()
|
||||
removed_user = history.pop()
|
||||
|
||||
# Store in undo stack for potential redo
|
||||
undo_stack.append((removed_user, removed_assistant))
|
||||
|
||||
console.print("[green]✓ Undone last turn[/green]")
|
||||
console.print(f"[dim]Removed: {str(removed_user.get('content', ''))[:50]}...[/dim]")
|
||||
else:
|
||||
console.print("[yellow]Not enough history to undo[/yellow]")
|
||||
|
||||
def _handle_queue_command(self, console, args, session_state):
|
||||
"""
|
||||
Handle /queue command - show or manage message queue.
|
||||
|
||||
Usage:
|
||||
- /queue - Show queued messages
|
||||
- /queue clear - Clear the queue
|
||||
- /queue remove N - Remove message at index N
|
||||
"""
|
||||
message_queue = session_state.get('message_queue')
|
||||
state_manager = session_state.get('state_manager')
|
||||
queue_display = session_state.get('queue_display')
|
||||
|
||||
if not message_queue:
|
||||
console.print("[yellow]Message queue not initialized[/yellow]")
|
||||
return
|
||||
|
||||
args = args.strip().lower() if args else ""
|
||||
|
||||
if args == "clear":
|
||||
count = message_queue.count
|
||||
message_queue.clear()
|
||||
console.print(f"[green]✓ Cleared {count} queued message(s)[/green]")
|
||||
elif args.startswith("remove "):
|
||||
try:
|
||||
index = int(args.split()[1])
|
||||
removed = message_queue.remove_at(index)
|
||||
if removed:
|
||||
console.print(f"[green]✓ Removed: {removed[:50]}...[/green]")
|
||||
else:
|
||||
console.print(f"[yellow]Invalid index: {index}[/yellow]")
|
||||
except (ValueError, IndexError):
|
||||
console.print("[yellow]Usage: /queue remove <index>[/yellow]")
|
||||
else:
|
||||
# Show queue status
|
||||
if state_manager:
|
||||
status = queue_display.format_status() if queue_display else ""
|
||||
if status:
|
||||
console.print(f"[cyan]{status}[/cyan]")
|
||||
|
||||
if message_queue.is_empty:
|
||||
console.print("[dim]No messages in queue[/dim]")
|
||||
else:
|
||||
console.print(f"\n[bold cyan]Queued Messages ({message_queue.count}):[/bold cyan]")
|
||||
for i, msg in enumerate(message_queue.get_all()):
|
||||
display_msg = msg[:60] + "..." if len(msg) > 60 else msg
|
||||
console.print(f" {i}. ↳ {display_msg}")
|
||||
console.print("\n[dim]Use /queue clear to clear, /queue remove N to remove[/dim]")
|
||||
|
||||
def _run_chat_mode(self, prompt, args):
|
||||
"""
|
||||
Run a single prompt in interactive style (non-interactive mode for testing).
|
||||
|
||||
Usage: praisonai "your prompt" --chat
|
||||
|
||||
This runs the prompt using the same agent/tools as interactive mode
|
||||
but exits after one response (useful for testing and scripting).
|
||||
"""
|
||||
from rich.console import Console
|
||||
|
||||
console = Console()
|
||||
self._interactive_mode = True # Use interactive mode settings
|
||||
|
||||
# Load tools
|
||||
tools_list = self._load_interactive_tools()
|
||||
|
||||
console.print(f"[dim]Chat mode: {len(tools_list)} tools available[/dim]")
|
||||
console.print(f"[dim]Prompt: {prompt[:50]}{'...' if len(prompt) > 50 else ''}[/dim]\n")
|
||||
|
||||
# Process the prompt with profiling enabled for testing
|
||||
self._process_interactive_prompt(prompt, tools_list, console, show_profiling=True)
|
||||
|
||||
return None
|
||||
|
||||
def _process_interactive_prompt(self, prompt, tools_list, console, show_profiling=False, session_state=None):
|
||||
"""Process a prompt in interactive mode with streaming."""
|
||||
from rich.live import Live
|
||||
from rich.spinner import Spinner
|
||||
import sys
|
||||
import warnings
|
||||
import logging
|
||||
import time
|
||||
|
||||
# Profiling timestamps
|
||||
timings = {}
|
||||
timings['start'] = time.time()
|
||||
|
||||
# Store original log levels to restore later (no global impact)
|
||||
original_levels = {}
|
||||
loggers_to_suppress = ["httpx", "httpcore", "duckduckgo_search", "crawl4ai", "lib"]
|
||||
for logger_name in loggers_to_suppress:
|
||||
logger = logging.getLogger(logger_name)
|
||||
original_levels[logger_name] = logger.level
|
||||
logger.setLevel(logging.WARNING)
|
||||
|
||||
# Temporarily filter warnings (scoped to this function)
|
||||
with warnings.catch_warnings():
|
||||
warnings.filterwarnings("ignore", message=".*duckduckgo_search.*")
|
||||
warnings.filterwarnings("ignore", message=".*has been renamed.*")
|
||||
|
||||
try:
|
||||
# Import agent
|
||||
timings['import_start'] = time.time()
|
||||
from praisonaiagents import Agent
|
||||
timings['import_end'] = time.time()
|
||||
|
||||
# Determine model to use
|
||||
model = None
|
||||
if session_state and session_state.get('current_model'):
|
||||
model = session_state['current_model']
|
||||
elif hasattr(self, 'args') and getattr(self.args, 'llm', None):
|
||||
model = self.args.llm
|
||||
|
||||
# Show thinking indicator and create agent
|
||||
timings['agent_create_start'] = time.time()
|
||||
with Live(Spinner("dots", text="Thinking...", style="cyan"), console=console, refresh_per_second=10, transient=True):
|
||||
# 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 for file operations, shell commands, and web search. Use tools when needed to complete tasks.",
|
||||
tools=tools_list if tools_list else None,
|
||||
verbose=False, # Suppress verbose panels
|
||||
llm=model
|
||||
)
|
||||
timings['agent_create_end'] = time.time()
|
||||
|
||||
# Get response
|
||||
console.print() # New line before response
|
||||
|
||||
timings['llm_start'] = time.time()
|
||||
|
||||
# Use chat method (streaming is handled internally by verbose mode)
|
||||
response = agent.chat(prompt, stream=False)
|
||||
|
||||
timings['llm_end'] = time.time()
|
||||
|
||||
# Check if tools were used by looking at agent's tool execution history
|
||||
if hasattr(agent, '_tool_calls') and agent._tool_calls:
|
||||
for tool_call in agent._tool_calls:
|
||||
tool_name = tool_call.get('name', 'unknown')
|
||||
console.print(f"[dim]⚙ Used tool: {tool_name}[/dim]")
|
||||
|
||||
# Print response with simulated streaming effect
|
||||
timings['display_start'] = time.time()
|
||||
response_str = str(response) if response else ""
|
||||
if response_str:
|
||||
words = response_str.split()
|
||||
for i, word in enumerate(words):
|
||||
console.print(word + " ", end="")
|
||||
sys.stdout.flush()
|
||||
if i % 15 == 14: # Small pause every 15 words for streaming effect
|
||||
time.sleep(0.005)
|
||||
console.print("\n") # Final newline
|
||||
timings['display_end'] = time.time()
|
||||
|
||||
# Update session state with token estimates and history
|
||||
if session_state is not None:
|
||||
# Estimate tokens (rough: ~4 chars per token)
|
||||
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
|
||||
|
||||
# Add to conversation history
|
||||
session_state['conversation_history'].append({
|
||||
'role': 'user',
|
||||
'content': prompt
|
||||
})
|
||||
session_state['conversation_history'].append({
|
||||
'role': 'assistant',
|
||||
'content': response_str
|
||||
})
|
||||
|
||||
# Show profiling if enabled
|
||||
if show_profiling:
|
||||
timings['total'] = time.time() - timings['start']
|
||||
console.print("\n[dim]─── Profiling ───[/dim]")
|
||||
console.print(f"[dim]Import: {(timings['import_end'] - timings['import_start'])*1000:.1f}ms[/dim]")
|
||||
console.print(f"[dim]Agent setup: {(timings['agent_create_end'] - timings['agent_create_start'])*1000:.1f}ms[/dim]")
|
||||
console.print(f"[dim]LLM call: {(timings['llm_end'] - timings['llm_start'])*1000:.1f}ms[/dim]")
|
||||
console.print(f"[dim]Display: {(timings['display_end'] - timings['display_start'])*1000:.1f}ms[/dim]")
|
||||
console.print(f"[dim]Total: {timings['total']*1000:.1f}ms[/dim]")
|
||||
|
||||
except Exception as e:
|
||||
console.print(f"\n[red]Error: {e}[/red]")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
finally:
|
||||
# Restore original log levels (no global impact on main package)
|
||||
for logger_name, level in original_levels.items():
|
||||
logging.getLogger(logger_name).setLevel(level)
|
||||
|
||||
if __name__ == "__main__":
|
||||
praison_ai = PraisonAI()
|
||||
praison_ai.main()
|
||||
@@ -57,7 +57,7 @@ class CloudDeployer:
|
||||
file.write("FROM python:3.11-slim\n")
|
||||
file.write("WORKDIR /app\n")
|
||||
file.write("COPY . .\n")
|
||||
file.write("RUN pip install flask praisonai==2.3.45 gunicorn markdown\n")
|
||||
file.write("RUN pip install flask praisonai==2.3.50 gunicorn markdown\n")
|
||||
file.write("EXPOSE 8080\n")
|
||||
file.write('CMD ["gunicorn", "-b", "0.0.0.0:8080", "api:app"]\n')
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "2.3.45"
|
||||
__version__ = "2.3.50"
|
||||
@@ -0,0 +1,473 @@
|
||||
"""
|
||||
Unit tests for Message Queue feature.
|
||||
|
||||
Tests for:
|
||||
1. MessageQueue - FIFO queue for messages
|
||||
2. ProcessingState - State management
|
||||
3. QueuedMessage - Message with metadata
|
||||
4. MessageQueueHandler - Main handler class
|
||||
"""
|
||||
|
||||
import time
|
||||
import threading
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 1. MessageQueue Tests - FIFO operations
|
||||
# ============================================================================
|
||||
|
||||
class TestMessageQueue:
|
||||
"""Test MessageQueue FIFO operations."""
|
||||
|
||||
def test_create_empty_queue(self):
|
||||
"""Queue should start empty."""
|
||||
from praisonai.cli.features.message_queue import MessageQueue
|
||||
queue = MessageQueue()
|
||||
assert queue.is_empty
|
||||
assert queue.count == 0
|
||||
|
||||
def test_add_message(self):
|
||||
"""Should add message to queue."""
|
||||
from praisonai.cli.features.message_queue import MessageQueue
|
||||
queue = MessageQueue()
|
||||
queue.add("Hello")
|
||||
assert not queue.is_empty
|
||||
assert queue.count == 1
|
||||
|
||||
def test_add_multiple_messages(self):
|
||||
"""Should add multiple messages in order."""
|
||||
from praisonai.cli.features.message_queue import MessageQueue
|
||||
queue = MessageQueue()
|
||||
queue.add("First")
|
||||
queue.add("Second")
|
||||
queue.add("Third")
|
||||
assert queue.count == 3
|
||||
|
||||
def test_pop_message_fifo(self):
|
||||
"""Should pop messages in FIFO order."""
|
||||
from praisonai.cli.features.message_queue import MessageQueue
|
||||
queue = MessageQueue()
|
||||
queue.add("First")
|
||||
queue.add("Second")
|
||||
msg = queue.pop()
|
||||
assert msg == "First"
|
||||
assert queue.count == 1
|
||||
|
||||
def test_pop_empty_queue(self):
|
||||
"""Should return None when popping empty queue."""
|
||||
from praisonai.cli.features.message_queue import MessageQueue
|
||||
queue = MessageQueue()
|
||||
msg = queue.pop()
|
||||
assert msg is None
|
||||
|
||||
def test_peek_message(self):
|
||||
"""Should peek without removing."""
|
||||
from praisonai.cli.features.message_queue import MessageQueue
|
||||
queue = MessageQueue()
|
||||
queue.add("First")
|
||||
queue.add("Second")
|
||||
msg = queue.peek()
|
||||
assert msg == "First"
|
||||
assert queue.count == 2 # Not removed
|
||||
|
||||
def test_peek_empty_queue(self):
|
||||
"""Should return None when peeking empty queue."""
|
||||
from praisonai.cli.features.message_queue import MessageQueue
|
||||
queue = MessageQueue()
|
||||
msg = queue.peek()
|
||||
assert msg is None
|
||||
|
||||
def test_clear_queue(self):
|
||||
"""Should clear all messages."""
|
||||
from praisonai.cli.features.message_queue import MessageQueue
|
||||
queue = MessageQueue()
|
||||
queue.add("First")
|
||||
queue.add("Second")
|
||||
queue.clear()
|
||||
assert queue.is_empty
|
||||
assert queue.count == 0
|
||||
|
||||
def test_get_all_messages(self):
|
||||
"""Should get all messages as list."""
|
||||
from praisonai.cli.features.message_queue import MessageQueue
|
||||
queue = MessageQueue()
|
||||
queue.add("First")
|
||||
queue.add("Second")
|
||||
messages = queue.get_all()
|
||||
assert messages == ["First", "Second"]
|
||||
assert queue.count == 2 # Not removed
|
||||
|
||||
def test_remove_at_index(self):
|
||||
"""Should remove message at specific index."""
|
||||
from praisonai.cli.features.message_queue import MessageQueue
|
||||
queue = MessageQueue()
|
||||
queue.add("First")
|
||||
queue.add("Second")
|
||||
queue.add("Third")
|
||||
removed = queue.remove_at(1)
|
||||
assert removed == "Second"
|
||||
assert queue.count == 2
|
||||
assert queue.get_all() == ["First", "Third"]
|
||||
|
||||
def test_remove_at_invalid_index(self):
|
||||
"""Should return None for invalid index."""
|
||||
from praisonai.cli.features.message_queue import MessageQueue
|
||||
queue = MessageQueue()
|
||||
queue.add("First")
|
||||
removed = queue.remove_at(5)
|
||||
assert removed is None
|
||||
assert queue.count == 1
|
||||
|
||||
def test_add_empty_message_ignored(self):
|
||||
"""Should ignore empty messages."""
|
||||
from praisonai.cli.features.message_queue import MessageQueue
|
||||
queue = MessageQueue()
|
||||
queue.add("")
|
||||
queue.add(" ")
|
||||
assert queue.is_empty
|
||||
|
||||
def test_thread_safety(self):
|
||||
"""Queue should be thread-safe."""
|
||||
from praisonai.cli.features.message_queue import MessageQueue
|
||||
queue = MessageQueue()
|
||||
results = []
|
||||
|
||||
def add_messages():
|
||||
for i in range(100):
|
||||
queue.add(f"msg-{i}")
|
||||
|
||||
def pop_messages():
|
||||
for _ in range(100):
|
||||
msg = queue.pop()
|
||||
if msg:
|
||||
results.append(msg)
|
||||
|
||||
t1 = threading.Thread(target=add_messages)
|
||||
t2 = threading.Thread(target=pop_messages)
|
||||
t1.start()
|
||||
t2.start()
|
||||
t1.join()
|
||||
t2.join()
|
||||
|
||||
# All messages should be accounted for
|
||||
total = len(results) + queue.count
|
||||
assert total == 100
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 2. ProcessingState Tests
|
||||
# ============================================================================
|
||||
|
||||
class TestProcessingState:
|
||||
"""Test ProcessingState enum and state management."""
|
||||
|
||||
def test_state_enum_values(self):
|
||||
"""Should have correct state values."""
|
||||
from praisonai.cli.features.message_queue import ProcessingState
|
||||
assert ProcessingState.IDLE.value == "idle"
|
||||
assert ProcessingState.PROCESSING.value == "processing"
|
||||
assert ProcessingState.WAITING_APPROVAL.value == "waiting_approval"
|
||||
|
||||
def test_state_manager_initial_state(self):
|
||||
"""Should start in IDLE state."""
|
||||
from praisonai.cli.features.message_queue import StateManager, ProcessingState
|
||||
manager = StateManager()
|
||||
assert manager.current_state == ProcessingState.IDLE
|
||||
|
||||
def test_state_transition(self):
|
||||
"""Should transition between states."""
|
||||
from praisonai.cli.features.message_queue import StateManager, ProcessingState
|
||||
manager = StateManager()
|
||||
manager.set_state(ProcessingState.PROCESSING)
|
||||
assert manager.current_state == ProcessingState.PROCESSING
|
||||
|
||||
def test_is_idle(self):
|
||||
"""Should correctly report idle state."""
|
||||
from praisonai.cli.features.message_queue import StateManager, ProcessingState
|
||||
manager = StateManager()
|
||||
assert manager.is_idle
|
||||
manager.set_state(ProcessingState.PROCESSING)
|
||||
assert not manager.is_idle
|
||||
|
||||
def test_is_processing(self):
|
||||
"""Should correctly report processing state."""
|
||||
from praisonai.cli.features.message_queue import StateManager, ProcessingState
|
||||
manager = StateManager()
|
||||
assert not manager.is_processing
|
||||
manager.set_state(ProcessingState.PROCESSING)
|
||||
assert manager.is_processing
|
||||
|
||||
def test_state_callback(self):
|
||||
"""Should call callback on state change."""
|
||||
from praisonai.cli.features.message_queue import StateManager, ProcessingState
|
||||
callback_called = []
|
||||
|
||||
def on_state_change(old_state, new_state):
|
||||
callback_called.append((old_state, new_state))
|
||||
|
||||
manager = StateManager(on_state_change=on_state_change)
|
||||
manager.set_state(ProcessingState.PROCESSING)
|
||||
|
||||
assert len(callback_called) == 1
|
||||
assert callback_called[0] == (ProcessingState.IDLE, ProcessingState.PROCESSING)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 3. QueuedMessage Tests
|
||||
# ============================================================================
|
||||
|
||||
class TestQueuedMessage:
|
||||
"""Test QueuedMessage dataclass."""
|
||||
|
||||
def test_create_queued_message(self):
|
||||
"""Should create message with content."""
|
||||
from praisonai.cli.features.message_queue import QueuedMessage
|
||||
msg = QueuedMessage(content="Hello")
|
||||
assert msg.content == "Hello"
|
||||
assert msg.timestamp is not None
|
||||
|
||||
def test_message_has_timestamp(self):
|
||||
"""Should have creation timestamp."""
|
||||
from praisonai.cli.features.message_queue import QueuedMessage
|
||||
before = time.time()
|
||||
msg = QueuedMessage(content="Hello")
|
||||
after = time.time()
|
||||
assert before <= msg.timestamp <= after
|
||||
|
||||
def test_message_priority(self):
|
||||
"""Should support priority levels."""
|
||||
from praisonai.cli.features.message_queue import QueuedMessage, MessagePriority
|
||||
msg = QueuedMessage(content="Hello", priority=MessagePriority.HIGH)
|
||||
assert msg.priority == MessagePriority.HIGH
|
||||
|
||||
def test_default_priority_normal(self):
|
||||
"""Should default to NORMAL priority."""
|
||||
from praisonai.cli.features.message_queue import QueuedMessage, MessagePriority
|
||||
msg = QueuedMessage(content="Hello")
|
||||
assert msg.priority == MessagePriority.NORMAL
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 4. MessageQueueHandler Tests
|
||||
# ============================================================================
|
||||
|
||||
class TestMessageQueueHandler:
|
||||
"""Test MessageQueueHandler main class."""
|
||||
|
||||
def test_handler_creation(self):
|
||||
"""Should create handler with default settings."""
|
||||
from praisonai.cli.features.message_queue import MessageQueueHandler
|
||||
handler = MessageQueueHandler()
|
||||
assert handler.queue.is_empty
|
||||
assert handler.state_manager.is_idle
|
||||
|
||||
def test_submit_when_idle(self):
|
||||
"""Should process immediately when idle."""
|
||||
from praisonai.cli.features.message_queue import MessageQueueHandler
|
||||
processed = []
|
||||
|
||||
def processor(msg):
|
||||
processed.append(msg)
|
||||
return f"Response to: {msg}"
|
||||
|
||||
handler = MessageQueueHandler(processor=processor)
|
||||
handler.submit("Hello")
|
||||
|
||||
# Should process immediately
|
||||
assert "Hello" in processed
|
||||
|
||||
def test_queue_when_processing(self):
|
||||
"""Should queue message when already processing."""
|
||||
from praisonai.cli.features.message_queue import MessageQueueHandler, ProcessingState
|
||||
|
||||
handler = MessageQueueHandler()
|
||||
handler.state_manager.set_state(ProcessingState.PROCESSING)
|
||||
handler.submit("Hello")
|
||||
|
||||
# Should be queued, not processed
|
||||
assert handler.queue.count == 1
|
||||
|
||||
def test_auto_process_queue_on_idle(self):
|
||||
"""Should auto-process queue when becoming idle."""
|
||||
from praisonai.cli.features.message_queue import MessageQueueHandler, ProcessingState
|
||||
processed = []
|
||||
|
||||
def processor(msg):
|
||||
processed.append(msg)
|
||||
return f"Response: {msg}"
|
||||
|
||||
handler = MessageQueueHandler(processor=processor)
|
||||
|
||||
# Simulate processing state
|
||||
handler.state_manager.set_state(ProcessingState.PROCESSING)
|
||||
handler.submit("Queued1")
|
||||
handler.submit("Queued2")
|
||||
|
||||
assert handler.queue.count == 2
|
||||
|
||||
# Simulate becoming idle - should trigger queue processing
|
||||
handler.on_processing_complete()
|
||||
|
||||
# Queue should be processed
|
||||
assert "Queued1" in processed or handler.queue.count < 2
|
||||
|
||||
def test_get_queue_status(self):
|
||||
"""Should return queue status."""
|
||||
from praisonai.cli.features.message_queue import MessageQueueHandler, ProcessingState
|
||||
handler = MessageQueueHandler()
|
||||
handler.state_manager.set_state(ProcessingState.PROCESSING)
|
||||
handler.submit("Hello")
|
||||
handler.submit("World")
|
||||
|
||||
status = handler.get_status()
|
||||
assert status['queue_count'] == 2
|
||||
assert status['state'] == 'processing'
|
||||
|
||||
def test_clear_queue(self):
|
||||
"""Should clear queue via handler."""
|
||||
from praisonai.cli.features.message_queue import MessageQueueHandler, ProcessingState
|
||||
handler = MessageQueueHandler()
|
||||
handler.state_manager.set_state(ProcessingState.PROCESSING)
|
||||
handler.submit("Hello")
|
||||
handler.clear_queue()
|
||||
assert handler.queue.is_empty
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 5. QueueDisplay Tests
|
||||
# ============================================================================
|
||||
|
||||
class TestQueueDisplay:
|
||||
"""Test QueueDisplay for visual indicators."""
|
||||
|
||||
def test_format_empty_queue(self):
|
||||
"""Should return empty string for empty queue."""
|
||||
from praisonai.cli.features.message_queue import QueueDisplay, MessageQueue
|
||||
queue = MessageQueue()
|
||||
display = QueueDisplay(queue)
|
||||
assert display.format_queue() == ""
|
||||
|
||||
def test_format_single_message(self):
|
||||
"""Should format single message."""
|
||||
from praisonai.cli.features.message_queue import QueueDisplay, MessageQueue
|
||||
queue = MessageQueue()
|
||||
queue.add("Hello world")
|
||||
display = QueueDisplay(queue)
|
||||
formatted = display.format_queue()
|
||||
assert "↳" in formatted
|
||||
assert "Hello world" in formatted
|
||||
|
||||
def test_format_multiple_messages(self):
|
||||
"""Should format multiple messages."""
|
||||
from praisonai.cli.features.message_queue import QueueDisplay, MessageQueue
|
||||
queue = MessageQueue()
|
||||
queue.add("First")
|
||||
queue.add("Second")
|
||||
display = QueueDisplay(queue)
|
||||
formatted = display.format_queue()
|
||||
assert "First" in formatted
|
||||
assert "Second" in formatted
|
||||
|
||||
def test_truncate_long_message(self):
|
||||
"""Should truncate long messages."""
|
||||
from praisonai.cli.features.message_queue import QueueDisplay, MessageQueue
|
||||
queue = MessageQueue()
|
||||
queue.add("A" * 100)
|
||||
display = QueueDisplay(queue, max_message_length=20)
|
||||
formatted = display.format_queue()
|
||||
assert "..." in formatted
|
||||
|
||||
def test_format_status_idle(self):
|
||||
"""Should show idle status."""
|
||||
from praisonai.cli.features.message_queue import QueueDisplay, MessageQueue, StateManager
|
||||
queue = MessageQueue()
|
||||
state = StateManager()
|
||||
display = QueueDisplay(queue, state_manager=state)
|
||||
status = display.format_status()
|
||||
assert "idle" in status.lower() or status == ""
|
||||
|
||||
def test_format_status_processing(self):
|
||||
"""Should show processing status."""
|
||||
from praisonai.cli.features.message_queue import (
|
||||
QueueDisplay, MessageQueue, StateManager, ProcessingState
|
||||
)
|
||||
queue = MessageQueue()
|
||||
state = StateManager()
|
||||
state.set_state(ProcessingState.PROCESSING)
|
||||
display = QueueDisplay(queue, state_manager=state)
|
||||
status = display.format_status()
|
||||
assert "processing" in status.lower() or "⏳" in status
|
||||
|
||||
def test_format_queue_count(self):
|
||||
"""Should show queue count."""
|
||||
from praisonai.cli.features.message_queue import QueueDisplay, MessageQueue, ProcessingState, StateManager
|
||||
queue = MessageQueue()
|
||||
queue.add("First")
|
||||
queue.add("Second")
|
||||
state = StateManager()
|
||||
state.set_state(ProcessingState.PROCESSING)
|
||||
display = QueueDisplay(queue, state_manager=state)
|
||||
count_str = display.format_queue_count()
|
||||
assert "2" in count_str
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 6. Integration Tests
|
||||
# ============================================================================
|
||||
|
||||
class TestMessageQueueIntegration:
|
||||
"""Integration tests for message queue system."""
|
||||
|
||||
def test_full_workflow(self):
|
||||
"""Test complete queue workflow."""
|
||||
from praisonai.cli.features.message_queue import (
|
||||
MessageQueueHandler, ProcessingState
|
||||
)
|
||||
|
||||
results = []
|
||||
|
||||
def processor(msg):
|
||||
results.append(msg)
|
||||
return f"Processed: {msg}"
|
||||
|
||||
handler = MessageQueueHandler(processor=processor)
|
||||
|
||||
# 1. Submit when idle - processes immediately
|
||||
handler.submit("First")
|
||||
assert "First" in results
|
||||
|
||||
# 2. Simulate processing
|
||||
handler.state_manager.set_state(ProcessingState.PROCESSING)
|
||||
|
||||
# 3. Queue messages while processing
|
||||
handler.submit("Second")
|
||||
handler.submit("Third")
|
||||
assert handler.queue.count == 2
|
||||
|
||||
# 4. Complete processing - should auto-process queue
|
||||
handler.on_processing_complete()
|
||||
|
||||
# Queue should be processed
|
||||
assert handler.queue.count < 2 or "Second" in results
|
||||
|
||||
def test_queue_with_display(self):
|
||||
"""Test queue with display formatting."""
|
||||
from praisonai.cli.features.message_queue import (
|
||||
MessageQueueHandler, QueueDisplay, ProcessingState
|
||||
)
|
||||
|
||||
handler = MessageQueueHandler()
|
||||
handler.state_manager.set_state(ProcessingState.PROCESSING)
|
||||
handler.submit("Task 1")
|
||||
handler.submit("Task 2")
|
||||
|
||||
display = QueueDisplay(handler.queue, state_manager=handler.state_manager)
|
||||
|
||||
# Should show processing status
|
||||
status = display.format_status()
|
||||
assert "processing" in status.lower() or "⏳" in status
|
||||
|
||||
# Should show queued messages
|
||||
queue_str = display.format_queue()
|
||||
assert "Task 1" in queue_str
|
||||
assert "Task 2" in queue_str
|
||||
Reference in new issue
Block a user