feat(c8): eliminate reverse wrapper imports in praisonai-code (225 → 0)

Repatriate wrapper-heavy CLI commands and features to praisonai, route via
_WRAPPER_RESIDENT_COMMANDS, and normalise remaining touchpoints through
_wrapper_bridge so the standalone hot path stays PyPI-independent.
This commit is contained in:
MervinPraison committed 2026-07-02 13:24:45 +01:00
1 parent 7546bde9ba
commit 66dcb788ae
86 files changed
+13665 -13861

No files matched your search

+2 -50
View File
@@ -1,53 +1,5 @@
# Files allowed to contain lazy ``from praisonai.*`` imports in praisonai-code.
# New direct imports require review; prefer praisonai_code._wrapper_bridge.
# C8 complete — reverse imports eliminated; bridge-only access remains.
# Regenerate counts: bash scripts/check_c7_imports.sh
#
# Format: path # reason (approx line count after C7.1)
cli/main.py # legacy argparse, auto, agents_generator (wrapper-enhanced)
cli/features/recipe.py # recipe runner (wrapper integrations)
cli/features/templates.py # template store in wrapper
cli/commands/serve.py # hybrid; gateway serve subpaths need wrapper
cli/commands/train.py # training uses wrapper capabilities
cli/commands/docs.py # docs server wrapper paths
cli/commands/replay.py # trace replay wrapper
cli/features/deploy.py # deployment integrations
cli/commands/standardise.py # migration tooling
cli/commands/schedule.py # scheduler wrapper
cli/commands/n8n.py # n8n integration
cli/commands/examples.py # example runners
cli/features/persistence.py # persistence backends
cli/commands/langfuse.py # observability
cli/commands/flow.py # Langflow
cli/features/recipe_optimizer.py # recipe tooling
cli/interactive/async_tui.py # interactive wrapper hooks
cli/commands/mcp.py # MCP server management
cli/commands/validate.py # validation against wrapper schemas
cli/commands/context.py # context features
cli/commands/batch.py # batch runner
cli/features/agent_scheduler.py # scheduler integration
cli/features/eval.py # eval harness
cli/features/tools.py # tool discovery
cli/features/sandbox_cli.py # sandbox wrapper
cli/features/registry.py # plugin registry wrapper
cli/features/ollama.py # ollama wrapper
cli/features/workflow.py # workflow wrapper
cli/features/tui/app.py # TUI wrapper hooks
cli/features/interactive_runtime.py # runtime bridge
cli/features/endpoints.py # API endpoints
cli/features/background.py # background jobs
cli/features/agent_tools.py # agent tools wrapper
cli/features/acp.py # ACP wrapper
cli/commands/rag.py # RAG server
cli/commands/realtime.py # realtime API
cli/commands/knowledge.py # knowledge CLI
cli/commands/langextract.py # langextract
cli/commands/profile.py # profiler
cli/commands/recipe.py # recipe command
cli/commands/audit.py # audit
cli/interactive/core.py # interactive core
cli/commands/managed.py # managed deployment (wrapper integrations)
cli/commands/app.py # legacy app launcher (wrapper-enhanced)
cli/features/doctor/checks/performance_checks.py # perf checks probe wrapper import cost
_version.py # optional wrapper version fallback
cli/langfuse_client.py # langfuse shim path
# If a new direct import is unavoidable, add path below after Architecture Review.
+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env python3
"""Convert direct ``from praisonai`` imports to bridge calls in a file."""
from __future__ import annotations
import re
import sys
from pathlib import Path
# Match: from praisonai.foo.bar import A, B
FROM_IMPORT = re.compile(
r"^(\s*)from praisonai(\.[\w.]+)? import (.+)$"
)
# Match: from praisonai import foo
FROM_ROOT = re.compile(
r"^(\s*)from praisonai import (.+)$"
)
def _convert_from_line(line: str) -> str | None:
m = FROM_ROOT.match(line)
if m:
indent, names = m.groups()
mod = "praisonai"
return (
f"{indent}from praisonai_code._wrapper_bridge import import_wrapper_module\n"
f"{indent}_mod = import_wrapper_module({mod!r})\n"
f"{indent}({names}) = tuple(getattr(_mod, n.strip()) for n in {names!r}.split(','))"
)
m = FROM_IMPORT.match(line)
if not m:
return None
indent, suffix, names = m.groups()
mod = f"praisonai{suffix or ''}"
# Handle ``import x as y``
parts = [p.strip() for p in names.split(",")]
assigns = []
for part in parts:
if " as " in part:
orig, alias = part.split(" as ", 1)
orig, alias = orig.strip(), alias.strip()
assigns.append(f"{alias} = getattr(_mod, {orig!r})")
else:
assigns.append(f"{part} = getattr(_mod, {part!r})")
body = "\n".join(f"{indent}{a}" for a in assigns)
return (
f"{indent}from praisonai_code._wrapper_bridge import import_wrapper_module\n"
f"{indent}_mod = import_wrapper_module({mod!r})\n"
f"{body}"
)
def convert_file(path: Path) -> int:
text = path.read_text()
lines = text.splitlines()
out: list[str] = []
changed = 0
for line in lines:
if line.strip().startswith("#") and "from praisonai" in line:
out.append(line)
continue
if "from praisonai" in line and "praisonai_code" not in line and "praisonaiagents" not in line:
new = _convert_from_line(line)
if new:
out.append(new)
changed += 1
continue
out.append(line)
if changed:
path.write_text("\n".join(out) + ("\n" if text.endswith("\n") else ""))
return changed
if __name__ == "__main__":
total = 0
for p in sys.argv[1:]:
n = convert_file(Path(p))
print(f"{p}: {n} lines converted")
total += n
print(f"total: {total}")
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env python3
"""C8 repatriation helper — move impl from praisonai-code to praisonai wrapper."""
from __future__ import annotations
import argparse
import shutil
import sys
from pathlib import Path
REPO = Path(__file__).resolve().parents[1]
CODE = REPO / "src/praisonai-code/praisonai_code"
WRAP = REPO / "src/praisonai/praisonai"
def repatriate_file(code_rel: str, wrap_rel: str | None = None) -> None:
"""Copy code file to wrapper and delete code copy."""
wrap_rel = wrap_rel or code_rel
src = CODE / code_rel
dst = WRAP / wrap_rel
if not src.exists():
print(f"SKIP missing: {src}", file=sys.stderr)
return
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, dst)
src.unlink()
print(f"repatriated {code_rel} -> {wrap_rel}")
def repatriate_command(name: str) -> None:
repatriate_file(f"cli/commands/{name}.py")
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("items", nargs="+", help="command names or code-relative paths")
parser.add_argument("--feature", action="store_true", help="path under cli/features/")
args = parser.parse_args()
for item in args.items:
if args.feature:
repatriate_file(f"cli/features/{item}")
elif "/" in item:
repatriate_file(item)
else:
repatriate_command(item)
if __name__ == "__main__":
main()
+3 -2
View File
@@ -8,7 +8,8 @@ cd "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# Module-level hot path: praisonai wrapper only (not praisonaiagents / praisonai_code).
HOT_PATH_RE='^from praisonai([[:space:]]|\.|$)|^import praisonai([[:space:]]|\.|$)'
# Line-level wrapper imports (exclude praisonaiagents, praisonai_code, praisonai-tools).
ANY_WRAPPER_RE='(^[[:space:]]*from praisonai([[:space:]]|\.)|^import praisonai($|\.))'
# Count indented bare ``import praisonai`` as well (C8.1 regex fix).
ANY_WRAPPER_RE='(^[[:space:]]*from praisonai([[:space:]]|\.)|^[[:space:]]*import praisonai($|\.))'
ALLOWLIST="scripts/c7_wrapper_import_allowlist.txt"
echo "== C7 hot-path module-level gate =="
@@ -41,7 +42,7 @@ done
echo "strict hot-path gate ok"
echo "== C7 regression baseline (total wrapper import lines) =="
BASELINE="${C7_WRAPPER_IMPORT_BASELINE:-225}"
BASELINE="${C8_WRAPPER_IMPORT_BASELINE:-${C7_WRAPPER_IMPORT_BASELINE:-50}}"
# Count wrapper import lines with a portable tool. Prefer ripgrep when present
# (fast, respects the same regex) and fall back to POSIX grep -r otherwise, so
# the gate does not exit 127 on runners without rg. The `|| true` keeps a
@@ -0,0 +1,35 @@
"""CLI extension protocols for wrapper ↔ code boundaries (C8.5)."""
from __future__ import annotations
from typing import Any, Optional, Protocol, runtime_checkable
@runtime_checkable
class TemplateStoreProtocol(Protocol):
"""Template persistence accessed from repatriated CLI features."""
def list_templates(self, *, category: Optional[str] = None) -> list[Any]:
...
def get_template(self, name: str) -> Any:
...
@runtime_checkable
class SessionStoreProtocol(Protocol):
"""Optional session backend for interactive TUI paths."""
def load(self, session_id: str) -> Optional[Any]:
...
def save(self, session_id: str, data: Any) -> None:
...
@runtime_checkable
class ServeHandlerProtocol(Protocol):
"""Dispatch serve subcommands without direct wrapper imports in code."""
def handle(self, args: list[str]) -> int:
...
@@ -25,7 +25,9 @@ def get_wrapper_version() -> Optional[str]:
return str(version("praisonai"))
except Exception:
try:
from praisonai.version import __version__ as wrapper_version
from praisonai_code._wrapper_bridge import import_wrapper_module
_mod = import_wrapper_module('praisonai.version')
wrapper_version = getattr(_mod, '__version__')
return str(wrapper_version)
except Exception:
+66 -18
View File
@@ -190,10 +190,11 @@ _LAZY_COMMANDS: Dict[str, Tuple[str, str, str]] = {
"loop": (".commands.loop", "app", "Autonomous agent execution loops"),
"tracker": (".commands.tracker", "app", "Autonomous agent tracking with step-by-step analysis"),
"github": (".commands.github", "app", "GitHub native context tracking and Issue triage"),
"audit": (".commands.audit", "audit", "Compliance auditing"),
"managed": (".commands.managed", "app", "Managed Agents (Anthropic cloud-hosted backend)"),
"models": (".commands.models", "app", "List and describe available models"),
# Bot/channel commands — see ``_WRAPPER_COMMANDS`` below. These entries keep
# Wrapper-resident commands — see ``_WRAPPER_RESIDENT_COMMANDS`` below. These entries keep
# relative ``.commands.*`` paths so they are advertised in ``--help``, but
# ``get_command()`` re-routes them to the absolute ``praisonai.cli.commands.*``
# path at invocation time (the modules live in the main wrapper).
@@ -223,10 +224,11 @@ _LAZY_COMMANDS: Dict[str, Tuple[str, str, str]] = {
# relative ``.commands.*`` path would resolve to ``praisonai_code`` where the
# modules do not live. The import happens lazily at invocation time only, so
# ``praisonai_code`` keeps no static dependency on ``praisonai``.
# C7.1 audit: wrapper-resident Typer modules only (no praisonai_code.cli.commands.*
# copy). Do NOT add train/serve here — their impl lives in praisonai_code; bridge
# internal wrapper imports instead. mint_link is wrapper-only but not in Typer registry.
_WRAPPER_COMMANDS = frozenset({
# C8: Typer modules whose implementation lives in ``praisonai.cli.commands.*`` only
# (no ``praisonai_code.cli.commands.*`` copy). ``get_command()`` loads these via
# absolute import when the wrapper is installed; standalone installs hide them from
# ``--help``. Add names here as commands are repatriated from praisonai-code.
_WRAPPER_RESIDENT_COMMANDS = frozenset({
"bot",
"gateway",
"pairing",
@@ -235,8 +237,38 @@ _WRAPPER_COMMANDS = frozenset({
"kanban",
"dashboard",
"claw",
# C8.2 Batch A — observability / integrations
"langfuse",
"langextract",
"flow",
"n8n",
"replay",
# C8.2 Batch B — training / managed / examples
"train",
"managed",
"examples",
"standardise",
# C8.2 Batch C — docs / schedule / batch
"docs",
"schedule",
"batch",
# C8.2 Batch D — legacy / RAG / profile
"rag",
"knowledge",
"realtime",
"profile",
"audit",
"app",
# C8.3 — feature-heavy commands
"context",
"recipe",
"mcp",
"validate",
})
# Backward-compatible alias (C7.1 name).
_WRAPPER_COMMANDS = _WRAPPER_RESIDENT_COMMANDS
# Special commands that need custom handling
_SPECIAL_COMMANDS = {
"tui": (".features.tui.debug", "create_debug_app", "Interactive TUI and simulation"),
@@ -261,7 +293,7 @@ class LazyCommandGroup(TyperGroup):
wrapper_ok = wrapper_available()
commands.update(
name for name in _LAZY_COMMANDS
if wrapper_ok or name not in _WRAPPER_COMMANDS
if wrapper_ok or name not in _WRAPPER_RESIDENT_COMMANDS
)
commands.update(_SPECIAL_COMMANDS.keys())
@@ -287,7 +319,7 @@ class LazyCommandGroup(TyperGroup):
if name in _LAZY_COMMANDS:
module_path, attr_name, _ = _LAZY_COMMANDS[name]
try:
if name in _WRAPPER_COMMANDS:
if name in _WRAPPER_RESIDENT_COMMANDS:
if not wrapper_available():
return None
module = importlib.import_module(f"praisonai.cli.commands.{name}")
@@ -316,16 +348,32 @@ class LazyCommandGroup(TyperGroup):
typer.echo(f"Error loading command '{name}': {e}", err=True)
return None
# Handle standardise/standardize
# Handle standardise/standardize — impl in wrapper (C8.2)
if name in ["standardise", "standardize"]:
if not wrapper_available():
return None
return self._get_standardise_command()
# Handle app command
# Handle app command — impl in wrapper when installed (C8.2)
if name == "app":
if wrapper_available():
try:
module = importlib.import_module("praisonai.cli.commands.app")
sub_app = getattr(module, "app")
if isinstance(sub_app, click.Command):
return sub_app
return typer_get_command(sub_app)
except (ImportError, AttributeError):
pass
return self._get_app_command()
return None
def _standardise_impl(self):
"""Load standardise helpers from the wrapper package."""
from praisonai_code._wrapper_bridge import import_wrapper_module
return import_wrapper_module("praisonai.cli.commands.standardise")
def _get_standardise_command(self) -> Optional[click.Command]:
"""Get the standardise command group."""
try:
@@ -339,7 +387,7 @@ class LazyCommandGroup(TyperGroup):
ci: bool = typer.Option(False, "--ci", help="CI mode"),
):
"""Check for standardisation issues."""
from .commands.standardise import _run_check
_run_check = self._standardise_impl()._run_check
import argparse
args = argparse.Namespace(path=path, feature=feature, scope=scope, ci=ci, dry_run=True)
_run_check(args)
@@ -352,7 +400,7 @@ class LazyCommandGroup(TyperGroup):
ci: bool = typer.Option(False, "--ci", help="CI mode"),
):
"""Generate detailed report."""
from .commands.standardise import _run_report
_run_report = self._standardise_impl()._run_report
import argparse
args = argparse.Namespace(path=path, format=format, output=output, ci=ci, feature=None, scope="all", dry_run=True)
_run_report(args)
@@ -365,7 +413,7 @@ class LazyCommandGroup(TyperGroup):
no_backup: bool = typer.Option(False, "--no-backup", help="Don't create backups"),
):
"""Fix standardisation issues."""
from .commands.standardise import _run_fix
_run_fix = self._standardise_impl()._run_fix
import argparse
args = argparse.Namespace(path=path, feature=feature, apply=apply, no_backup=no_backup, scope="all", ci=False, dry_run=not apply)
_run_fix(args)
@@ -377,7 +425,7 @@ class LazyCommandGroup(TyperGroup):
apply: bool = typer.Option(False, "--apply", help="Actually create files"),
):
"""Initialise a new feature with all required artifacts."""
from .commands.standardise import _run_init
_run_init = self._standardise_impl()._run_init
import argparse
args = argparse.Namespace(feature=feature, path=path, apply=apply, scope="all", ci=False, dry_run=not apply)
_run_init(args)
@@ -392,7 +440,7 @@ class LazyCommandGroup(TyperGroup):
path: str = typer.Option(".", "--path", "-p", help="Project root path"),
):
"""AI-powered generation of docs/examples."""
from .commands.standardise import _run_ai
_run_ai = self._standardise_impl()._run_ai
import argparse
args = argparse.Namespace(feature=feature, type=gen_type, apply=apply, verify=verify, model=model, path=path, scope="all", ci=False, dry_run=not apply)
_run_ai(args)
@@ -403,7 +451,7 @@ class LazyCommandGroup(TyperGroup):
path: str = typer.Option(".", "--path", "-p", help="Repository path"),
):
"""Create an undo checkpoint."""
from .commands.standardise import _run_checkpoint
_run_checkpoint = self._standardise_impl()._run_checkpoint
import argparse
args = argparse.Namespace(message=message, path=path)
_run_checkpoint(args)
@@ -415,7 +463,7 @@ class LazyCommandGroup(TyperGroup):
path: str = typer.Option(".", "--path", "-p", help="Repository path"),
):
"""Undo to a previous checkpoint."""
from .commands.standardise import _run_undo
_run_undo = self._standardise_impl()._run_undo
import argparse
args = argparse.Namespace(checkpoint=checkpoint, list=list_checkpoints, path=path)
_run_undo(args)
@@ -425,7 +473,7 @@ class LazyCommandGroup(TyperGroup):
path: str = typer.Option(".", "--path", "-p", help="Repository path"),
):
"""Redo after an undo."""
from .commands.standardise import _run_redo
_run_redo = self._standardise_impl()._run_redo
import argparse
args = argparse.Namespace(path=path)
_run_redo(args)
@@ -779,7 +827,7 @@ def get_command_names():
"""
names = set(_LAZY_COMMANDS.keys())
# Bot/channel commands are already part of ``_LAZY_COMMANDS`` (re-routed to
# the main wrapper at invocation time via ``_WRAPPER_COMMANDS``).
# the main wrapper at invocation time via ``_WRAPPER_RESIDENT_COMMANDS``).
# Special commands with custom handling (tui, queue)
names.update(_SPECIAL_COMMANDS.keys())
# Inline special commands handled outside the registries
@@ -1,139 +0,0 @@
"""
CLI command for AgentOS - production deployment of AI agents.
Usage:
praisonai app # Start server with default config
praisonai app --port 9000 # Start on custom port
praisonai app --config app.yaml # Load from config file
praisonai app --reload # Enable auto-reload for development
"""
import click
from typing import Optional
@click.command("app")
@click.option("--port", "-p", type=int, default=8000, help="Port to listen on")
@click.option("--host", "-h", type=str, default="0.0.0.0", help="Host to bind to")
@click.option("--config", "-c", type=click.Path(exists=True), help="Path to config file (YAML)")
@click.option("--reload", "-r", is_flag=True, help="Enable auto-reload for development")
@click.option("--debug", "-d", is_flag=True, help="Enable debug mode")
@click.option("--name", "-n", type=str, default="PraisonAI App", help="Application name")
def app_command(
port: int,
host: str,
config: Optional[str],
reload: bool,
debug: bool,
name: str,
):
"""
Start an AgentOS server for production deployment.
AgentOS provides a FastAPI-based web service for deploying AI agents
with REST and WebSocket endpoints.
Examples:
# Start with defaults (port 8000)
praisonai app
# Start on custom port
praisonai app --port 9000
# Enable auto-reload for development
praisonai app --reload
# Load agents from config file
praisonai app --config agents.yaml
"""
from rich.console import Console
console = Console()
try:
from praisonai import AgentOS
from praisonaiagents import AgentOSConfig
except ImportError as e:
console.print(f"[red]Error importing AgentOS: {e}[/red]")
console.print("[yellow]Install with: pip install praisonai[api][/yellow]")
raise click.Abort()
# Load agents from config file if provided
agents = []
if config:
agents = _load_agents_from_config(config, console)
# Create config
app_config = AgentOSConfig(
name=name,
host=host,
port=port,
reload=reload,
debug=debug,
)
# Create and start app
console.print(f"\n[bold green]🚀 Starting {name}[/bold green]")
console.print(f"[dim]Host: {host}:{port}[/dim]")
if agents:
console.print(f"[dim]Agents: {len(agents)}[/dim]")
if reload:
console.print("[yellow]Auto-reload enabled (development mode)[/yellow]")
console.print()
try:
agent_app = AgentOS(
name=name,
agents=agents,
config=app_config,
)
agent_app.serve()
except ImportError as e:
console.print(f"[red]Missing dependency: {e}[/red]")
console.print("[yellow]Install with: pip install praisonai[api][/yellow]")
raise click.Abort()
except Exception as e:
console.print(f"[red]Error starting server: {e}[/red]")
raise click.Abort()
def _load_agents_from_config(config_path: str, console) -> list:
"""Load agents from a YAML config file."""
import yaml
from pathlib import Path
try:
with open(config_path, 'r') as f:
config_data = yaml.safe_load(f)
except Exception as e:
console.print(f"[red]Error loading config: {e}[/red]")
return []
if not config_data:
return []
agents = []
# Try to load agents from config
agents_config = config_data.get('agents', [])
if not agents_config and 'agent' in config_data:
agents_config = [config_data['agent']]
if agents_config:
try:
from praisonaiagents import Agent
for agent_data in agents_config:
if isinstance(agent_data, dict):
agent = Agent(
name=agent_data.get('name', 'Agent'),
role=agent_data.get('role'),
instructions=agent_data.get('instructions', agent_data.get('goal', '')),
llm=agent_data.get('llm'),
)
agents.append(agent)
console.print(f"[green]✓ Loaded agent: {agent.name}[/green]")
except Exception as e:
console.print(f"[yellow]Warning: Could not load agents from config: {e}[/yellow]")
return agents
@@ -1,102 +0,0 @@
"""
CLI command for agent-centric compliance auditing.
Usage:
praisonai audit agent-centric --scan /path/to/examples
praisonai audit agent-centric --fix /path/to/examples
praisonai audit agent-centric --check /path/to/examples
"""
import click
import json
import sys
from pathlib import Path
@click.group()
def audit():
"""Audit commands for compliance checking."""
pass
@audit.command("agent-centric")
@click.option("--scan", "scan_path", type=click.Path(exists=True), help="Scan path for compliance")
@click.option("--fix", "fix_path", type=click.Path(exists=True), help="Fix non-compliant files")
@click.option("--check", "check_path", type=click.Path(exists=True), help="Check and fail if non-compliant")
@click.option("--json", "output_json", is_flag=True, help="Output as JSON")
@click.option("--line-limit", default=40, help="Line limit for header scan (default: 40)")
@click.option("--only-examples", is_flag=True, help="Only scan Python examples")
@click.option("--only-docs", is_flag=True, help="Only scan documentation")
@click.option("--verbose", "-v", is_flag=True, help="Verbose output")
def agent_centric(scan_path, fix_path, check_path, output_json, line_limit, only_examples, only_docs, verbose):
"""
Audit agent-centric compliance for examples and docs.
Examples:
praisonai audit agent-centric --scan ./examples
praisonai audit agent-centric --fix ./examples --verbose
praisonai audit agent-centric --check ./examples # Fails if non-compliant
"""
from praisonai.audit.agent_centric import AgentCentricAuditor, ComplianceStatus
path = scan_path or fix_path or check_path
if not path:
click.echo("Error: Must specify --scan, --fix, or --check with a path", err=True)
sys.exit(1)
auditor = AgentCentricAuditor(line_limit=line_limit, verbose=verbose)
if fix_path:
# Fix mode
click.echo(f"Fixing non-compliant files in: {fix_path}")
report = auditor.scan(fix_path)
fixed_count = 0
for result in report.results:
if result.status == ComplianceStatus.NON_COMPLIANT:
success, reason = auditor.fix_file(result.file_path)
if success:
fixed_count += 1
if verbose:
click.echo(f" Fixed: {result.file_path} - {reason}")
click.echo(f"\nFixed {fixed_count} files")
# Re-scan to verify
report = auditor.scan(fix_path)
click.echo(f"Compliance rate: {report.compliance_rate():.1f}%")
else:
# Scan or check mode
report = auditor.scan(path)
if output_json:
click.echo(auditor.to_json(report))
else:
click.echo(f"\n{'='*60}")
click.echo("AGENT-CENTRIC COMPLIANCE REPORT")
click.echo(f"{'='*60}")
click.echo(f"Total files: {report.total_files}")
click.echo(f"Compliant: {report.compliant}")
click.echo(f"Non-compliant: {report.non_compliant}")
click.echo(f"Skipped: {report.skipped}")
click.echo(f"Compliance rate: {report.compliance_rate():.1f}%")
if report.by_reason:
click.echo(f"\nBy Reason:")
for reason, count in sorted(report.by_reason.items()):
click.echo(f" {reason}: {count}")
if verbose and report.non_compliant > 0:
click.echo(f"\nNon-compliant files:")
for r in report.results:
if r.status == ComplianceStatus.NON_COMPLIANT:
click.echo(f" {r.file_path}")
click.echo(f" Reason: {r.reason.value if r.reason else 'unknown'}")
# Check mode - fail if non-compliant
if check_path and report.non_compliant > 0:
click.echo(f"\n❌ FAILED: {report.non_compliant} non-compliant files found", err=True)
sys.exit(1)
elif check_path:
click.echo(f"\n✓ PASSED: All files are agent-centric compliant")
@@ -1,848 +0,0 @@
"""
PraisonAI Batch CLI Command.
Run all Python files with PraisonAI imports in the current directory.
Designed for quick debugging - running multiple local scripts at once.
Usage:
praisonai batch # Run all PraisonAI scripts in current dir
praisonai batch --sub # Include subdirectories
praisonai batch --sub --depth 2 # Limit recursion depth
praisonai batch --parallel # Run in parallel with async reporting
praisonai batch list # List discovered files
praisonai batch stats # Show statistics
praisonai batch report # View latest report
"""
from datetime import datetime
from pathlib import Path
from typing import Optional
import typer
app = typer.Typer(
help="Run all PraisonAI scripts in current folder for quick debugging",
no_args_is_help=False,
)
def _get_default_report_dir() -> Path:
"""Get default report directory with timestamp."""
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
return Path.home() / "Downloads" / "reports" / "batch" / timestamp
def _get_latest_report_dir() -> Optional[Path]:
"""Get the latest report directory."""
base_dir = Path.home() / "Downloads" / "reports" / "batch"
if not base_dir.exists():
return None
# Find most recent timestamp directory
dirs = [d for d in base_dir.iterdir() if d.is_dir()]
if not dirs:
return None
return max(dirs, key=lambda d: d.name)
@app.callback(invoke_without_command=True)
def batch_run(
ctx: typer.Context,
path: Optional[Path] = typer.Option(
None,
"--path", "-p",
help="Path to search (default: current directory)",
),
recursive: bool = typer.Option(
False,
"--sub", "--recursive", "-r",
help="Include subdirectories",
),
depth: Optional[int] = typer.Option(
None,
"--depth", "-d",
help="Maximum recursion depth (only with --sub)",
),
include_tests: bool = typer.Option(
False,
"--include-tests",
help="Include test files (test_*.py, *_test.py)",
),
timeout: int = typer.Option(
60,
"--timeout", "-t",
help="Per-script timeout in seconds",
),
max_items: Optional[int] = typer.Option(
None,
"--max-items",
help="Maximum scripts to run",
),
fail_fast: bool = typer.Option(
False,
"--fail-fast", "-x",
help="Stop on first failure",
),
parallel: bool = typer.Option(
True,
"--parallel/--sequential",
help="Run in parallel (default: parallel)",
),
max_workers: int = typer.Option(
4,
"--workers", "-w",
help="Max parallel workers (only with --parallel)",
),
no_stream: bool = typer.Option(
False,
"--no-stream",
help="Don't stream output to terminal",
),
report_dir: Optional[Path] = typer.Option(
None,
"--report-dir",
help="Directory for reports (default: ~/Downloads/reports/batch/<timestamp>)",
),
no_report: bool = typer.Option(
False,
"--no-report",
help="Skip report generation",
),
quiet: bool = typer.Option(
False,
"--quiet", "-q",
help="Minimal output",
),
server: bool = typer.Option(
False,
"--server",
help="Run only server scripts (uvicorn, Flask, streamlit, etc.) with 10s timeout",
),
filter_type: Optional[str] = typer.Option(
None,
"--filter", "-f",
help="Filter by type: 'agent', 'agents', or 'workflow'",
),
ci: bool = typer.Option(
False,
"--ci",
help="CI-friendly output (no colors, proper exit codes)",
),
):
"""
Run all PraisonAI scripts in the current folder.
Quick debugging tool for running multiple Python files that use
praisonaiagents or praisonai. Unlike 'praisonai run' (which runs
agent configs) or 'praisonai examples' (which runs repo examples),
this command finds and executes local scripts for rapid testing.
Only files containing 'from praisonaiagents' or 'from praisonai'
are executed. Test files (test_*.py) are excluded by default.
Examples:
praisonai batch # Run all in current dir
praisonai batch --sub # Include subdirectories
praisonai batch --sub --depth 2 # Limit depth
praisonai batch --parallel # Run in parallel
praisonai batch --timeout 120 # Custom timeout
"""
# If a subcommand was invoked, skip the default run
if ctx.invoked_subcommand is not None:
return
# Lazy import to avoid loading at CLI startup
from praisonai.suite_runner import BatchSource, SuiteExecutor, RunResult
# Resolve paths
search_path = Path(path).resolve() if path else Path.cwd().resolve()
output_dir = report_dir or _get_default_report_dir()
if not search_path.exists():
if ci:
print(f"ERROR: Path not found: {search_path}")
else:
typer.echo(f"❌ Path not found: {search_path}")
raise typer.Exit(2)
# Validate filter_type
if filter_type and filter_type not in ("agent", "agents", "workflow"):
if ci:
print(f"ERROR: Invalid filter type: {filter_type}. Use 'agent', 'agents', or 'workflow'")
else:
typer.echo(f"❌ Invalid filter type: {filter_type}. Use 'agent', 'agents', or 'workflow'")
raise typer.Exit(2)
# Server mode uses 10s timeout by default
effective_timeout = 10 if server else timeout
# Create source
source = BatchSource(
root=search_path,
recursive=recursive,
depth=depth,
exclude_tests=not include_tests,
exclude_servers=not server, # Exclude servers by default, include when --server
server_only=server, # Only servers when --server flag
filter_type=filter_type,
)
# Discover items
items = source.discover()
if not items:
if ci:
print(f"INFO: No PraisonAI scripts found in {search_path}")
else:
typer.echo(f"ℹ️ No PraisonAI scripts found in {search_path}")
if not recursive:
typer.echo(" Tip: Use --sub to include subdirectories")
raise typer.Exit(0)
# Parallel execution
if parallel:
_run_parallel(
items=items,
source=source,
search_path=search_path,
output_dir=output_dir,
timeout=effective_timeout,
max_workers=max_workers,
quiet=quiet,
ci=ci,
)
return
# Sequential execution (default)
executor = SuiteExecutor(
suite="batch",
source_path=search_path,
timeout=effective_timeout,
fail_fast=fail_fast,
stream_output=not no_stream,
max_items=max_items,
report_dir=output_dir if not no_report else None,
generate_json=not no_report,
generate_md=not no_report,
generate_csv=not no_report,
pythonpath_additions=source.get_pythonpath(),
)
# Status icons (CI mode uses text)
if ci:
icons = {
"passed": "PASS",
"failed": "FAIL",
"skipped": "SKIP",
"timeout": "TIMEOUT",
"not_run": "NOT_RUN",
"xfail": "XFAIL",
}
else:
icons = {
"passed": "✅",
"failed": "❌",
"skipped": "⏭️",
"timeout": "⏱️",
"not_run": "📝",
"xfail": "⚠️",
}
def on_item_start(item, idx: int, total: int):
if not quiet:
if ci:
print(f"[{idx}/{total}] Running: {item.display_name}")
else:
typer.echo(f"\n[{idx}/{total}] Running: {item.display_name}")
def on_item_end(result: RunResult, idx: int, total: int):
icon = icons.get(result.status, "?")
duration = f"{result.duration_seconds:.2f}s" if result.duration_seconds else ""
if quiet:
if ci:
print(f"{icon} {result.display_name}")
else:
typer.echo(f"{icon} {result.display_name}")
else:
msg = f" {icon} {result.status.upper()}"
if duration:
msg += f" ({duration})"
if result.skip_reason:
msg += f" - {result.skip_reason}"
if result.error_message and not no_stream:
msg += f"\n Error: {result.error_message[:100]}"
if ci:
print(msg)
else:
typer.echo(msg)
def on_output(line: str, stream: str):
if not quiet and not no_stream:
prefix = " | " if ci else " │ "
if stream != "stdout":
prefix += "[err] "
if ci:
print(f"{prefix}{line.rstrip()}")
else:
typer.echo(f"{prefix}{line.rstrip()}")
# Print header
if not quiet:
if ci:
print("=" * 60)
print("PraisonAI Batch Runner")
print("=" * 60)
print(f"Path: {search_path}")
print(f"Recursive: {recursive}")
if depth:
print(f"Depth: {depth}")
print(f"Timeout: {effective_timeout}s")
print(f"Scripts: {len(items)}")
if server:
print("Mode: Server scripts only")
if filter_type:
print(f"Filter: {filter_type}")
if not no_report:
print(f"Reports: {output_dir}")
else:
typer.echo("=" * 60)
typer.echo("PraisonAI Batch Runner")
typer.echo("=" * 60)
typer.echo(f"Path: {search_path}")
typer.echo(f"Recursive: {recursive}")
if depth:
typer.echo(f"Depth: {depth}")
typer.echo(f"Timeout: {effective_timeout}s")
typer.echo(f"Scripts: {len(items)}")
if server:
typer.echo("Mode: Server scripts only")
if filter_type:
typer.echo(f"Filter: {filter_type}")
if not no_report:
typer.echo(f"Reports: {output_dir}")
# Run
report = executor.run(
items=items,
on_item_start=on_item_start,
on_item_end=on_item_end,
on_output=on_output if not no_stream else None,
)
# Print summary
totals = report.totals
total_count = sum(totals.values())
if not quiet:
if ci:
print("")
print("=" * 60)
print("SUMMARY")
print("=" * 60)
print(f" PASSED: {totals['passed']}")
print(f" FAILED: {totals['failed']}")
print(f" SKIPPED: {totals['skipped']}")
print(f" TIMEOUT: {totals['timeout']}")
print(" -----------------")
print(f" TOTAL: {total_count}")
print("=" * 60)
if not no_report and output_dir.exists():
print(f"Reports: {output_dir}")
else:
typer.echo("\n" + "=" * 60)
typer.echo("SUMMARY")
typer.echo("=" * 60)
typer.echo(f" ✅ Passed: {totals['passed']}")
typer.echo(f" ❌ Failed: {totals['failed']}")
typer.echo(f" ⏭️ Skipped: {totals['skipped']}")
typer.echo(f" ⏱️ Timeout: {totals['timeout']}")
typer.echo(" ─────────────────")
typer.echo(f" Total: {total_count}")
typer.echo("=" * 60)
if not no_report and output_dir.exists():
typer.echo(f"\n📁 Reports saved to: {output_dir}")
# Exit code
if totals['failed'] > 0 or totals['timeout'] > 0:
raise typer.Exit(1)
raise typer.Exit(0)
def _run_parallel(
items,
source,
search_path: Path,
output_dir: Path,
timeout: int,
max_workers: int,
quiet: bool,
ci: bool = False,
):
"""Run items in parallel with async reporting."""
import subprocess
import sys
from concurrent.futures import ThreadPoolExecutor, as_completed
from praisonai.suite_runner import RunResult, RunReport, SuiteReporter
from datetime import timezone
if ci:
print("=" * 60)
print("PraisonAI Batch Runner (Parallel Mode)")
print("=" * 60)
print(f"Path: {search_path}")
print(f"Workers: {max_workers}")
print(f"Timeout: {timeout}s")
print(f"Scripts: {len(items)}")
print(f"Reports: {output_dir}")
print("")
print("Running... Progress:")
print("-" * 60)
else:
typer.echo("=" * 60)
typer.echo("PraisonAI Batch Runner (Parallel Mode)")
typer.echo("=" * 60)
typer.echo(f"Path: {search_path}")
typer.echo(f"Workers: {max_workers}")
typer.echo(f"Timeout: {timeout}s")
typer.echo(f"Scripts: {len(items)}")
typer.echo(f"Reports: {output_dir}")
typer.echo("")
typer.echo("Running in background... Progress:")
typer.echo("-" * 60)
results = []
completed = 0
total = len(items)
# Build environment
import os
env = os.environ.copy()
pythonpath = source.get_pythonpath()
if pythonpath:
existing = env.get('PYTHONPATH', '')
env['PYTHONPATH'] = os.pathsep.join(pythonpath + ([existing] if existing else []))
def run_item(item):
"""Run a single item and return result."""
nonlocal completed
if not item.runnable:
completed += 1
return RunResult(
item_id=item.item_id,
suite="batch",
group=item.group,
source_path=item.source_path,
status="skipped" if item.skip else "not_run",
skip_reason=item.skip_reason,
code_hash=item.code_hash,
)
start_time = datetime.now(timezone.utc)
try:
result = subprocess.run(
[sys.executable, '-u', str(item.script_path)],
capture_output=True,
text=True,
timeout=timeout,
env=env,
cwd=item.script_path.parent,
)
end_time = datetime.now(timezone.utc)
duration = (end_time - start_time).total_seconds()
status = "passed" if result.returncode == 0 else "failed"
if item.xfail and result.returncode != 0:
status = "xfail"
error_message = None
if result.returncode != 0 and result.stderr:
lines = result.stderr.strip().split('\n')
error_message = lines[-1][:200] if lines else None
completed += 1
# Progress indicator
if ci:
icon = "PASS" if status == "passed" else "FAIL" if status == "failed" else "XFAIL"
else:
icon = "✅" if status == "passed" else "❌" if status == "failed" else "⚠️"
if not quiet:
if ci:
print(f" [{completed}/{total}] {icon} {item.display_name} ({duration:.2f}s)")
else:
typer.echo(f" [{completed}/{total}] {icon} {item.display_name} ({duration:.2f}s)")
return RunResult(
item_id=item.item_id,
suite="batch",
group=item.group,
source_path=item.source_path,
status=status,
exit_code=result.returncode,
duration_seconds=duration,
start_time=start_time.isoformat(),
end_time=end_time.isoformat(),
error_message=error_message,
stdout=result.stdout,
stderr=result.stderr,
code_hash=item.code_hash,
)
except subprocess.TimeoutExpired:
completed += 1
end_time = datetime.now(timezone.utc)
duration = (end_time - start_time).total_seconds()
if not quiet:
if ci:
print(f" [{completed}/{total}] TIMEOUT {item.display_name} (timeout)")
else:
typer.echo(f" [{completed}/{total}] ⏱️ {item.display_name} (timeout)")
return RunResult(
item_id=item.item_id,
suite="batch",
group=item.group,
source_path=item.source_path,
status="timeout",
duration_seconds=duration,
error_message=f"Exceeded {timeout}s timeout",
code_hash=item.code_hash,
)
except Exception as e:
completed += 1
if not quiet:
if ci:
print(f" [{completed}/{total}] FAIL {item.display_name} (error: {e})")
else:
typer.echo(f" [{completed}/{total}] ❌ {item.display_name} (error: {e})")
return RunResult(
item_id=item.item_id,
suite="batch",
group=item.group,
source_path=item.source_path,
status="failed",
error_message=str(e),
code_hash=item.code_hash,
)
# Run in parallel
with ThreadPoolExecutor(max_workers=max_workers) as executor:
futures = {executor.submit(run_item, item): item for item in items}
for future in as_completed(futures):
result = future.result()
results.append(result)
# Create report
report = RunReport(
results=results,
suite="batch",
source_path=search_path,
groups_run=[],
)
# Generate reports
output_dir.mkdir(parents=True, exist_ok=True)
reporter = SuiteReporter(output_dir)
reporter.save_logs(results)
reporter.generate_json(report)
reporter.generate_markdown(report)
reporter.generate_csv(report)
# Print summary
totals = report.totals
total_count = sum(totals.values())
if ci:
print("-" * 60)
print("")
print("SUMMARY")
print("=" * 60)
print(f" PASSED: {totals['passed']}")
print(f" FAILED: {totals['failed']}")
print(f" SKIPPED: {totals['skipped']}")
print(f" TIMEOUT: {totals['timeout']}")
print(" -----------------")
print(f" TOTAL: {total_count}")
print("=" * 60)
print(f"Reports: {output_dir}")
else:
typer.echo("-" * 60)
typer.echo("\nSUMMARY")
typer.echo("=" * 60)
typer.echo(f" ✅ Passed: {totals['passed']}")
typer.echo(f" ❌ Failed: {totals['failed']}")
typer.echo(f" ⏭️ Skipped: {totals['skipped']}")
typer.echo(f" ⏱️ Timeout: {totals['timeout']}")
typer.echo(" ─────────────────")
typer.echo(f" Total: {total_count}")
typer.echo("=" * 60)
typer.echo(f"\n📁 Reports saved to: {output_dir}")
# Exit code
if totals['failed'] > 0 or totals['timeout'] > 0:
raise typer.Exit(1)
raise typer.Exit(0)
@app.command("list")
def batch_list(
path: Optional[Path] = typer.Option(
None,
"--path", "-p",
help="Path to search",
),
recursive: bool = typer.Option(
False,
"--sub", "--recursive", "-r",
help="Include subdirectories",
),
depth: Optional[int] = typer.Option(
None,
"--depth", "-d",
help="Maximum recursion depth",
),
include_tests: bool = typer.Option(
False,
"--include-tests",
help="Include test files",
),
show_groups: bool = typer.Option(
False,
"--groups",
help="Show available groups only",
),
):
"""
List discovered PraisonAI scripts without running them.
Examples:
praisonai batch list
praisonai batch list --sub
praisonai batch list --groups
"""
from praisonai.suite_runner import BatchSource
search_path = Path(path).resolve() if path else Path.cwd().resolve()
if not search_path.exists():
typer.echo(f"❌ Path not found: {search_path}")
raise typer.Exit(2)
source = BatchSource(
root=search_path,
recursive=recursive,
depth=depth,
exclude_tests=not include_tests,
)
# Show groups only
if show_groups:
groups = source.get_groups()
if not groups:
typer.echo(f"No PraisonAI scripts found in {search_path}")
return
typer.echo(f"Available groups in {search_path}:\n")
for g in groups:
typer.echo(f" - {g}")
return
items = source.discover()
if not items:
typer.echo(f"No PraisonAI scripts found in {search_path}")
if not recursive:
typer.echo("Tip: Use --sub to include subdirectories")
return
typer.echo(f"Found {len(items)} PraisonAI scripts in {search_path}\n")
for idx, item in enumerate(items, 1):
rel_path = item.source_path.relative_to(search_path)
flags = []
if item.skip:
flags.append("skip")
if item.uses_agent:
flags.append("Agent")
if item.uses_agents:
flags.append("Agents")
if item.uses_workflow:
flags.append("Workflow")
flag_str = f" [{', '.join(flags)}]" if flags else ""
typer.echo(f" {idx:3}. [{item.group}] {rel_path}{flag_str}")
@app.command("stats")
def batch_stats(
path: Optional[Path] = typer.Option(
None,
"--path", "-p",
help="Path to search",
),
recursive: bool = typer.Option(
False,
"--sub", "--recursive", "-r",
help="Include subdirectories",
),
depth: Optional[int] = typer.Option(
None,
"--depth", "-d",
help="Maximum recursion depth",
),
include_tests: bool = typer.Option(
False,
"--include-tests",
help="Include test files",
),
):
"""
Show statistics for PraisonAI scripts.
Displays counts by group, runnable status, and agent-centric usage.
Examples:
praisonai batch stats
praisonai batch stats --sub
"""
from collections import Counter
from praisonai.suite_runner import BatchSource
search_path = Path(path).resolve() if path else Path.cwd().resolve()
if not search_path.exists():
typer.echo(f"❌ Path not found: {search_path}")
raise typer.Exit(2)
source = BatchSource(
root=search_path,
recursive=recursive,
depth=depth,
exclude_tests=not include_tests,
)
items = source.discover()
if not items:
typer.echo(f"No PraisonAI scripts found in {search_path}")
return
# Calculate stats
group_counts = Counter(item.group for item in items)
runnable_by_group = Counter(item.group for item in items if item.runnable)
# Agent-centric stats
agent_count = sum(1 for item in items if item.uses_agent)
agents_count = sum(1 for item in items if item.uses_agents)
workflow_count = sum(1 for item in items if item.uses_workflow)
agent_by_group = Counter(item.group for item in items if item.uses_agent)
agents_by_group = Counter(item.group for item in items if item.uses_agents)
workflow_by_group = Counter(item.group for item in items if item.uses_workflow)
typer.echo("\n📊 Batch Execution Statistics")
typer.echo(f"Path: {search_path}")
typer.echo("=" * 80)
typer.echo(f"\n{'Group':<20} {'Total':>8} {'Runnable':>10} {'Agent':>8} {'Agents':>8} {'Workflow':>10}")
typer.echo("-" * 80)
for g in sorted(group_counts.keys()):
typer.echo(
f"{g:<20} {group_counts[g]:>8} {runnable_by_group[g]:>10} "
f"{agent_by_group[g]:>8} {agents_by_group[g]:>8} {workflow_by_group[g]:>10}"
)
typer.echo("-" * 80)
typer.echo(
f"{'TOTAL':<20} {len(items):>8} {sum(1 for i in items if i.runnable):>10} "
f"{agent_count:>8} {agents_count:>8} {workflow_count:>10}"
)
typer.echo()
@app.command("report")
def batch_report(
report_dir: Optional[Path] = typer.Option(
None,
"--dir", "-d",
help="Specific report directory (default: latest)",
),
format: str = typer.Option(
"summary",
"--format", "-f",
help="Output format: summary, failures, full",
),
):
"""
View the latest batch execution report.
Examples:
praisonai batch report
praisonai batch report --format failures
praisonai batch report --dir ~/Downloads/reports/batch/20240115_120000
"""
import json
# Find report directory
if report_dir:
target_dir = Path(report_dir)
else:
target_dir = _get_latest_report_dir()
if not target_dir or not target_dir.exists():
typer.echo("❌ No batch reports found.")
typer.echo(" Run 'praisonai batch' first to generate a report.")
raise typer.Exit(1)
# Load JSON report
json_path = target_dir / "report.json"
if not json_path.exists():
typer.echo(f"❌ Report not found: {json_path}")
raise typer.Exit(1)
report_data = json.loads(json_path.read_text())
typer.echo(f"\n📊 Batch Report: {target_dir.name}")
typer.echo("=" * 60)
# Summary
totals = report_data.get("totals", {})
typer.echo(f" ✅ Passed: {totals.get('passed', 0)}")
typer.echo(f" ❌ Failed: {totals.get('failed', 0)}")
typer.echo(f" ⏭️ Skipped: {totals.get('skipped', 0)}")
typer.echo(f" ⏱️ Timeout: {totals.get('timeout', 0)}")
typer.echo("=" * 60)
if format in ("failures", "full"):
results = report_data.get("results", [])
failures = [r for r in results if r.get("status") == "failed"]
if failures:
typer.echo("\n❌ FAILURES:")
typer.echo("-" * 60)
for r in failures:
typer.echo(f"\n {r.get('source_path', 'unknown')}")
if r.get("error_message"):
typer.echo(f" Error: {r['error_message'][:200]}")
if format == "full":
results = report_data.get("results", [])
typer.echo("\n📋 ALL RESULTS:")
typer.echo("-" * 60)
for r in results:
status = r.get("status", "unknown")
icon = {"passed": "✅", "failed": "❌", "skipped": "⏭️", "timeout": "⏱️"}.get(status, "?")
duration = r.get("duration_seconds", 0)
typer.echo(f" {icon} {r.get('source_path', 'unknown')} ({duration:.2f}s)")
typer.echo(f"\n📁 Full report: {target_dir}")
@@ -1,414 +0,0 @@
"""
Context command group for PraisonAI CLI.
Provides context management commands:
- stats: Show context statistics
- compact: Manually trigger context compaction
- export: Export context to file
- show: Show current context
- clear: Clear current context
"""
import typer
app = typer.Typer(help="Context management")
@app.command("show")
def context_show(
verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
):
"""Show current context."""
from praisonai_code.cli.main import PraisonAI
import sys
argv = ['context', 'show']
if verbose:
argv.append('--verbose')
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@app.command("clear")
def context_clear():
"""Clear current context."""
from praisonai_code.cli.main import PraisonAI
import sys
argv = ['context', 'clear']
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@app.command("add")
def context_add(
content: str = typer.Argument(..., help="Content to add to context"),
):
"""Add content to context."""
from praisonai_code.cli.main import PraisonAI
import sys
argv = ['context', 'add', content]
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@app.command("stats")
def context_stats(
agent: str = typer.Option("", "--agent", "-a", help="Agent name to show stats for"),
json_output: bool = typer.Option(False, "--json", "-j", help="Output as JSON"),
):
"""
Show context management statistics.
Displays token usage, utilization, cache stats, and optimization history.
"""
try:
from praisonaiagents.context import get_metrics
import json
# Get global metrics
metrics = get_metrics()
stats = metrics.to_dict()
if json_output:
print(json.dumps(stats, indent=2))
else:
from rich.console import Console
from rich.table import Table
console = Console()
# Timing table
timing_table = Table(title="Context Operation Timing")
timing_table.add_column("Operation", style="cyan")
timing_table.add_column("Calls", justify="right")
timing_table.add_column("Avg (ms)", justify="right")
timing_table.add_column("Max (ms)", justify="right")
for op, data in stats["timing"].items():
timing_table.add_row(
op,
str(data["calls"]),
f"{data['avg_ms']:.2f}",
f"{data['max_ms']:.2f}",
)
console.print(timing_table)
# Token stats
console.print("\n[bold]Token Statistics:[/bold]")
console.print(f" Processed: {stats['tokens']['processed']:,}")
console.print(f" Saved: {stats['tokens']['saved']:,}")
console.print(f" Compactions: {stats['tokens']['compactions']}")
# Cache stats
console.print("\n[bold]Cache Statistics:[/bold]")
console.print(f" Hits: {stats['cache']['hits']:,}")
console.print(f" Misses: {stats['cache']['misses']:,}")
console.print(f" Hit Rate: {stats['cache']['hit_rate']:.1%}")
except ImportError as e:
typer.echo(f"Error: Context module not available: {e}", err=True)
raise typer.Exit(1)
@app.command("compact")
def context_compact(
agent: str = typer.Option("", "--agent", "-a", help="Agent name to compact"),
threshold: float = typer.Option(0.8, "--threshold", "-t", help="Compaction threshold (0.0-1.0)"),
dry_run: bool = typer.Option(False, "--dry-run", "-n", help="Show what would be compacted without doing it"),
):
"""
Manually trigger context compaction.
Compacts context when utilization exceeds threshold.
"""
try:
from praisonaiagents.context import get_global_store
store = get_global_store()
stats = store.get_stats()
if dry_run:
typer.echo("[Dry Run] Current store stats:")
typer.echo(f" Agents: {stats['agent_count']}")
typer.echo(f" Total messages: {stats['total_messages']}")
for agent_id, agent_stats in stats.get("agents", {}).items():
typer.echo(f" {agent_id}: {agent_stats['message_count']} messages, {agent_stats['effective_count']} effective")
else:
# Cleanup orphaned parents for all agents
for agent_id in stats.get("agents", {}).keys():
if agent and agent_id != agent:
continue
store.cleanup_agent(agent_id)
typer.echo(f"Cleaned up agent: {agent_id}")
typer.echo("Compaction complete.")
except ImportError as e:
typer.echo(f"Error: Context module not available: {e}", err=True)
raise typer.Exit(1)
@app.command("export")
def context_export(
output: str = typer.Argument("context_export.json", help="Output file path"),
agent: str = typer.Option("", "--agent", "-a", help="Agent name to export"),
format: str = typer.Option("json", "--format", "-f", help="Export format (json, txt)"),
):
"""
Export context to a file.
Exports the current context store state for debugging or backup.
"""
try:
from praisonaiagents.context import get_global_store
from pathlib import Path
store = get_global_store()
if format == "json":
data = store.snapshot()
output_path = Path(output)
output_path.write_bytes(data)
typer.echo(f"Exported context to: {output_path}")
else:
# Text format
stats = store.get_stats()
output_path = Path(output)
lines = ["# Context Export", ""]
lines.append(f"Agents: {stats['agent_count']}")
lines.append(f"Total Messages: {stats['total_messages']}")
lines.append("")
for agent_id, agent_stats in stats.get("agents", {}).items():
if agent and agent_id != agent:
continue
lines.append(f"## Agent: {agent_id}")
lines.append(f"Messages: {agent_stats['message_count']}")
lines.append(f"Effective: {agent_stats['effective_count']}")
lines.append("")
output_path.write_text("\n".join(lines))
typer.echo(f"Exported context to: {output_path}")
except ImportError as e:
typer.echo(f"Error: Context module not available: {e}", err=True)
raise typer.Exit(1)
# =============================================================================
# Artifact Commands (Dynamic Context Discovery)
# =============================================================================
artifacts_app = typer.Typer(help="Artifact management for dynamic context discovery")
app.add_typer(artifacts_app, name="artifacts")
@artifacts_app.command("list")
def artifacts_list(
run_id: str = typer.Option(None, "--run-id", "-r", help="Filter by run ID"),
agent_id: str = typer.Option(None, "--agent-id", "-a", help="Filter by agent ID"),
tool_name: str = typer.Option(None, "--tool", "-t", help="Filter by tool name"),
json_output: bool = typer.Option(False, "--json", "-j", help="Output as JSON"),
):
"""List artifacts."""
try:
from praisonai.context import FileSystemArtifactStore
import json as json_module
store = FileSystemArtifactStore()
artifacts = store.list_artifacts(
run_id=run_id,
agent_id=agent_id,
tool_name=tool_name,
)
if json_output:
data = [a.to_dict() for a in artifacts]
print(json_module.dumps(data, indent=2))
else:
if not artifacts:
typer.echo("No artifacts found.")
return
from rich.console import Console
from rich.table import Table
console = Console()
table = Table(title=f"Artifacts ({len(artifacts)} found)")
table.add_column("ID", style="cyan")
table.add_column("Tool", style="green")
table.add_column("Size", justify="right")
table.add_column("Summary")
table.add_column("Path", style="dim")
for ref in artifacts:
size_str = ref._format_size(ref.size_bytes)
table.add_row(
ref.artifact_id,
ref.tool_name or "-",
size_str,
ref.summary[:40] + "..." if len(ref.summary) > 40 else ref.summary,
ref.path,
)
console.print(table)
except ImportError as e:
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(1)
@artifacts_app.command("show")
def artifacts_show(
artifact_path: str = typer.Argument(..., help="Path to artifact"),
lines: int = typer.Option(None, "--lines", "-n", help="Number of lines to show"),
):
"""Show artifact content."""
try:
from praisonai.context import FileSystemArtifactStore
from praisonaiagents.context.artifacts import ArtifactRef
store = FileSystemArtifactStore()
ref = ArtifactRef(path=artifact_path, summary="", size_bytes=0)
if lines:
content = store.head(ref, lines=lines)
else:
content = store.load(ref)
if not isinstance(content, str):
import json
content = json.dumps(content, indent=2)
typer.echo(content)
except FileNotFoundError:
typer.echo(f"Artifact not found: {artifact_path}", err=True)
raise typer.Exit(1)
except Exception as e:
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(1)
@artifacts_app.command("tail")
def artifacts_tail(
artifact_path: str = typer.Argument(..., help="Path to artifact"),
lines: int = typer.Option(50, "--lines", "-n", help="Number of lines"),
):
"""Get last N lines of artifact."""
try:
from praisonai.context import FileSystemArtifactStore
from praisonaiagents.context.artifacts import ArtifactRef
store = FileSystemArtifactStore()
ref = ArtifactRef(path=artifact_path, summary="", size_bytes=0)
content = store.tail(ref, lines=lines)
typer.echo(content)
except FileNotFoundError:
typer.echo(f"Artifact not found: {artifact_path}", err=True)
raise typer.Exit(1)
except Exception as e:
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(1)
@artifacts_app.command("grep")
def artifacts_grep(
artifact_path: str = typer.Argument(..., help="Path to artifact"),
pattern: str = typer.Argument(..., help="Search pattern (regex)"),
context_lines: int = typer.Option(2, "--context", "-C", help="Context lines"),
max_matches: int = typer.Option(50, "--max", "-m", help="Max matches"),
):
"""Search for pattern in artifact."""
try:
from praisonai.context import FileSystemArtifactStore
from praisonaiagents.context.artifacts import ArtifactRef
store = FileSystemArtifactStore()
ref = ArtifactRef(path=artifact_path, summary="", size_bytes=0)
matches = store.grep(ref, pattern=pattern, context_lines=context_lines, max_matches=max_matches)
if not matches:
typer.echo(f"No matches found for: {pattern}")
return
from rich.console import Console
console = Console()
console.print(f"[bold]Found {len(matches)} matches:[/bold]")
for match in matches:
console.print(f"\n[cyan]--- Line {match.line_number} ---[/cyan]")
for ctx in match.context_before:
console.print(f" {ctx}", style="dim")
console.print(f"[green]> {match.line_content}[/green]")
for ctx in match.context_after:
console.print(f" {ctx}", style="dim")
except FileNotFoundError:
typer.echo(f"Artifact not found: {artifact_path}", err=True)
raise typer.Exit(1)
except ValueError as e:
typer.echo(f"Invalid pattern: {e}", err=True)
raise typer.Exit(1)
except Exception as e:
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(1)
@artifacts_app.command("export")
def artifacts_export(
artifact_path: str = typer.Argument(..., help="Path to artifact"),
output: str = typer.Option(None, "--output", "-o", help="Output file path"),
):
"""Export artifact to file."""
try:
from pathlib import Path
import shutil
src_path = Path(artifact_path)
if not src_path.exists():
typer.echo(f"Artifact not found: {artifact_path}", err=True)
raise typer.Exit(1)
if output:
dst_path = Path(output)
else:
dst_path = Path.cwd() / src_path.name
shutil.copy2(src_path, dst_path)
typer.echo(f"Exported to: {dst_path}")
except Exception as e:
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(1)
File diff suppressed because it is too large. Load diff
@@ -1,953 +0,0 @@
"""
PraisonAI Examples CLI Command.
Run and manage example files with reporting and diagnostics.
Usage:
praisonai examples run # Run all examples
praisonai examples run --path ./examples # Custom path
praisonai examples list # List discovered examples
"""
from datetime import datetime
from pathlib import Path
from typing import Optional, List
import re
import typer
app = typer.Typer(help="Run and manage example files")
def _get_default_examples_path() -> Path:
"""Get default examples path (repo examples/ or cwd)."""
candidates = [
Path.home() / "praisonai-package" / "examples",
Path("/Users/praison/praisonai-package/examples"),
Path.cwd() / "examples",
Path(__file__).parent.parent.parent.parent.parent / "examples", # repo root
]
for candidate in candidates:
if candidate.exists() and candidate.is_dir():
return candidate
return Path.cwd()
def _get_default_report_dir() -> Path:
"""Get default report directory with timestamp."""
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
return Path.home() / "Downloads" / "reports" / "examples" / timestamp
@app.command()
def run(
path: Optional[Path] = typer.Option(
None,
"--path", "-p",
help="Path to examples directory",
),
include: Optional[List[str]] = typer.Option(
None,
"--include", "-i",
help="Include patterns (glob), can be specified multiple times",
),
exclude: Optional[List[str]] = typer.Option(
None,
"--exclude", "-e",
help="Exclude patterns (glob), can be specified multiple times",
),
group: Optional[List[str]] = typer.Option(
None,
"--group", "-g",
help="Run only specific groups (top-level dirs), can be repeated",
),
timeout: int = typer.Option(
60,
"--timeout", "-t",
help="Per-example timeout in seconds",
),
max_items: Optional[int] = typer.Option(
None,
"--max-items",
help="Maximum examples to run",
),
fail_fast: bool = typer.Option(
False,
"--fail-fast", "-x",
help="Stop on first failure",
),
no_stream: bool = typer.Option(
False,
"--no-stream",
help="Don't stream output to terminal",
),
report_dir: Optional[Path] = typer.Option(
None,
"--report-dir", "-r",
help="Directory for reports (default: ./reports/examples/<timestamp>)",
),
no_json: bool = typer.Option(
False,
"--no-json",
help="Skip JSON report generation",
),
no_md: bool = typer.Option(
False,
"--no-md",
help="Skip Markdown report generation",
),
no_csv: bool = typer.Option(
False,
"--no-csv",
help="Skip CSV report generation",
),
require_env: Optional[List[str]] = typer.Option(
None,
"--require-env",
help="Required env vars (skip all if missing)",
),
python: Optional[str] = typer.Option(
None,
"--python",
help="Python executable to use (default: current interpreter)",
),
quiet: bool = typer.Option(
False,
"--quiet", "-q",
help="Minimal output",
),
):
"""
Run all examples in the specified directory.
Examples:
praisonai examples run
praisonai examples run --path ./examples --timeout 120
praisonai examples run --group python --group mcp --max-items 5
praisonai examples run --include "context/*" --exclude "*_wow.py"
praisonai examples run --fail-fast --no-stream
"""
# Lazy import to avoid loading at CLI startup
from praisonai.suite_runner import ExamplesSource, SuiteExecutor, RunResult
# Resolve paths
examples_path = path or _get_default_examples_path()
output_dir = report_dir or _get_default_report_dir()
if not examples_path.exists():
typer.echo(f"❌ Examples path not found: {examples_path}")
raise typer.Exit(2)
# Create source
source = ExamplesSource(
root=examples_path,
include_patterns=list(include) if include else None,
exclude_patterns=list(exclude) if exclude else None,
groups=list(group) if group else None,
)
# Discover items
items = source.discover()
# Create executor
executor = SuiteExecutor(
suite="examples",
source_path=examples_path,
timeout=timeout,
fail_fast=fail_fast,
stream_output=not no_stream,
max_items=max_items,
require_env=list(require_env) if require_env else None,
report_dir=output_dir,
generate_json=not no_json,
generate_md=not no_md,
generate_csv=not no_csv,
python_executable=python,
pythonpath_additions=source.get_pythonpath(),
groups=list(group) if group else None,
)
# Status icons
icons = {
"passed": "✅",
"failed": "❌",
"skipped": "⏭️",
"timeout": "⏱️",
"xfail": "⚠️",
"not_run": "📝",
}
def on_item_start(item, idx: int, total: int):
if not quiet:
typer.echo(f"\n[{idx}/{total}] Running: {item.display_name}")
def on_item_end(result: RunResult, idx: int, total: int):
icon = icons.get(result.status, "?")
duration = f"{result.duration_seconds:.2f}s" if result.duration_seconds else ""
if quiet:
typer.echo(f"{icon} {result.display_name}")
else:
msg = f" {icon} {result.status.upper()}"
if duration:
msg += f" ({duration})"
if result.skip_reason:
msg += f" - {result.skip_reason}"
if result.error_message and not no_stream:
msg += f"\n Error: {result.error_message[:100]}"
typer.echo(msg)
def on_output(line: str, stream: str):
if not quiet and not no_stream:
prefix = " │ " if stream == "stdout" else " │ [err] "
typer.echo(f"{prefix}{line.rstrip()}")
# Print header
if not quiet:
typer.echo("=" * 60)
typer.echo("PraisonAI Examples Runner")
typer.echo("=" * 60)
typer.echo(f"Path: {examples_path}")
typer.echo(f"Timeout: {timeout}s")
if group:
typer.echo(f"Groups: {', '.join(group)}")
typer.echo(f"Reports: {output_dir}")
typer.echo(f"Items: {len(items)}")
# Run examples
report = executor.run(
items=items,
on_item_start=on_item_start,
on_item_end=on_item_end,
on_output=on_output if not no_stream else None,
)
# Update report with CLI args
report.cli_args = [f"--path={examples_path}", f"--timeout={timeout}"]
if fail_fast:
report.cli_args.append("--fail-fast")
if group:
for g in group:
report.cli_args.append(f"--group={g}")
# Print summary
totals = report.totals
total_count = sum(totals.values())
if not quiet:
typer.echo("\n" + "=" * 60)
typer.echo("SUMMARY")
typer.echo("=" * 60)
typer.echo(f" ✅ Passed: {totals['passed']}")
typer.echo(f" ❌ Failed: {totals['failed']}")
typer.echo(f" ⏭️ Skipped: {totals['skipped']}")
typer.echo(f" ⏱️ Timeout: {totals['timeout']}")
typer.echo(f" ⚠️ XFail: {totals['xfail']}")
typer.echo(" ─────────────────")
typer.echo(f" Total: {total_count}")
typer.echo("=" * 60)
if output_dir.exists():
typer.echo(f"\n📁 Reports saved to: {output_dir}")
# Exit code
if totals['failed'] > 0 or totals['timeout'] > 0:
raise typer.Exit(1)
raise typer.Exit(0)
@app.command("list")
def list_examples(
path: Optional[Path] = typer.Option(
None,
"--path", "-p",
help="Path to examples directory",
),
include: Optional[List[str]] = typer.Option(
None,
"--include", "-i",
help="Include patterns (glob)",
),
exclude: Optional[List[str]] = typer.Option(
None,
"--exclude", "-e",
help="Exclude patterns (glob)",
),
group: Optional[List[str]] = typer.Option(
None,
"--group", "-g",
help="Filter by group (top-level dir)",
),
show_metadata: bool = typer.Option(
False,
"--metadata", "-m",
help="Show parsed metadata for each example",
),
show_groups: bool = typer.Option(
False,
"--groups",
help="Show available groups only",
),
):
"""
List discovered examples without running them.
Examples:
praisonai examples list
praisonai examples list --groups
praisonai examples list --group python --metadata
"""
from praisonai.suite_runner import ExamplesSource
examples_path = path or _get_default_examples_path()
if not examples_path.exists():
typer.echo(f"❌ Examples path not found: {examples_path}")
raise typer.Exit(2)
source = ExamplesSource(
root=examples_path,
include_patterns=list(include) if include else None,
exclude_patterns=list(exclude) if exclude else None,
groups=list(group) if group else None,
)
# Show groups only
if show_groups:
groups = source.get_groups()
typer.echo(f"Available groups in {examples_path}:\n")
for g in groups:
typer.echo(f" - {g}")
return
items = source.discover()
typer.echo(f"Found {len(items)} examples in {examples_path}\n")
for idx, item in enumerate(items, 1):
rel_path = item.source_path.relative_to(examples_path)
if show_metadata:
flags = []
if item.skip:
flags.append("skip")
if item.timeout:
flags.append(f"timeout={item.timeout}")
if item.require_env:
flags.append(f"env={','.join(item.require_env)}")
if item.xfail:
flags.append(f"xfail={item.xfail}")
if item.is_interactive:
flags.append("interactive")
flag_str = f" [{', '.join(flags)}]" if flags else ""
typer.echo(f" {idx:3}. [{item.group}] {rel_path}{flag_str}")
else:
typer.echo(f" {idx:3}. [{item.group}] {rel_path}")
def _tokenize_query(query: str) -> List[str]:
"""Tokenize query into lowercase search terms."""
return [t for t in re.split(r"[^a-zA-Z0-9_\-]+", query.lower()) if t]
@app.command("find")
def find_examples(
query: str = typer.Argument(..., help="Search query (e.g. rag, mcp, gemini)"),
path: Optional[Path] = typer.Option(
None,
"--path", "-p",
help="Path to examples directory",
),
group: Optional[List[str]] = typer.Option(
None,
"--group", "-g",
help="Filter by group (top-level dir), can be repeated",
),
limit: int = typer.Option(
10,
"--limit", "-n",
help="Maximum number of matches to show",
min=1,
),
):
"""
Find examples by keyword with lightweight relevance scoring.
Examples:
praisonai examples find rag
praisonai examples find mcp --group python
praisonai examples find gemini --limit 20
"""
from praisonai.suite_runner import ExamplesSource
examples_path = path or _get_default_examples_path()
if not examples_path.exists():
typer.echo(f"❌ Examples path not found: {examples_path}")
raise typer.Exit(2)
if not examples_path.is_dir():
typer.echo(f"❌ Examples path is not a directory: {examples_path}")
raise typer.Exit(2)
terms = _tokenize_query(query)
if not terms:
typer.echo("❌ Please provide a non-empty search query")
raise typer.Exit(2)
# Resolve path to ensure it's absolute for relative_to operations
examples_path = examples_path.resolve()
source = ExamplesSource(
root=examples_path,
groups=list(group) if group else None,
)
items = source.discover()
matches = []
for item in items:
rel_path = item.source_path.relative_to(examples_path).as_posix()
rel_lower = rel_path.lower()
filename_lower = item.source_path.name.lower()
group_lower = item.group.lower()
# Build searchable text with metadata signals
searchable = [
rel_lower,
filename_lower,
group_lower,
(item.runnable_decision or "").lower(),
" ".join((item.require_env or [])).lower(),
]
if item.uses_agent:
searchable.append("agent")
if item.uses_agents:
searchable.append("agents")
if item.uses_workflow:
searchable.append("workflow")
haystack = " ".join(searchable)
score = 0
matched_terms = 0
for term in terms:
term_score = 0
if term in filename_lower:
term_score = max(term_score, 8)
if f"/{term}" in rel_lower or rel_lower.startswith(term):
term_score = max(term_score, 6)
if term in group_lower:
term_score = max(term_score, 5)
if term in haystack:
term_score = max(term_score, 3)
if term_score > 0:
matched_terms += 1
score += term_score
# Prefer results matching all terms; allow partial for flexibility
if score > 0:
if matched_terms == len(terms):
score += 2
matches.append((score, rel_path, item))
matches.sort(key=lambda x: (-x[0], x[1]))
if not matches:
typer.echo(f"No examples found for query: {query}")
raise typer.Exit(0)
typer.echo(f"Found {len(matches)} matches for '{query}' in {examples_path}\n")
for idx, (score, rel_path, item) in enumerate(matches[:limit], 1):
tags = []
if item.uses_agent:
tags.append("agent")
if item.uses_agents:
tags.append("agents")
if item.uses_workflow:
tags.append("workflow")
if item.require_env:
tags.append("env")
tag_str = f" [{' '.join(tags)}]" if tags else ""
typer.echo(f" {idx:2}. [{item.group}] {rel_path} (score={score}){tag_str}")
@app.command()
def info(
example: Path = typer.Argument(
...,
help="Path to example file",
),
):
"""
Show detailed metadata for a specific example.
Examples:
praisonai examples info ./examples/context/01_basic.py
"""
from praisonai.suite_runner import ExamplesSource
if not example.exists():
typer.echo(f"❌ Example not found: {example}")
raise typer.Exit(2)
# Create a source just to parse the file
source = ExamplesSource(root=example.parent)
item = source._create_item(example)
typer.echo(f"Example: {example.name}")
typer.echo(f"Path: {example}")
typer.echo(f"Group: {item.group}")
typer.echo("")
typer.echo("Metadata:")
typer.echo(f" Runnable: {item.runnable}")
typer.echo(f" Decision: {item.runnable_decision}")
typer.echo(f" Skip: {item.skip}")
if item.skip_reason:
typer.echo(f" Skip Reason: {item.skip_reason}")
typer.echo(f" Timeout: {item.timeout or 'default'}")
typer.echo(f" Required Env: {', '.join(item.require_env) if item.require_env else 'none'}")
typer.echo(f" XFail: {item.xfail or 'no'}")
typer.echo(f" Interactive: {item.is_interactive}")
typer.echo(f" Code Hash: {item.code_hash}")
@app.command("stats")
def examples_stats(
path: Optional[Path] = typer.Option(
None,
"--path", "-p",
help="Path to examples directory",
),
group: Optional[List[str]] = typer.Option(
None,
"--group", "-g",
help="Filter by group (top-level dir)",
),
):
"""
Show statistics for examples.
Displays counts by group, runnable status, and agent-centric usage.
Examples:
praisonai examples stats
praisonai examples stats --group python --group mcp
"""
from collections import Counter
from praisonai.suite_runner import ExamplesSource
examples_path = path or _get_default_examples_path()
if not examples_path.exists():
typer.echo(f"❌ Examples path not found: {examples_path}")
raise typer.Exit(2)
source = ExamplesSource(
root=examples_path,
groups=list(group) if group else None,
)
items = source.discover()
# Calculate stats
group_counts = Counter(item.group for item in items)
runnable_by_group = Counter(item.group for item in items if item.runnable)
# Agent-centric stats
agent_count = sum(1 for item in items if item.uses_agent)
agents_count = sum(1 for item in items if item.uses_agents)
workflow_count = sum(1 for item in items if item.uses_workflow)
agent_by_group = Counter(item.group for item in items if item.uses_agent)
agents_by_group = Counter(item.group for item in items if item.uses_agents)
workflow_by_group = Counter(item.group for item in items if item.uses_workflow)
typer.echo("\n📊 Examples Statistics")
typer.echo(f"Path: {examples_path}")
typer.echo("=" * 80)
typer.echo(f"\n{'Group':<20} {'Total':>8} {'Runnable':>10} {'Agent':>8} {'Agents':>8} {'Workflow':>10}")
typer.echo("-" * 80)
for g in sorted(group_counts.keys()):
typer.echo(
f"{g:<20} {group_counts[g]:>8} {runnable_by_group[g]:>10} "
f"{agent_by_group[g]:>8} {agents_by_group[g]:>8} {workflow_by_group[g]:>10}"
)
typer.echo("-" * 80)
typer.echo(
f"{'TOTAL':<20} {len(items):>8} {sum(1 for i in items if i.runnable):>10} "
f"{agent_count:>8} {agents_count:>8} {workflow_count:>10}"
)
typer.echo()
@app.command("run-all")
def examples_run_all(
path: Optional[Path] = typer.Option(
None,
"--path", "-p",
help="Path to examples directory",
),
timeout: int = typer.Option(
60,
"--timeout", "-t",
help="Per-example timeout in seconds",
),
report_dir: Optional[Path] = typer.Option(
None,
"--report-dir", "-r",
help="Directory for reports (default: ~/Downloads/reports/examples/<timestamp>)",
),
parallel: bool = typer.Option(
True,
"--parallel/--sequential",
help="Run groups in parallel (default: parallel)",
),
max_workers: int = typer.Option(
4,
"--workers", "-w",
help="Max parallel workers (default: 4)",
),
quiet: bool = typer.Option(
False,
"--quiet", "-q",
help="Minimal output",
),
ci: bool = typer.Option(
False,
"--ci",
help="CI-friendly output (no colors, proper exit codes)",
),
):
"""
Run all examples group-by-group.
Executes all groups and generates a comprehensive report.
Uses parallel execution by default for faster results.
Examples:
praisonai examples run-all
praisonai examples run-all --sequential
praisonai examples run-all --workers 8 --timeout 120
"""
import json
from concurrent.futures import ThreadPoolExecutor, as_completed
from praisonai.suite_runner import ExamplesSource, SuiteExecutor, RunResult
examples_path = path or _get_default_examples_path()
output_dir = report_dir or _get_default_report_dir()
if not examples_path.exists():
typer.echo(f"❌ Examples path not found: {examples_path}")
raise typer.Exit(2)
# Get all groups
source = ExamplesSource(root=examples_path)
all_groups = sorted(source.get_groups())
if ci:
print(f"Found {len(all_groups)} groups to process")
print(f"Mode: {'parallel' if parallel else 'sequential'}")
print(f"Workers: {max_workers}")
print(f"Timeout: {timeout}s")
print("=" * 60)
else:
typer.echo(f"Found {len(all_groups)} groups to process")
typer.echo(f"Mode: {'parallel' if parallel else 'sequential'}")
typer.echo("=" * 60)
# Track overall results
overall_results = {
'passed': 0, 'failed': 0, 'skipped': 0,
'timeout': 0, 'not_run': 0, 'total': 0, 'xfail': 0,
}
group_summaries = []
def run_group(group_name: str) -> dict:
"""Run a single group and return summary."""
group_source = ExamplesSource(root=examples_path, groups=[group_name])
items = group_source.discover()
if not items:
return {
'group': group_name,
'total': 0, 'passed': 0, 'failed': 0,
'skipped': 0, 'timeout': 0, 'not_run': 0, 'xfail': 0,
}
group_report_dir = output_dir / group_name
group_report_dir.mkdir(parents=True, exist_ok=True)
executor = SuiteExecutor(
suite='examples',
source_path=examples_path,
report_dir=group_report_dir,
timeout=timeout,
stream_output=False,
generate_json=True,
generate_md=True,
generate_csv=True,
groups=[group_name],
)
report = executor.run(items=items)
totals = report.totals
return {
'group': group_name,
**totals,
}
if parallel and len(all_groups) > 1:
# Parallel execution using ThreadPoolExecutor (ProcessPoolExecutor cannot pickle nested functions)
with ThreadPoolExecutor(max_workers=min(max_workers, len(all_groups))) as pool:
futures = {pool.submit(run_group, g): g for g in all_groups}
for future in as_completed(futures):
group_name = futures[future]
try:
summary = future.result()
group_summaries.append(summary)
# Update overall
for key in overall_results:
if key in summary:
overall_results[key] += summary[key]
if not quiet:
if ci:
print(
f"PASS {group_name}: "
f"PASS:{summary['passed']} FAIL:{summary['failed']} "
f"SKIP:{summary['skipped']} TIMEOUT:{summary['timeout']} XFAIL:{summary.get('xfail', 0)}"
)
else:
typer.echo(
f"✅ {group_name}: "
f"✅{summary['passed']} ❌{summary['failed']} "
f"⏭️{summary['skipped']} ⏱️{summary['timeout']} ⚠️{summary.get('xfail', 0)}"
)
except Exception as e:
if ci:
print(f"FAIL {group_name}: Error - {e}")
else:
typer.echo(f"❌ {group_name}: Error - {e}")
else:
# Sequential execution with real-time output
for group_name in all_groups:
if not quiet:
typer.echo(f"\n{'='*60}")
typer.echo(f"GROUP: {group_name}")
typer.echo(f"{'='*60}")
group_source = ExamplesSource(root=examples_path, groups=[group_name])
items = group_source.discover()
if not items:
if not quiet:
typer.echo(" No items found, skipping")
group_summaries.append({
'group': group_name,
'total': 0, 'passed': 0, 'failed': 0,
'skipped': 0, 'timeout': 0, 'not_run': 0, 'xfail': 0,
})
continue
group_report_dir = output_dir / group_name
group_report_dir.mkdir(parents=True, exist_ok=True)
executor = SuiteExecutor(
suite='examples',
source_path=examples_path,
report_dir=group_report_dir,
timeout=timeout,
stream_output=False,
generate_json=True,
generate_md=True,
generate_csv=True,
groups=[group_name],
)
icons = {'passed': '✅', 'failed': '❌', 'skipped': '⏭️', 'timeout': '⏱️', 'xfail': '⚠️'}
def on_start(item, idx, total):
if not quiet:
typer.echo(f" [{idx}/{total}] {item.display_name} ", nl=False)
def on_end(result: RunResult, idx, total):
if not quiet:
icon = icons.get(result.status, '❓')
typer.echo(f"{icon}")
report = executor.run(items=items, on_item_start=on_start, on_item_end=on_end)
totals = report.totals
group_summaries.append({'group': group_name, **totals})
for key in overall_results:
if key in totals:
overall_results[key] += totals[key]
if not quiet:
typer.echo(
f"\n Summary: ✅{totals['passed']} ❌{totals['failed']} "
f"⏭️{totals['skipped']} ⏱️{totals['timeout']} ⚠️{totals.get('xfail', 0)}"
)
# Final report
if ci:
print("")
print("=" * 80)
print("FINAL REPORT - ALL GROUPS")
print("=" * 80)
print(f"{'Group':<20} {'Total':>8} {'Passed':>8} {'Failed':>8} {'Skip':>8} {'Timeout':>8} {'XFail':>8}")
print("-" * 80)
for gs in sorted(group_summaries, key=lambda x: x['group']):
print(
f"{gs['group']:<20} {gs['total']:>8} {gs['passed']:>8} {gs['failed']:>8} "
f"{gs['skipped']:>8} {gs['timeout']:>8} {gs.get('xfail', 0):>8}"
)
print("-" * 80)
print(
f"{'TOTAL':<20} {overall_results['total']:>8} {overall_results['passed']:>8} "
f"{overall_results['failed']:>8} {overall_results['skipped']:>8} "
f"{overall_results['timeout']:>8} {overall_results.get('xfail', 0):>8}"
)
else:
typer.echo("\n" + "=" * 80)
typer.echo("FINAL REPORT - ALL GROUPS")
typer.echo("=" * 80)
typer.echo(f"\n{'Group':<20} {'Total':>8} {'Passed':>8} {'Failed':>8} {'Skip':>8} {'Timeout':>8} {'XFail':>8}")
typer.echo("-" * 80)
for gs in sorted(group_summaries, key=lambda x: x['group']):
typer.echo(
f"{gs['group']:<20} {gs['total']:>8} {gs['passed']:>8} {gs['failed']:>8} "
f"{gs['skipped']:>8} {gs['timeout']:>8} {gs.get('xfail', 0):>8}"
)
typer.echo("-" * 80)
typer.echo(
f"{'TOTAL':<20} {overall_results['total']:>8} {overall_results['passed']:>8} "
f"{overall_results['failed']:>8} {overall_results['skipped']:>8} "
f"{overall_results['timeout']:>8} {overall_results.get('xfail', 0):>8}"
)
# Save final summary
output_dir.mkdir(parents=True, exist_ok=True)
summary_path = output_dir / 'final_summary.json'
with open(summary_path, 'w') as f:
json.dump({
'timestamp': datetime.now().isoformat(),
'overall': overall_results,
'groups': group_summaries,
}, f, indent=2)
if ci:
print(f"Final summary: {summary_path}")
print(f"Group reports: {output_dir}")
else:
typer.echo(f"\n📁 Final summary saved to: {summary_path}")
typer.echo(f"📁 Individual group reports in: {output_dir}")
# Exit code
if overall_results['failed'] > 0 or overall_results['timeout'] > 0:
raise typer.Exit(1)
raise typer.Exit(0)
@app.command("report")
def examples_report(
report_dir: Optional[Path] = typer.Argument(
None,
help="Report directory path (default: auto-detect latest)",
),
base_dir: Optional[Path] = typer.Option(
None,
"--base-dir", "-b",
help="Base reports directory (default: ~/Downloads/reports/examples)",
),
limit: int = typer.Option(
50,
"--limit", "-n",
help="Limit rows in tables (0 = no limit)",
),
errors: bool = typer.Option(
True,
"--errors/--no-errors",
help="Show error grouping section",
),
wide: bool = typer.Option(
False,
"--wide/--no-wide",
help="Wide output: full error messages and item lists",
),
show_paths: bool = typer.Option(
True,
"--show-paths/--no-show-paths",
help="Show affected item paths in error groups",
),
match: Optional[List[str]] = typer.Option(
None,
"--match", "-m",
help="Filter by status (repeatable: --match failed --match timeout)",
),
group: Optional[List[str]] = typer.Option(
None,
"--group", "-g",
help="Filter by group name (repeatable)",
),
contains: Optional[str] = typer.Option(
None,
"--contains", "-c",
help="Filter items whose error contains text (case-insensitive)",
),
open_dir: bool = typer.Option(
False,
"--open",
help="Show report artifacts listing",
),
output_format: str = typer.Option(
"table",
"--format", "-f",
help="Output format: table or json",
),
):
"""
View execution report with failures and error grouping.
Examples:
praisonai examples report # View latest report
praisonai examples report ./my-report # View specific report
praisonai examples report --wide --limit 0 # Full details
praisonai examples report --match failed # Only failures
praisonai examples report --format json # JSON output
"""
# Lazy import to avoid loading at CLI startup
from praisonai.suite_runner.report_viewer import view_report
output, exit_code = view_report(
report_dir=report_dir,
suite="examples",
base_dir=base_dir,
limit=limit,
show_errors=errors,
wide=wide,
show_paths=show_paths,
match_statuses=match,
match_groups=group,
contains=contains,
show_artifacts=open_dir,
output_format=output_format,
)
typer.echo(output)
raise typer.Exit(exit_code)
if __name__ == "__main__":
app()
@@ -1,436 +0,0 @@
"""PraisonAI Flow — Visual workflow builder powered by Langflow.
Provides `praisonai flow` CLI command to launch Langflow with
PraisonAI custom components (Agent, Agents, Task) pre-loaded.
"""
import typer
import click
app = typer.Typer(name="flow", help="Visual workflow builder (Langflow)")
@app.callback(invoke_without_command=True)
def flow_start(
ctx: typer.Context,
port: int = typer.Option(7860, "--port", "-p", help="Port to listen on"),
host: str = typer.Option("127.0.0.1", "--host", "-H", help="Host to bind to"),
env_file: str = typer.Option(None, "--env-file", help="Path to .env file"),
no_open: bool = typer.Option(False, "--no-open", help="Don't open browser on start"),
log_level: str = typer.Option(
"error",
"--log-level",
"-l",
help="Logging level (debug, info, warning, error, critical)",
),
backend_only: bool = typer.Option(
False, "--backend-only", help="Run backend API only (no frontend UI)"
),
components_path: str = typer.Option(
None,
"--components-path",
help="Additional custom components directory",
),
):
"""Start PraisonAI Flow — visual workflow builder.
Launches Langflow with PraisonAI Agent, AgentTeam, and Task components
pre-loaded in the sidebar. Build complex AI workflows visually.
Install: pip install praisonai[flow]
Examples:
praisonai flow
praisonai flow --port 8080
praisonai flow --host 0.0.0.0 --backend-only
"""
if ctx.invoked_subcommand is not None:
return
import os
import subprocess
import sys
from pathlib import Path
from rich.console import Console
console = Console()
# Check langflow is installed
try:
import langflow # noqa: F401
except ImportError:
console.print(
"[red]Langflow is not installed.[/red]\n"
"[yellow]Install with: pip install praisonai[flow][/yellow]"
)
raise typer.Abort()
# Resolve PraisonAI components directory (absolute path)
praison_components = str(
(Path(__file__).parent.parent.parent / "flow" / "components").resolve()
)
# Set LANGFLOW_COMPONENTS_PATH env var (comma-separated for pydantic list parsing)
env = os.environ.copy()
existing = env.get("LANGFLOW_COMPONENTS_PATH", "")
if components_path:
all_paths = f"{praison_components},{components_path}"
else:
all_paths = praison_components
env["LANGFLOW_COMPONENTS_PATH"] = (
f"{all_paths},{existing}" if existing else all_paths
)
# Suppress macOS CoreFoundation fork safety warnings (harmless noise from multiprocessing)
if sys.platform == "darwin":
env.setdefault("OBJC_DISABLE_INITIALIZE_FORK_SAFETY", "YES")
# Suppress noisy Openlayer/loguru debug messages unless user wants them
if log_level.lower() not in ("debug",):
env.setdefault("LOGURU_LEVEL", log_level.upper())
# Build langflow run command
cmd = [
sys.executable,
"-m",
"langflow",
"run",
"--port",
str(port),
"--host",
host,
"--log-level",
log_level,
]
if backend_only:
cmd.append("--backend-only")
if env_file:
cmd.extend(["--env-file", env_file])
if no_open:
cmd.extend(["--open-browser", "false"])
console.print()
console.print("[bold green]🚀 Starting PraisonAI Flow[/bold green]")
console.print(f"[dim]Langflow + PraisonAI components on {host}:{port}[/dim]")
console.print(
f"[dim]Components: {praison_components}[/dim]"
)
console.print()
try:
subprocess.run(cmd, env=env)
except KeyboardInterrupt:
console.print("\n[yellow]PraisonAI Flow stopped.[/yellow]")
except Exception as e:
console.print(f"[red]Error starting Langflow: {e}[/red]")
raise typer.Abort()
@app.command("import")
def flow_import(
yaml_path: str = typer.Argument(..., help="Path to YAML workflow file"),
langflow_url: str = typer.Option(
"http://localhost:7860",
"--url",
help="Langflow server URL"
),
dry_run: bool = typer.Option(
False,
"--dry-run",
help="Preview JSON without uploading"
),
open_browser: bool = typer.Option(
False,
"--open",
help="Open imported flow in browser"
),
output: str = typer.Option(
None,
"--output",
"-o",
help="Save JSON to file instead of uploading"
),
):
"""Import YAML workflow into Langflow.
Converts PraisonAI YAML workflow to Langflow JSON format and uploads
to a running Langflow instance for visual editing.
Examples:
praisonai flow import workflow.yaml
praisonai flow import workflow.yaml --dry-run
praisonai flow import workflow.yaml --output flow.json
praisonai flow import workflow.yaml --url http://localhost:8080
"""
from pathlib import Path
from rich.console import Console
from rich.json import JSON
console = Console()
# Validate input file
yaml_file = Path(yaml_path)
if not yaml_file.exists():
console.print(f"[red]Error: File not found: {yaml_path}[/red]")
raise typer.Abort()
try:
from praisonai.flow.converter import yaml_to_langflow_json
console.print(f"[cyan]Converting {yaml_path} to Langflow format...[/cyan]")
# Convert YAML to Langflow JSON
langflow_json = yaml_to_langflow_json(str(yaml_file))
# Dry run: just show the JSON
if dry_run:
console.print("\n[bold green]✅ Conversion Preview[/bold green]")
console.print(JSON.from_data(langflow_json, indent=2))
return
# Save to file mode
if output:
output_path = Path(output)
output_path.parent.mkdir(parents=True, exist_ok=True)
import json
with open(output_path, 'w') as f:
json.dump(langflow_json, f, indent=2)
console.print(f"[green]✅ Flow saved to {output_path}[/green]")
return
# Upload to Langflow
from praisonai.flow.client import create_client
console.print(f"[cyan]Connecting to Langflow at {langflow_url}...[/cyan]")
client = create_client(langflow_url)
# Check server health
health = client.health_check()
if health["status"] != "healthy":
console.print(f"[red]Error: Langflow server not accessible at {langflow_url}[/red]")
console.print("[yellow]Make sure Langflow is running: praisonai flow[/yellow]")
raise typer.Abort()
# Upload flow
console.print("[cyan]Uploading flow to Langflow...[/cyan]")
response = client.upload_flow(langflow_json)
flow_id = response.get("id", response.get("flow_id", ""))
flow_name = langflow_json.get("name", "Imported Flow")
console.print(f"[green]✅ Flow '{flow_name}' imported successfully![/green]")
console.print(f"[dim]Flow ID: {flow_id}[/dim]")
# Generate flow URL
if flow_id:
flow_url = f"{langflow_url}/flow/{flow_id}"
console.print(f"[blue]View: {flow_url}[/blue]")
# Open browser if requested
if open_browser:
import webbrowser
webbrowser.open(flow_url)
console.print("[dim]Opening in browser...[/dim]")
except ImportError as e:
console.print(f"[red]Error: {e}[/red]")
console.print("[yellow]Install with: pip install 'praisonai[flow]'[/yellow]")
raise typer.Abort()
except Exception as e:
console.print(f"[red]Error importing flow: {e}[/red]")
raise typer.Abort()
@app.command("export")
def flow_export(
flow_id: str = typer.Argument(..., help="Flow ID to export"),
output: str = typer.Option(
None,
"--output",
"-o",
help="Output YAML file path (default: flow_id.yaml)"
),
langflow_url: str = typer.Option(
"http://localhost:7860",
"--url",
help="Langflow server URL"
),
format: str = typer.Option(
"yaml",
"--format",
help="Output format (yaml, json)",
click_type=click.Choice(["yaml", "json"])
),
):
"""Export Langflow flow to YAML format.
Downloads a flow from Langflow and converts it back to PraisonAI
YAML format for use with the CLI.
Examples:
praisonai flow export abc-123-def
praisonai flow export abc-123-def --output my_workflow.yaml
praisonai flow export abc-123-def --format json
"""
from pathlib import Path
from rich.console import Console
console = Console()
try:
from praisonai.flow.client import create_client
console.print(f"[cyan]Connecting to Langflow at {langflow_url}...[/cyan]")
client = create_client(langflow_url)
# Check server health
health = client.health_check()
if health["status"] != "healthy":
console.print(f"[red]Error: Langflow server not accessible at {langflow_url}[/red]")
raise typer.Abort()
# Download flow
console.print(f"[cyan]Downloading flow {flow_id}...[/cyan]")
flow_data = client.get_flow(flow_id)
# Determine output file
if not output:
flow_name = flow_data.get("name", flow_id)
safe_name = "".join(c for c in flow_name if c.isalnum() or c in (' ', '-', '_')).rstrip()
output = f"{safe_name}.{format}"
output_path = Path(output)
output_path.parent.mkdir(parents=True, exist_ok=True)
if format == "json":
# Save as JSON
import json
with open(output_path, 'w') as f:
json.dump(flow_data, f, indent=2)
else:
# Convert to YAML
from praisonai.flow.converter import langflow_json_to_yaml
console.print("[cyan]Converting to YAML format...[/cyan]")
yaml_content = langflow_json_to_yaml(flow_data)
with open(output_path, 'w') as f:
f.write(yaml_content)
console.print(f"[green]✅ Flow exported to {output_path}[/green]")
except ImportError as e:
console.print(f"[red]Error: {e}[/red]")
console.print("[yellow]Install with: pip install 'praisonai[flow]'[/yellow]")
raise typer.Abort()
except Exception as e:
console.print(f"[red]Error exporting flow: {e}[/red]")
raise typer.Abort()
@app.command("list")
def flow_list(
langflow_url: str = typer.Option(
"http://localhost:7860",
"--url",
help="Langflow server URL"
),
search: str = typer.Option(
None,
"--search",
"-s",
help="Search flows by name or description"
),
):
"""List flows in Langflow server.
Shows all flows with their IDs, names, and descriptions for
easy identification and export.
Examples:
praisonai flow list
praisonai flow list --search research
praisonai flow list --url http://localhost:8080
"""
from rich.console import Console
from rich.table import Table
console = Console()
try:
from praisonai.flow.client import create_client
console.print(f"[cyan]Connecting to Langflow at {langflow_url}...[/cyan]")
client = create_client(langflow_url)
# Check server health
health = client.health_check()
if health["status"] != "healthy":
console.print(f"[red]Error: Langflow server not accessible at {langflow_url}[/red]")
raise typer.Abort()
# Get flows
if search:
console.print(f"[cyan]Searching for flows matching '{search}'...[/cyan]")
flows = client.search_flows(search)
else:
console.print("[cyan]Loading flows...[/cyan]")
flows = client.list_flows()
if not flows:
if search:
console.print(f"[yellow]No flows found matching '{search}'[/yellow]")
else:
console.print("[yellow]No flows found[/yellow]")
return
# Create table
table = Table(title=f"Langflow Flows ({len(flows)} found)")
table.add_column("ID", style="cyan", min_width=20)
table.add_column("Name", style="green")
table.add_column("Description", style="dim")
table.add_column("Created", style="blue")
for flow in flows:
flow_id = flow.get("id", "")[:20] + "..." if len(flow.get("id", "")) > 20 else flow.get("id", "")
name = flow.get("name", "Unnamed")
description = flow.get("description", "")[:50] + "..." if len(flow.get("description", "")) > 50 else flow.get("description", "")
created = flow.get("created_at", "")[:10] if flow.get("created_at") else ""
table.add_row(flow_id, name, description, created)
console.print(table)
except ImportError as e:
console.print(f"[red]Error: {e}[/red]")
console.print("[yellow]Install with: pip install 'praisonai[flow]'[/yellow]")
raise typer.Abort()
except Exception as e:
console.print(f"[red]Error listing flows: {e}[/red]")
raise typer.Abort()
@app.command("version")
def flow_version():
"""Show Langflow version information."""
from rich.console import Console
console = Console()
try:
from langflow.utils.version import get_version_info
info = get_version_info()
console.print(f"[bold]Langflow[/bold]: {info.get('version', 'unknown')}")
console.print(f"[bold]Package[/bold]: {info.get('package', 'unknown')}")
except ImportError:
console.print(
"[red]Langflow is not installed.[/red]\n"
"[yellow]Install with: pip install praisonai[flow][/yellow]"
)
except Exception as e:
console.print(f"[red]Error: {e}[/red]")
@@ -1,440 +0,0 @@
"""
Knowledge command group for PraisonAI CLI.
Provides knowledge base management commands:
- index: Add/index documents into a knowledge base (canonical indexing command)
- search: Search/retrieve from knowledge base (no generation)
- list: List available knowledge bases
Knowledge is the canonical substrate for indexing and retrieval.
For answering questions with citations, use `praisonai rag query`.
"""
import typer
from typing import Optional, List
from pathlib import Path
from contextlib import contextmanager
import time
import json as json_module
app = typer.Typer(help="Knowledge base management (indexing and retrieval)")
@contextmanager
def knowledge_profiler(enabled: bool, profile_out: Optional[Path], profile_top: int = 20):
"""
Context manager for knowledge command profiling.
Uses the existing praisonai.profiler infrastructure when available,
falls back to basic timing if not.
"""
if not enabled:
yield None
return
import sys
start_time = time.perf_counter()
start_modules = set(sys.modules.keys())
# Try to use the full profiler
profiler = None
try:
from praisonai.profiler import Profiler
Profiler.enable()
Profiler.clear()
profiler = Profiler
except ImportError:
pass
# Try tracemalloc for memory
try:
import tracemalloc
tracemalloc.start()
except Exception:
tracemalloc = None
profile_data = {
"command": "knowledge",
"start_time": time.time(),
"metrics": {},
"imports": [],
"top_functions": [],
}
try:
yield profile_data
finally:
end_time = time.perf_counter()
elapsed_ms = (end_time - start_time) * 1000
# Collect metrics
profile_data["metrics"]["wall_time_ms"] = elapsed_ms
profile_data["metrics"]["wall_time_s"] = elapsed_ms / 1000
# Memory usage
if tracemalloc:
try:
current, peak = tracemalloc.get_traced_memory()
tracemalloc.stop()
profile_data["metrics"]["peak_memory_mb"] = peak / (1024 * 1024)
profile_data["metrics"]["current_memory_mb"] = current / (1024 * 1024)
except Exception:
pass
# New imports during execution
end_modules = set(sys.modules.keys())
new_modules = end_modules - start_modules
profile_data["imports"] = sorted(list(new_modules))[:profile_top]
profile_data["metrics"]["modules_imported"] = len(new_modules)
# Get profiler data if available
if profiler:
try:
profiler.disable()
stats = profiler.get_statistics()
if stats:
profile_data["top_functions"] = stats.get("top_functions", [])[:profile_top]
profile_data["metrics"].update(stats.get("summary", {}))
except Exception:
pass
# Save to file if requested
if profile_out:
try:
profile_out.parent.mkdir(parents=True, exist_ok=True)
with open(profile_out, "w") as f:
json_module.dump(profile_data, f, indent=2, default=str)
except Exception as e:
print(f"Warning: Failed to save profile: {e}")
# Print summary
from rich.console import Console
console = Console(stderr=True)
console.print(f"\n[dim]Profile: {elapsed_ms:.2f}ms wall time[/dim]")
if "peak_memory_mb" in profile_data["metrics"]:
console.print(f"[dim]Profile: {profile_data['metrics']['peak_memory_mb']:.2f}MB peak memory[/dim]")
console.print(f"[dim]Profile: {len(new_modules)} modules imported[/dim]")
if profile_out:
console.print(f"[dim]Profile saved to: {profile_out}[/dim]")
@app.command("index")
def knowledge_index(
sources: List[str] = typer.Argument(..., help="Source files, directories, or URLs to index"),
collection: str = typer.Option("default", "--collection", "-c", help="Collection/knowledge base name"),
user_id: Optional[str] = typer.Option(None, "--user-id", "-u", help="User ID for scoping (required for mem0 backend)"),
agent_id: Optional[str] = typer.Option(None, "--agent-id", "-a", help="Agent ID for scoping"),
run_id: Optional[str] = typer.Option(None, "--run-id", "-r", help="Run ID for scoping"),
backend: str = typer.Option("mem0", "--backend", "-b", help="Knowledge backend: mem0, chroma, internal"),
config: Optional[Path] = typer.Option(None, "--config", "-f", help="Config file path"),
verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
profile: bool = typer.Option(False, "--profile", help="Enable performance profiling"),
profile_out: Optional[Path] = typer.Option(None, "--profile-out", help="Save profile to JSON file"),
profile_top: int = typer.Option(20, "--profile-top", help="Top N items in profile"),
):
"""
Index documents into a knowledge base.
This is the canonical command for adding documents to a knowledge base.
Use `praisonai rag query` to answer questions using the indexed knowledge.
Examples:
praisonai knowledge index ./docs --user-id myuser
praisonai knowledge index paper.pdf --collection research --agent-id research_agent
praisonai knowledge index ./data --backend chroma --profile
"""
from rich.console import Console
from rich.progress import Progress, SpinnerColumn, TextColumn
console = Console()
with knowledge_profiler(profile, profile_out, profile_top) as profile_data:
try:
# Lazy import to avoid startup cost
from praisonaiagents.knowledge import Knowledge
# Validate scope for mem0 backend
if backend == "mem0" and not any([user_id, agent_id, run_id]):
console.print("[yellow]Warning:[/yellow] mem0 backend requires at least one scope identifier.")
console.print("Use --user-id, --agent-id, or --run-id to scope your knowledge.")
console.print("Defaulting to user_id='default_user'")
user_id = "default_user"
# Build config based on backend
if backend == "chroma":
knowledge_config = {
"vector_store": {
"provider": "chroma",
"config": {
"collection_name": collection,
"path": f"./.praison/knowledge/{collection}",
}
}
}
else:
# mem0 or internal backend
knowledge_config = {
"vector_store": {
"provider": "qdrant",
"config": {
"collection_name": collection,
}
}
}
# Load config file if provided
if config and config.exists():
import yaml
with open(config) as f:
file_config = yaml.safe_load(f)
if "knowledge" in file_config:
knowledge_config.update(file_config["knowledge"])
# Initialize Knowledge
knowledge = Knowledge(config=knowledge_config, verbose=verbose)
# Index sources with scope identifiers
with Progress(
SpinnerColumn(),
TextColumn("[progress.description]{task.description}"),
console=console,
transient=True,
) as progress:
for source in sources:
task = progress.add_task(f"Indexing {source}...", total=None)
try:
result = knowledge.add(
source,
user_id=user_id,
agent_id=agent_id,
run_id=run_id,
)
count = len(result.get("results", [])) if isinstance(result, dict) else 0
console.print(f"[green]✓[/green] Indexed {source}: {count} chunks")
except Exception as e:
console.print(f"[red]✗[/red] Failed to index {source}: {e}")
progress.remove_task(task)
console.print(f"\n[bold green]Indexing complete![/bold green] Collection: {collection}")
if profile_data:
profile_data["command"] = "knowledge index"
profile_data["collection"] = collection
profile_data["sources"] = sources
except ImportError as e:
console.print(f"[red]Error:[/red] Missing dependency: {e}")
console.print("Install with: pip install 'praisonaiagents[knowledge]'")
raise typer.Exit(1)
except Exception as e:
console.print(f"[red]Error:[/red] {e}")
raise typer.Exit(1)
@app.command("search")
def knowledge_search(
query: str = typer.Argument(..., help="Search query"),
collection: str = typer.Option("default", "--collection", "-c", help="Collection to search"),
user_id: Optional[str] = typer.Option(None, "--user-id", "-u", help="User ID for scoping (required for mem0 backend)"),
agent_id: Optional[str] = typer.Option(None, "--agent-id", "-a", help="Agent ID for scoping"),
run_id: Optional[str] = typer.Option(None, "--run-id", "-r", help="Run ID for scoping"),
backend: str = typer.Option("mem0", "--backend", "-b", help="Knowledge backend: mem0, chroma, internal"),
top_k: int = typer.Option(5, "--top-k", "-k", help="Number of results to retrieve"),
hybrid: bool = typer.Option(False, "--hybrid", help="Use hybrid retrieval (dense + BM25)"),
config: Optional[Path] = typer.Option(None, "--config", "-f", help="Config file path"),
verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
profile: bool = typer.Option(False, "--profile", help="Enable performance profiling"),
profile_out: Optional[Path] = typer.Option(None, "--profile-out", help="Save profile to JSON file"),
profile_top: int = typer.Option(20, "--profile-top", help="Top N items in profile"),
):
"""
Search/retrieve from a knowledge base (no LLM generation).
Returns raw search results without generating an answer.
For answers with citations, use `praisonai rag query`.
Examples:
praisonai knowledge search "capital of France" --user-id myuser
praisonai knowledge search "main findings" --collection research --top-k 10
praisonai knowledge search "Python tutorial" --hybrid --backend chroma
"""
from rich.console import Console
from rich.table import Table
console = Console()
with knowledge_profiler(profile, profile_out, profile_top) as profile_data:
try:
from praisonaiagents.knowledge import Knowledge
# Validate scope for mem0 backend
if backend == "mem0" and not any([user_id, agent_id, run_id]):
console.print("[yellow]Warning:[/yellow] mem0 backend requires at least one scope identifier.")
console.print("Use --user-id, --agent-id, or --run-id to scope your search.")
console.print("Defaulting to user_id='default_user'")
user_id = "default_user"
# Build config based on backend
if backend == "chroma":
knowledge_config = {
"vector_store": {
"provider": "chroma",
"config": {
"collection_name": collection,
"path": f"./.praison/knowledge/{collection}",
}
}
}
else:
# mem0 or internal backend
knowledge_config = {
"vector_store": {
"provider": "qdrant",
"config": {
"collection_name": collection,
}
}
}
# Add hybrid retrieval config if enabled
if hybrid:
knowledge_config["retrieval"] = {
"strategy": "hybrid",
}
# Load config file if provided
if config and config.exists():
import yaml
with open(config) as f:
file_config = yaml.safe_load(f)
if "knowledge" in file_config:
knowledge_config.update(file_config["knowledge"])
# Initialize and search
knowledge = Knowledge(config=knowledge_config, verbose=verbose)
# Use hybrid search if enabled, pass scope identifiers
if hybrid:
if verbose:
console.print("[dim]Using hybrid retrieval (dense + BM25)...[/dim]")
results = knowledge.search(
query, limit=top_k, hybrid=True,
user_id=user_id, agent_id=agent_id, run_id=run_id,
)
else:
results = knowledge.search(
query, limit=top_k,
user_id=user_id, agent_id=agent_id, run_id=run_id,
)
if not results:
console.print("[yellow]No results found.[/yellow]")
return
# Display results
table = Table(title=f"Search Results for: {query}")
table.add_column("#", style="dim", width=3)
table.add_column("Score", width=8)
table.add_column("Source", width=20)
table.add_column("Content", width=60)
result_list = results.get('results', results) if isinstance(results, dict) else results
for i, result in enumerate(result_list[:top_k], 1):
if isinstance(result, dict):
score = f"{result.get('score', 0):.3f}" if result.get('score') else "-"
# CRITICAL: Handle metadata=None from mem0
metadata = result.get('metadata') or {}
source = metadata.get('filename', metadata.get('source', '-'))
content = result.get('memory', result.get('text', ''))[:100] + "..."
else:
score = "-"
source = "-"
content = str(result)[:100] + "..."
table.add_row(str(i), score, source, content)
console.print(table)
if profile_data:
profile_data["command"] = "knowledge search"
profile_data["collection"] = collection
profile_data["query"] = query
profile_data["num_results"] = len(result_list)
except ImportError as e:
console.print(f"[red]Error:[/red] Missing dependency: {e}")
console.print("Install with: pip install 'praisonaiagents[knowledge]'")
raise typer.Exit(1)
except Exception as e:
console.print(f"[red]Error:[/red] {e}")
if verbose:
import traceback
console.print(traceback.format_exc())
raise typer.Exit(1)
@app.command("add")
def knowledge_add(
sources: List[str] = typer.Argument(..., help="Source files, directories, or URLs to index"),
collection: str = typer.Option("default", "--collection", "-c", help="Collection/knowledge base name"),
config: Optional[Path] = typer.Option(None, "--config", "-f", help="Config file path"),
verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
profile: bool = typer.Option(False, "--profile", help="Enable performance profiling"),
profile_out: Optional[Path] = typer.Option(None, "--profile-out", help="Save profile to JSON file"),
profile_top: int = typer.Option(20, "--profile-top", help="Top N items in profile"),
):
"""
Add documents to a knowledge base (alias for 'index').
This is a backward-compatible alias for `praisonai knowledge index`.
Examples:
praisonai knowledge add ./docs
praisonai knowledge add paper.pdf --collection research
"""
# Delegate to index command
knowledge_index(
sources=sources,
collection=collection,
config=config,
verbose=verbose,
profile=profile,
profile_out=profile_out,
profile_top=profile_top,
)
@app.command("list")
def knowledge_list(
verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
):
"""List available knowledge bases/collections."""
from rich.console import Console
from rich.table import Table
import os
console = Console()
# Check for knowledge directories
knowledge_dirs = ["./.praison/knowledge", "./.praison/rag", ".praison"]
collections = set()
for base_dir in knowledge_dirs:
if os.path.exists(base_dir):
for item in os.listdir(base_dir):
item_path = os.path.join(base_dir, item)
if os.path.isdir(item_path):
collections.add((item, base_dir))
if not collections:
console.print("[yellow]No knowledge bases found.[/yellow]")
console.print("Create one with: praisonai knowledge index ./docs --collection myknowledge")
return
table = Table(title="Knowledge Bases")
table.add_column("Collection", style="cyan")
table.add_column("Path", style="dim")
for name, path in sorted(collections):
table.add_row(name, os.path.join(path, name))
console.print(table)
@@ -1,120 +0,0 @@
"""
PraisonAI Langextract Commands.
CLI commands for rendering PraisonAI traces with langextract:
- `praisonai langextract view` - render existing JSONL to HTML
- `praisonai langextract render` - run workflow with langextract observability
"""
import typer
import webbrowser
from pathlib import Path
from typing import Optional
app = typer.Typer(name="langextract", help="Render PraisonAI traces with langextract.")
@app.command(name="view")
def view(
jsonl_path: Path = typer.Argument(..., help="Path to annotated-documents JSONL"),
output_html: Path = typer.Option("trace.html", "--output", "-o", help="Output HTML file path"),
no_open: bool = typer.Option(False, "--no-open", help="Don't open HTML in browser"),
):
"""Render an existing annotated-documents JSONL to an interactive HTML."""
try:
import langextract as lx # type: ignore
except ImportError:
typer.echo("Error: langextract is not installed. Install with: pip install 'praisonai[langextract]'", err=True)
raise typer.Exit(1)
if not jsonl_path.exists():
typer.echo(f"Error: JSONL file not found: {jsonl_path}", err=True)
raise typer.Exit(1)
try:
html = lx.visualize(str(jsonl_path))
html_text = html.data if hasattr(html, "data") else html
output_html.write_text(html_text, encoding="utf-8")
typer.echo(f"✅ Wrote {output_html}")
if not no_open:
webbrowser.open(f"file://{output_html.resolve()}")
except Exception as e:
typer.echo(f"Error: Failed to render HTML: {e}", err=True)
raise typer.Exit(1)
@app.command(name="render")
def render(
yaml_path: Path = typer.Argument(..., help="PraisonAI YAML workflow"),
output_html: Path = typer.Option("workflow.html", "--output", "-o", help="Output HTML file path"),
no_open: bool = typer.Option(False, "--no-open", help="Don't open HTML in browser"),
api_url: Optional[str] = typer.Option(None, "--api-url", help="API URL (if using remote API)"),
):
"""Run a workflow end-to-end with LangextractSink attached, then open the HTML."""
try:
import langextract # noqa: F401 — probe optional dep early for clear error
from praisonai.observability import LangextractSink, LangextractSinkConfig
from praisonaiagents.trace.protocol import TraceEmitter, set_default_emitter
from praisonai import PraisonAI
except ImportError as e:
typer.echo(
f"Error: Missing dependencies: {e}. "
"Install langextract with: pip install 'praisonai[langextract]'",
err=True,
)
raise typer.Exit(1) from e
if not yaml_path.exists():
typer.echo(f"Error: YAML file not found: {yaml_path}", err=True)
raise typer.Exit(1)
# Set up langextract observability
config = LangextractSinkConfig(
output_path=str(output_html),
auto_open=not no_open,
)
sink = LangextractSink(config=config)
# Set up trace emitter for the duration of the run
emitter = TraceEmitter(sink=sink, enabled=True)
set_default_emitter(emitter)
# Also bridge the context emitter so real agent runtime events
# (agent_start/end, tool_call_*, llm_response) are captured.
from praisonai.observability.langextract import LangextractSink
def warn_handler(msg: str):
# Warn user about bridge failure since this command specifically generates traces
typer.echo(f"Warning: {msg}", err=True)
LangextractSink.bridge_context_events(
sink=sink,
session_id="praisonai-langextract-render",
warn_callback=warn_handler
)
try:
# Run the workflow
praison = PraisonAI(agent_file=str(yaml_path))
if api_url:
praison.api_url = api_url.rstrip("/")
result = praison.main()
typer.echo(result)
except Exception as e:
typer.echo(f"Error: Workflow failed: {e}", err=True)
raise typer.Exit(1) from e
finally:
# Ensure sink is closed even if workflow fails
sink.close()
if output_html.exists():
typer.echo(f"✅ Trace rendered: {output_html}")
else:
typer.echo(
f"Error: Trace was not rendered to {output_html} (see logs for details)",
err=True,
)
raise typer.Exit(1)
@@ -1,984 +0,0 @@
"""PraisonAI Langfuse — Observability platform for LLM apps.
Provides `praisonai langfuse` CLI commands to manage local (Docker) and remote
Langfuse instances for observability and evaluation of agent workflows.
"""
import os
import json
import subprocess
import sys
import time
from datetime import datetime
from pathlib import Path
from typing import Optional
import typer
from rich.console import Console
from rich.table import Table
from rich.panel import Panel
from rich import box
app = typer.Typer(name="langfuse", help="🔍 Langfuse observability platform")
def _format_publishers(publishers):
"""Format Publishers field for display in status table."""
if isinstance(publishers, list) and publishers:
# Publishers is array of port mapping objects
ports = []
for pub in publishers:
if isinstance(pub, dict):
url = pub.get("URL", "")
target_port = pub.get("TargetPort", "")
published_port = pub.get("PublishedPort", "")
protocol = pub.get("Protocol", "tcp")
if published_port and target_port:
ports.append(f"{url}:{published_port}->{target_port}/{protocol}")
return ", ".join(ports) if ports else ""
elif isinstance(publishers, str):
return publishers # Fallback for string format
else:
return ""
@app.callback(invoke_without_command=True)
def langfuse_start(
ctx: typer.Context,
port: int = typer.Option(3000, "--port", "-p", help="Port to listen on"),
host: str = typer.Option("127.0.0.1", "--host", "-H", help="Host to bind to"),
detach: bool = typer.Option(True, "--detach/--no-detach", help="Run in background"),
data_dir: str = typer.Option(
None, "--data-dir", help="Data directory (default: ~/.praisonai/langfuse)"
),
email: str = typer.Option(
None, "--email", "-e", help="Initial admin email (default: [email protected])"
),
password: str = typer.Option(
None, "--password", "-pw", help="Initial admin password (default: admin12345)"
),
):
"""Start local Langfuse server (Docker).
Clones Langfuse repo and starts the full Docker Compose stack
with web UI, worker, PostgreSQL, ClickHouse, Redis, and MinIO.
Examples:
praisonai langfuse
praisonai langfuse --port 8080
praisonai langfuse --no-detach
praisonai langfuse --email [email protected] --password mypassword123
"""
if ctx.invoked_subcommand is not None:
return
console = Console()
# Check Docker availability
try:
subprocess.run(["docker", "--version"], capture_output=True, check=True)
subprocess.run(["docker", "compose", "version"], capture_output=True, check=True)
except (subprocess.CalledProcessError, FileNotFoundError):
console.print("[red]Docker or Docker Compose is not installed or not running.[/red]")
console.print("[yellow]Please install Docker and ensure the daemon is running.[/yellow]")
raise typer.Abort()
# Set up data directory
if data_dir is None:
data_dir = os.path.expanduser("~/.praisonai/langfuse")
data_path = Path(data_dir)
repo_path = data_path / "langfuse"
console.print(f"[blue]📁 Data directory: {data_path}[/blue]")
# Clone Langfuse repo if not present
if not repo_path.exists():
console.print("[yellow]📥 Cloning Langfuse repository...[/yellow]")
data_path.mkdir(parents=True, exist_ok=True)
try:
subprocess.run([
"git", "clone",
"https://github.com/langfuse/langfuse.git",
str(repo_path)
], check=True, cwd=data_path)
console.print("[green]✅ Repository cloned successfully[/green]")
except subprocess.CalledProcessError:
console.print("[red]❌ Failed to clone Langfuse repository[/red]")
raise typer.Abort()
else:
console.print("[green]✅ Langfuse repository already exists[/green]")
# Check if containers are already running
try:
result = subprocess.run([
"docker", "compose", "ps", "--format", "table"
], cwd=repo_path, capture_output=True, text=True)
if "langfuse-web" in result.stdout and "Up" in result.stdout:
console.print("[yellow]⚠️ Langfuse is already running[/yellow]")
console.print(f"[blue]🌐 Access UI: http://{host}:{port}[/blue]")
return
except subprocess.CalledProcessError:
pass
# Update docker-compose.yml port if different from default
if port != 3000:
compose_file = repo_path / "docker-compose.yml"
if compose_file.exists():
content = compose_file.read_text()
content = content.replace("3000:3000", f"{port}:3000")
compose_file.write_text(content)
# Set up initial credentials via .env file
env_file = repo_path / ".env"
env_vars = {}
# Read existing .env if present
if env_file.exists():
for line in env_file.read_text().strip().split('\n'):
if '=' in line and not line.startswith('#'):
key, value = line.split('=', 1)
env_vars[key.strip()] = value.strip()
# Set default or custom credentials
default_email = email or "[email protected]"
default_password = password or "admin12345"
default_name = "Admin User"
# Update with Langfuse init user environment variables
env_vars["LANGFUSE_INIT_USER_EMAIL"] = default_email
env_vars["LANGFUSE_INIT_USER_PASSWORD"] = default_password
env_vars["LANGFUSE_INIT_USER_NAME"] = default_name
env_vars["LANGFUSE_INIT_ORG_NAME"] = "Default Org"
env_vars["LANGFUSE_INIT_PROJECT_NAME"] = "Default Project"
# Write .env file
env_content = "# Langfuse Initial User Configuration\n"
env_content += f"LANGFUSE_INIT_USER_EMAIL={default_email}\n"
env_content += f"LANGFUSE_INIT_USER_PASSWORD={default_password}\n"
env_content += f"LANGFUSE_INIT_USER_NAME={default_name}\n"
env_content += f"LANGFUSE_INIT_ORG_NAME=Default Org\n"
env_content += f"LANGFUSE_INIT_PROJECT_NAME=Default Project\n"
env_content += "LANGFUSE_INIT_PROJECT_ID=default-project\n"
env_file.write_text(env_content)
console.print(f"[dim]📄 Initial user: {default_email}[/dim]")
# Start Docker Compose
console.print(f"[blue]🚀 Starting Langfuse on {host}:{port}[/blue]")
console.print("[dim]This may take a few minutes on first run (downloading images)...[/dim]")
cmd = ["docker", "compose", "up"]
if detach:
cmd.append("-d")
try:
if detach:
subprocess.run(cmd, cwd=repo_path, check=True)
console.print("[green]✅ Langfuse started successfully[/green]")
# Wait for health check
console.print("[yellow]⏳ Waiting for services to be ready...[/yellow]")
try:
import requests as requests_lib
except ImportError:
requests_lib = None
max_wait = 180 # 3 minutes
for i in range(max_wait):
try:
if requests_lib is None:
break
response = requests_lib.get(f"http://{host}:{port}", timeout=5)
if response.status_code < 500:
break
except Exception:
pass
time.sleep(1)
if i % 10 == 0:
console.print(f"[dim]Still waiting... ({i}s/{max_wait}s)[/dim]")
else:
console.print("[yellow]⚠️ Service may still be starting up[/yellow]")
console.print()
console.print("[bold green]🎉 Langfuse is ready![/bold green]")
console.print(f"[blue]🌐 Web UI: http://{host}:{port}[/blue]")
console.print(f"[dim]Login: {default_email} / {default_password[:3]}***[/dim]")
console.print()
console.print("[bold]Next steps:[/bold]")
console.print("1. Open the web UI and login")
console.print("2. Get API keys from Settings > API Keys")
console.print("3. Configure PraisonAI:")
console.print(f" [green]praisonai langfuse config --public-key pk-... --secret-key sk-...[/green]")
console.print("4. Test: [green]praisonai langfuse test[/green]")
else:
subprocess.run(cmd, cwd=repo_path)
except subprocess.CalledProcessError as e:
error_output = str(e)
if hasattr(e, 'stderr') and e.stderr:
error_output += e.stderr
if hasattr(e, 'stdout') and e.stdout:
error_output += e.stdout
# Check for port conflicts
from praisonai.cli.commands.port import _is_port_conflict_error, _extract_port_from_error, _show_port_conflict_help
if _is_port_conflict_error(error_output):
port = _extract_port_from_error(error_output)
if port:
_show_port_conflict_help(port, console)
else:
console.print(f"[red]❌ Failed to start Langfuse: Port conflict detected[/red]")
console.print("[yellow]Run [cyan]praisonai port list[/cyan] to see ports in use[/yellow]")
else:
console.print(f"[red]❌ Failed to start Langfuse: {e}[/red]")
console.print("[yellow]Check Docker logs with: docker compose logs[/yellow]")
raise typer.Abort()
except KeyboardInterrupt:
console.print("\n[yellow]⏹️ Stopping Langfuse...[/yellow]")
@app.command("stop")
def langfuse_stop(
data_dir: str = typer.Option(
None, "--data-dir", help="Data directory (default: ~/.praisonai/langfuse)"
),
):
"""Stop local Langfuse server."""
console = Console()
# Set up data directory
if data_dir is None:
data_dir = os.path.expanduser("~/.praisonai/langfuse")
repo_path = Path(data_dir) / "langfuse"
if not repo_path.exists():
console.print("[yellow]⚠️ Langfuse repository not found[/yellow]")
return
console.print("[yellow]⏹️ Stopping Langfuse...[/yellow]")
try:
subprocess.run(["docker", "compose", "down"], cwd=repo_path, check=True)
console.print("[green]✅ Langfuse stopped successfully[/green]")
except subprocess.CalledProcessError as e:
console.print(f"[red]❌ Failed to stop Langfuse: {e}[/red]")
raise typer.Abort()
@app.command("init")
def langfuse_init(
email: str = typer.Option(
"[email protected]", "--email", "-e", help="Admin email address"
),
password: str = typer.Option(
None, "--password", "-p", help="Admin password (will prompt if not provided)"
),
port: int = typer.Option(3000, "--port", "-p", help="Port to listen on"),
data_dir: str = typer.Option(
None, "--data-dir", help="Data directory (default: ~/.praisonai/langfuse)"
),
):
"""Initialize Langfuse with custom admin credentials.
First-time setup with configurable admin user. Sets up the Docker
environment and creates initial user account.
Examples:
praisonai langfuse init
praisonai langfuse init --email [email protected] --password mypass123
praisonai langfuse init --email [email protected] --port 8080
"""
console = Console()
# Prompt for password if not provided
if password is None:
password = typer.prompt("Enter admin password", hide_input=True)
password_confirm = typer.prompt("Confirm password", hide_input=True)
if password != password_confirm:
console.print("[red]❌ Passwords do not match[/red]")
raise typer.Abort()
# Validate password length (Langfuse requires 8+ characters)
if len(password) < 8:
console.print("[red]❌ Password must be at least 8 characters long[/red]")
raise typer.Abort()
console.print("[bold]🚀 Initializing Langfuse...[/bold]")
console.print(f"[dim]Email: {email}[/dim]")
console.print(f"[dim]Port: {port}[/dim]")
console.print()
# Reuse the start logic with explicit credentials
ctx = typer.Context(langfuse_start)
langfuse_start(
ctx=ctx,
port=port,
host="127.0.0.1",
detach=True,
data_dir=data_dir,
email=email,
password=password
)
@app.command("status")
def langfuse_status(
data_dir: str = typer.Option(
None, "--data-dir", help="Data directory (default: ~/.praisonai/langfuse)"
),
):
"""Check Langfuse server status."""
console = Console()
# Set up data directory
if data_dir is None:
data_dir = os.path.expanduser("~/.praisonai/langfuse")
repo_path = Path(data_dir) / "langfuse"
if not repo_path.exists():
console.print("[yellow]⚠️ Langfuse repository not found[/yellow]")
console.print("Run 'praisonai langfuse' to set up Langfuse")
return
try:
import requests as requests_lib
except ImportError:
requests_lib = None
# Check Docker containers
try:
result = subprocess.run([
"docker", "compose", "ps", "--format", "json"
], cwd=repo_path, capture_output=True, text=True, check=True)
if not result.stdout.strip():
console.print("[yellow]📊 No Langfuse containers running[/yellow]")
return
# Parse JSON output
containers = []
for line in result.stdout.strip().split('\n'):
if line:
containers.append(json.loads(line))
# Create status table
table = Table(title="Langfuse Services")
table.add_column("Service", style="cyan")
table.add_column("Status", style="green")
table.add_column("Ports", style="blue")
table.add_column("Health", style="yellow")
for container in containers:
status_color = "green" if container.get("State") == "running" else "red"
health = container.get("Health", "unknown")
table.add_row(
container.get("Service", "unknown"),
f"[{status_color}]{container.get('State', 'unknown')}[/{status_color}]",
_format_publishers(container.get("Publishers", [])),
health
)
console.print(table)
# Test API availability
web_running = any(
c.get("Service") == "langfuse-web" and c.get("State") == "running"
for c in containers
)
if web_running:
# Extract port from web service
web_container = next(
c for c in containers
if c.get("Service") == "langfuse-web"
)
# Extract published port from Publishers array
publishers = web_container.get("Publishers", [])
port = "3000"
if isinstance(publishers, list) and publishers:
# Publishers is array of {"URL": "0.0.0.0", "TargetPort": 80, "PublishedPort": 8080, "Protocol": "tcp"}
for pub in publishers:
if isinstance(pub, dict) and pub.get("TargetPort") == 3000:
port = str(pub.get("PublishedPort", 3000))
break
elif isinstance(publishers, str) and ":" in publishers:
# Fallback for older format or edge cases
import re
match = re.search(r":(\d+)->", publishers)
port = match.group(1) if match else "3000"
try:
if requests_lib is not None:
response = requests_lib.get(f"http://127.0.0.1:{port}", timeout=5)
if response.status_code < 500:
console.print(f"[green]🌐 Web UI available: http://127.0.0.1:{port}[/green]")
else:
console.print(f"[yellow]🌐 Web UI starting: http://127.0.0.1:{port}[/yellow]")
else:
console.print(f"[blue]🌐 Web UI: http://127.0.0.1:{port}[/blue]")
except Exception:
console.print(f"[red]🌐 Web UI not responding: http://127.0.0.1:{port}[/red]")
except subprocess.CalledProcessError:
console.print("[red]❌ Failed to check container status[/red]")
except Exception as e:
console.print(f"[red]❌ Error: {e}[/red]")
@app.command("config")
def langfuse_config(
public_key: str = typer.Option(None, "--public-key", "-pk", help="Langfuse public key"),
secret_key: str = typer.Option(None, "--secret-key", "-sk", help="Langfuse secret key"),
host: str = typer.Option(None, "--host", help="Langfuse host URL"),
show: bool = typer.Option(False, "--show", help="Show current configuration"),
):
"""Configure Langfuse connection credentials."""
console = Console()
config_dir = Path.home() / ".praisonai"
config_file = config_dir / "langfuse.env"
if show:
# Show current configuration
if config_file.exists():
console.print(f"[blue]📄 Configuration file: {config_file}[/blue]")
content = config_file.read_text()
table = Table(title="Langfuse Configuration")
table.add_column("Setting", style="cyan")
table.add_column("Value", style="green")
for line in content.strip().split('\n'):
if '=' in line and not line.startswith('#'):
key, value = line.split('=', 1)
# Mask secret key for security
if "SECRET" in key:
value = value[:8] + "..." if len(value) > 8 else "***"
table.add_row(key, value)
console.print(table)
# Also show env vars
env_vars = []
for var in ["LANGFUSE_PUBLIC_KEY", "LANGFUSE_SECRET_KEY", "LANGFUSE_HOST", "LANGFUSE_BASE_URL"]:
value = os.environ.get(var)
if value:
if "SECRET" in var:
value = value[:8] + "..." if len(value) > 8 else "***"
env_vars.append((var, value))
if env_vars:
console.print("\n[blue]🌍 Environment Variables:[/blue]")
for var, value in env_vars:
console.print(f" {var}={value}")
else:
console.print("[yellow]⚠️ No configuration file found[/yellow]")
return
# Set configuration
if not any([public_key, secret_key, host]):
console.print("[yellow]ℹ️ No configuration options provided[/yellow]")
console.print()
console.print("[bold]Quick Setup Guide:[/bold]")
console.print()
console.print("1. [bold cyan]Get your API keys from Langfuse Cloud:[/bold cyan]")
console.print(" [blue]https://cloud.langfuse.com[/blue]")
console.print(" → Sign up → Create project → Project Settings → API Keys")
console.print()
console.print("2. [bold cyan]Configure PraisonAI:[/bold cyan]")
console.print(" [dim]praisonai langfuse config[/dim]")
console.print(" [dim]--public-key pk-...[/dim]")
console.print(" [dim]--secret-key sk-...[/dim]")
console.print()
console.print("3. [bold cyan]Verify your setup:[/bold cyan]")
console.print(" [green]praisonai langfuse test[/green]")
console.print()
console.print("[dim]Use --show to view current configuration[/dim]")
raise typer.Abort()
# Read existing config
config_data = {}
if config_file.exists():
content = config_file.read_text()
for line in content.strip().split('\n'):
if '=' in line and not line.startswith('#'):
key, value = line.split('=', 1)
config_data[key] = value
# Update with new values
if public_key:
config_data["LANGFUSE_PUBLIC_KEY"] = public_key
if secret_key:
config_data["LANGFUSE_SECRET_KEY"] = secret_key
if host:
config_data["LANGFUSE_HOST"] = host
# Write config
config_dir.mkdir(exist_ok=True)
content_lines = [
"# Langfuse Configuration",
"# Generated by PraisonAI CLI",
""
]
for key, value in config_data.items():
content_lines.append(f"{key}={value}")
config_file.write_text('\n'.join(content_lines) + '\n')
console.print(f"[green]✅ Configuration saved to {config_file}[/green]")
console.print("\n[blue]💡 To use this configuration, either:[/blue]")
console.print(f" 1. Source it: source {config_file}")
console.print(" 2. Export variables manually")
console.print(" 3. Set them in your shell profile")
@app.command("connect")
def langfuse_connect(
public_key: str = typer.Option(..., "--public-key", "-pk", help="Langfuse public key"),
secret_key: str = typer.Option(..., "--secret-key", "-sk", help="Langfuse secret key"),
host: str = typer.Option("https://cloud.langfuse.com", "--host", help="Langfuse host URL"),
):
"""Configure connection to remote Langfuse instance."""
return langfuse_config(public_key=public_key, secret_key=secret_key, host=host)
@app.command("test")
def langfuse_test():
"""Emit a test trace to verify Langfuse connectivity."""
console = Console()
try:
from praisonai.observability.langfuse import LangfuseSink, LangfuseSinkConfig
from praisonaiagents.trace.protocol import ActionEvent, ActionEventType, TraceEmitter
import time
except ImportError as e:
console.print(f"[red]❌ Import error: {e}[/red]")
console.print("[yellow]Install with: pip install praisonai[langfuse][/yellow]")
raise typer.Abort()
# Load configuration from env vars and ~/.praisonai/langfuse.env if present
config_file = Path.home() / ".praisonai" / "langfuse.env"
if config_file.exists():
# Load env file into current process environment
with open(config_file, 'r') as f:
for line in f:
line = line.strip()
if line and not line.startswith('#') and '=' in line:
key, value = line.split('=', 1)
os.environ[key.strip()] = value.strip()
console.print(f"[dim]📄 Loaded config from {config_file}[/dim]")
config = LangfuseSinkConfig()
if not config.enabled:
console.print("[yellow]⚠️ Langfuse credentials not configured[/yellow]")
console.print()
console.print("[bold]How to get your Langfuse API keys:[/bold]")
console.print("1. Go to [blue]https://cloud.langfuse.com[/blue] and sign up/login")
console.print("2. Create a new project (or open existing)")
console.print("3. Go to Project Settings → API Keys")
console.print("4. Click 'Create new API keys'")
console.print()
console.print("[bold]Then configure PraisonAI:[/bold]")
console.print(" [green]praisonai langfuse config --public-key=pk-... --secret-key=sk-...[/green]")
console.print()
console.print("Or set environment variables:")
console.print(" [green]export LANGFUSE_PUBLIC_KEY=pk-...[/green]")
console.print(" [green]export LANGFUSE_SECRET_KEY=sk-...[/green]")
raise typer.Abort()
console.print("[blue]🧪 Testing Langfuse connectivity...[/blue]")
console.print(f"[dim]Host: {config.host}[/dim]")
console.print(f"[dim]Public Key: {config.public_key[:8]}...[/dim]")
try:
# Create sink and emitter
sink = LangfuseSink(config)
emitter = TraceEmitter(sink=sink, enabled=True)
# Emit test events
agent_name = "test-agent"
timestamp = time.time()
console.print("[yellow]📤 Emitting test events...[/yellow]")
# Agent start
emitter.agent_start(agent_name, metadata={"test": True, "cli": "praisonai langfuse test"})
# Tool call
emitter.tool_start("test_tool", {"query": "Hello, Langfuse!"}, agent_name=agent_name)
time.sleep(0.1) # Simulate work
emitter.tool_end(
"test_tool",
duration_ms=100,
status="ok",
result_summary="Test completed successfully",
agent_name=agent_name
)
# Output
emitter.output("Test trace emitted successfully!", agent_name=agent_name)
# Agent end
emitter.agent_end(agent_name, status="ok")
# Flush and close
emitter.flush()
emitter.close()
console.print("[green]✅ Test trace sent successfully![/green]")
console.print(f"[blue]🌐 Check your Langfuse dashboard: {config.host}[/blue]")
console.print("[dim]It may take a few seconds for the trace to appear[/dim]")
except Exception as e:
console.print(f"[red]❌ Test failed: {e}[/red]")
raise typer.Abort()
@app.command("version")
def langfuse_version():
"""Show Langfuse version information."""
console = Console()
try:
import langfuse
console.print(f"[bold]Langfuse SDK[/bold]: {langfuse.__version__}")
except ImportError:
console.print("[yellow]Langfuse SDK not installed[/yellow]")
console.print("[blue]Install with: pip install praisonai[langfuse][/blue]")
except AttributeError:
console.print("[yellow]Langfuse SDK version unknown[/yellow]")
# Check local server version if running
data_dir = os.path.expanduser("~/.praisonai/langfuse")
repo_path = Path(data_dir) / "langfuse"
if repo_path.exists():
try:
result = subprocess.run([
"git", "describe", "--tags", "--abbrev=0"
], cwd=repo_path, capture_output=True, text=True)
if result.returncode == 0:
version = result.stdout.strip()
console.print(f"[bold]Local Langfuse[/bold]: {version}")
except Exception:
pass
@app.command("traces")
def langfuse_traces(
limit: int = typer.Option(20, "--limit", "-n", help="Maximum number of traces to show"),
session_id: Optional[str] = typer.Option(None, "--session", "-s", help="Filter by session ID"),
agent: Optional[str] = typer.Option(None, "--agent", "-a", help="Filter by agent name"),
):
"""
List traces from Langfuse.
Fetches and displays traces from the Langfuse API without opening the web UI.
Shows trace ID, name, timestamp, session, and metadata.
Examples:
praisonai langfuse traces
praisonai langfuse traces --limit 10
praisonai langfuse traces --session abc-123
praisonai langfuse traces --agent MyAgent
"""
console = Console()
# Import the client
try:
from praisonai.cli.langfuse_client import LangfuseClient, LangfuseAPIError
except ImportError:
console.print("[red]❌ Langfuse client not available[/red]")
raise typer.Abort()
# Load client from config
try:
client = LangfuseClient.from_config_file()
except FileNotFoundError as e:
console.print(f"[red]❌ {e}[/red]")
console.print("[yellow]Run 'praisonai langfuse config --public-key ... --secret-key ...' first[/yellow]")
raise typer.Abort()
except ValueError as e:
console.print(f"[red]❌ {e}[/red]")
raise typer.Abort()
# Fetch traces
try:
console.print(f"[blue]📊 Fetching traces from {client.host}...[/blue]")
traces = client.get_traces(limit=limit, session_id=session_id, name=agent)
if not traces:
console.print("[yellow]No traces found[/yellow]")
if session_id:
console.print(f"[dim]Filter: session_id={session_id}[/dim]")
if agent:
console.print(f"[dim]Filter: agent={agent}[/dim]")
return
# Create table
table = Table(title=f"Recent Traces ({len(traces)})")
table.add_column("Trace ID", style="cyan", no_wrap=True)
table.add_column("Name", style="green")
table.add_column("Timestamp", style="blue")
table.add_column("Session", style="yellow")
table.add_column("Observations", style="magenta")
for trace in traces:
trace_id = trace.get("id", "unknown")[:12] + "..."
name = trace.get("name", "unnamed")
# Parse timestamp
ts = trace.get("timestamp", "")
if ts:
try:
dt = datetime.fromisoformat(ts.replace("Z", "+00:00"))
timestamp = dt.strftime("%Y-%m-%d %H:%M")
except Exception:
timestamp = str(ts)[:16]
else:
timestamp = "unknown"
session = trace.get("sessionId", "")[:8] + "..." if trace.get("sessionId") else "-"
obs_count = len(trace.get("observations", []))
table.add_row(trace_id, name, timestamp, session, str(obs_count))
console.print(table)
# Show hint for viewing details
if traces:
console.print(f"[dim]View details: praisonai langfuse show {traces[0].get('id')}[/dim]")
except LangfuseAPIError as e:
console.print(f"[red]❌ API Error: {e}[/red]")
if "401" in str(e):
console.print("[yellow]Check your LANGFUSE_PUBLIC_KEY and LANGFUSE_SECRET_KEY[/yellow]")
elif "connection" in str(e).lower():
console.print("[yellow]Ensure Langfuse is running: praisonai langfuse status[/yellow]")
raise typer.Abort()
except Exception as e:
console.print(f"[red]❌ Error: {e}[/red]")
raise typer.Abort()
@app.command("sessions")
def langfuse_sessions(
limit: int = typer.Option(20, "--limit", "-n", help="Maximum number of sessions to show"),
):
"""
List sessions from Langfuse.
Fetches and displays sessions (conversation threads) from the Langfuse API.
Shows session ID, creation time, and number of traces in each session.
Examples:
praisonai langfuse sessions
praisonai langfuse sessions --limit 10
"""
console = Console()
# Import the client
try:
from praisonai.cli.langfuse_client import LangfuseClient, LangfuseAPIError
except ImportError:
console.print("[red]❌ Langfuse client not available[/red]")
raise typer.Abort()
# Load client from config
try:
client = LangfuseClient.from_config_file()
except FileNotFoundError as e:
console.print(f"[red]❌ {e}[/red]")
console.print("[yellow]Run 'praisonai langfuse config --public-key ... --secret-key ...' first[/yellow]")
raise typer.Abort()
except ValueError as e:
console.print(f"[red]❌ {e}[/red]")
raise typer.Abort()
# Fetch sessions
try:
console.print(f"[blue]📊 Fetching sessions from {client.host}...[/blue]")
sessions = client.get_sessions(limit=limit)
if not sessions:
console.print("[yellow]No sessions found[/yellow]")
return
# Create table
table = Table(title=f"Sessions ({len(sessions)})")
table.add_column("Session ID", style="cyan", no_wrap=True)
table.add_column("Created", style="blue")
table.add_column("Traces", style="green", justify="right")
for session in sessions:
session_id = session.get("id", "unknown")[:16] + "..."
# Parse timestamp
ts = session.get("createdAt", "")
if ts:
try:
dt = datetime.fromisoformat(ts.replace("Z", "+00:00"))
created = dt.strftime("%Y-%m-%d %H:%M")
except Exception:
created = str(ts)[:16]
else:
created = "unknown"
trace_count = session.get("traceCount", len(session.get("traces", [])))
table.add_row(session_id, created, str(trace_count))
console.print(table)
# Show hint
if sessions:
console.print(f"[dim]View traces in session: praisonai langfuse traces --session {sessions[0].get('id')}[/dim]")
except LangfuseAPIError as e:
console.print(f"[red]❌ API Error: {e}[/red]")
if "401" in str(e):
console.print("[yellow]Check your LANGFUSE_PUBLIC_KEY and LANGFUSE_SECRET_KEY[/yellow]")
elif "connection" in str(e).lower():
console.print("[yellow]Ensure Langfuse is running: praisonai langfuse status[/yellow]")
raise typer.Abort()
except Exception as e:
console.print(f"[red]❌ Error: {e}[/red]")
raise typer.Abort()
@app.command("show")
def langfuse_show(
trace_id: str = typer.Argument(..., help="Trace ID to display"),
):
"""
Show detailed information about a specific trace.
Displays full trace details including metadata, input/output, and all
observations (spans, events, generations) without opening the web UI.
Examples:
praisonai langfuse show trace-abc-123
praisonai langfuse show --help
"""
console = Console()
# Import the client
try:
from praisonai.cli.langfuse_client import LangfuseClient, LangfuseAPIError
except ImportError:
console.print("[red]❌ Langfuse client not available[/red]")
raise typer.Abort()
# Load client from config
try:
client = LangfuseClient.from_config_file()
except FileNotFoundError as e:
console.print(f"[red]❌ {e}[/red]")
console.print("[yellow]Run 'praisonai langfuse config --public-key ... --secret-key ...' first[/yellow]")
raise typer.Abort()
except ValueError as e:
console.print(f"[red]❌ {e}[/red]")
raise typer.Abort()
# Fetch trace details
try:
console.print(f"[blue]🔍 Fetching trace {trace_id}...[/blue]")
trace = client.get_trace(trace_id)
# Display trace info
console.print()
console.print(Panel(f"[bold cyan]Trace: {trace.get('name', 'unnamed')}[/bold cyan]",
subtitle=f"ID: {trace_id}"))
# Metadata
meta_table = Table(show_header=False, box=None)
meta_table.add_column("Key", style="yellow")
meta_table.add_column("Value", style="white")
# Parse timestamp
ts = trace.get("timestamp", "")
if ts:
try:
dt = datetime.fromisoformat(ts.replace("Z", "+00:00"))
timestamp = dt.strftime("%Y-%m-%d %H:%M:%S")
except Exception:
timestamp = str(ts)
else:
timestamp = "unknown"
meta_table.add_row("Timestamp:", timestamp)
meta_table.add_row("Session:", trace.get("sessionId", "-"))
meta_table.add_row("User:", trace.get("userId", "-"))
if trace.get("metadata"):
meta_str = json.dumps(trace.get("metadata"), indent=2)
meta_table.add_row("Metadata:", meta_str)
console.print(meta_table)
# Observations
observations = trace.get("observations", [])
if observations:
console.print()
console.print(f"[bold]Observations ({len(observations)}):[/bold]")
obs_table = Table()
obs_table.add_column("Type", style="cyan")
obs_table.add_column("Name", style="green")
obs_table.add_column("Start Time", style="blue")
obs_table.add_column("Status", style="yellow")
for obs in observations:
obs_type = obs.get("type", "unknown")
name = obs.get("name", "unnamed")
# Parse start time
start_ts = obs.get("startTime", "")
if start_ts:
try:
dt = datetime.fromisoformat(start_ts.replace("Z", "+00:00"))
start_time = dt.strftime("%H:%M:%S")
except Exception:
start_time = str(start_ts)[11:19]
else:
start_time = "-"
status = obs.get("level", "DEFAULT")
if obs.get("statusMessage"):
status = f"{status} ({obs.get('statusMessage')})"
obs_table.add_row(obs_type, name, start_time, status)
console.print(obs_table)
# Input/Output (if available)
if trace.get("input"):
console.print()
console.print("[bold]Input:[/bold]")
input_str = json.dumps(trace.get("input"), indent=2)
console.print(f"[dim]{input_str[:500]}[/dim]")
if trace.get("output"):
console.print()
console.print("[bold]Output:[/bold]")
output_str = json.dumps(trace.get("output"), indent=2)
console.print(f"[dim]{output_str[:500]}[/dim]")
console.print()
console.print(f"[dim]View in UI: {client.host}/trace/{trace_id}[/dim]")
except LangfuseAPIError as e:
if "404" in str(e) or "not found" in str(e).lower():
console.print(f"[red]❌ Trace not found: {trace_id}[/red]")
else:
console.print(f"[red]❌ API Error: {e}[/red]")
raise typer.Abort()
except Exception as e:
console.print(f"[red]❌ Error: {e}[/red]")
raise typer.Abort()
@@ -1,659 +0,0 @@
"""
Managed Agents command group for PraisonAI CLI.
Provides commands for Anthropic Managed Agents (cloud-hosted agent backend).
Sub-command groups:
praisonai managed "prompt" — run a single prompt
praisonai managed multi — interactive multi-turn chat
praisonai managed sessions ... — list / get / resume sessions
praisonai managed agents ... — list / get / update agents
praisonai managed envs ... — list / get environments
praisonai managed ids ... — save / restore / show ID snapshots
"""
import json
import pathlib
from typing import Optional
import typer
app = typer.Typer(help="Managed Agents (Anthropic cloud or local provider)")
# ── sub-app registrations (defined below, registered after) ──
sessions_app = typer.Typer(help="Manage sessions")
agents_app = typer.Typer(help="Manage agents")
envs_app = typer.Typer(help="Manage environments")
ids_app = typer.Typer(help="Save / restore assigned IDs")
@app.callback()
def managed_callback(ctx: typer.Context):
"""Managed Agents — Anthropic cloud-hosted backend.
Run a prompt:
praisonai managed run "Say hello"
praisonai managed run --stream "Explain decorators"
Manage resources:
praisonai managed sessions list agent_01...
praisonai managed agents list
praisonai managed envs list
praisonai managed ids show
"""
def _build_managed(
provider: Optional[str] = None,
model: Optional[str] = None,
system: Optional[str] = None,
name: Optional[str] = None,
packages: Optional[str] = None,
networking: bool = True,
database_url: Optional[str] = None,
):
"""Build a ManagedAgent instance from CLI arguments."""
try:
from praisonai import ManagedAgent, ManagedConfig
except ImportError:
typer.echo("Error: praisonai with managed agents support is required.")
typer.echo("Install with: pip install 'praisonai[managed]'")
raise typer.Exit(1)
# Build DB adapter if --db URL was provided
db_adapter = None
if database_url:
from praisonai import DB
db_adapter = DB(database_url=database_url)
# Determine effective provider
resolved_provider = provider
if resolved_provider is None:
import os
if os.getenv("ANTHROPIC_API_KEY") or os.getenv("CLAUDE_API_KEY"):
resolved_provider = "anthropic"
else:
resolved_provider = "local"
if resolved_provider == "anthropic":
cfg_kwargs = {}
if model:
cfg_kwargs["model"] = model
if system:
cfg_kwargs["system"] = system
if name:
cfg_kwargs["name"] = name
if packages:
cfg_kwargs["packages"] = {"pip": [p.strip() for p in packages.split(",")]}
if not networking:
cfg_kwargs["networking"] = {"type": "limited"}
config = ManagedConfig(**cfg_kwargs) if cfg_kwargs else None
return ManagedAgent(provider="anthropic", config=config, db=db_adapter)
else:
from praisonai import LocalManagedConfig
cfg_kwargs = {}
if model:
cfg_kwargs["model"] = model
if system:
cfg_kwargs["system"] = system
if name:
cfg_kwargs["name"] = name
if packages:
cfg_kwargs["packages"] = {"pip": [p.strip() for p in packages.split(",")]}
if not networking:
cfg_kwargs["networking"] = {"type": "limited"}
config = LocalManagedConfig(**cfg_kwargs) if cfg_kwargs else None
return ManagedAgent(provider=resolved_provider, config=config, db=db_adapter)
@app.command("run")
def managed_main(
prompt: str = typer.Argument(..., help="Prompt to send to the managed agent"),
model: Optional[str] = typer.Option(None, "--model", "-m", help="Model to use"),
system: Optional[str] = typer.Option(None, "--system", "-s", help="System prompt"),
name: Optional[str] = typer.Option(None, "--name", "-n", help="Agent name"),
stream: bool = typer.Option(False, "--stream", help="Stream response token by token"),
packages: Optional[str] = typer.Option(None, "--packages", "-p", help="Comma-separated pip packages to install"),
networking: bool = typer.Option(True, "--networking/--no-networking", help="Enable unrestricted networking"),
provider: Optional[str] = typer.Option(None, "--provider", help="Provider: anthropic, local, openai, ollama, gemini (auto-detects if not set)"),
database_url: Optional[str] = typer.Option(None, "--db", help="Database URL for persistence (e.g. sqlite:///data.db, postgresql://...)"),
verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
):
"""Run a single prompt on a managed agent.
Auto-detects provider: Anthropic if ANTHROPIC_API_KEY is set, local otherwise.
Use --provider to override.
Examples:
praisonai managed run "Say hello"
praisonai managed run "Write fibonacci" --model claude-sonnet-4-6
praisonai managed run "Analyze data" --provider local --model gpt-4o
praisonai managed run --stream --provider ollama --model llama3 "Explain decorators"
"""
managed = _build_managed(provider=provider, model=model, system=system, name=name, packages=packages, networking=networking, database_url=database_url)
from praisonai import Agent
agent = Agent(name=name or "managed-agent", backend=managed)
if verbose:
typer.echo(f"Provider: {managed.provider}")
typer.echo(f"Model: {managed._cfg.get('model', 'unknown')}")
typer.echo(f"System: {managed._cfg.get('system', 'default')}")
if packages:
typer.echo(f"Packages: {packages}")
if database_url:
typer.echo(f"DB: {database_url}")
typer.echo("")
result = agent.start(prompt, stream=stream)
if result and not stream:
print(result)
if verbose:
typer.echo(f"\nAgent ID: {managed.agent_id}")
typer.echo(f"Session ID: {getattr(managed, 'session_id', None)}")
typer.echo(f"Input tokens: {managed.total_input_tokens}")
typer.echo(f"Output tokens: {managed.total_output_tokens}")
@app.command("multi")
def managed_multi(
model: Optional[str] = typer.Option(None, "--model", "-m", help="Model to use"),
system: Optional[str] = typer.Option(None, "--system", "-s", help="System prompt"),
name: Optional[str] = typer.Option(None, "--name", "-n", help="Agent name"),
provider: Optional[str] = typer.Option(None, "--provider", help="Provider: anthropic, local, openai, ollama, gemini"),
database_url: Optional[str] = typer.Option(None, "--db", help="Database URL for persistence (e.g. sqlite:///data.db, postgresql://...)"),
verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
):
"""
Start multi-turn conversation with a managed agent.
Examples:
praisonai managed multi
praisonai managed multi --model claude-sonnet-4-6
praisonai managed multi --provider local --model gpt-4o
"""
managed = _build_managed(provider=provider, model=model, system=system, name=name, database_url=database_url)
from praisonai import Agent
agent = Agent(name=name or "managed-agent", backend=managed)
typer.echo(f"Managed Agent multi-turn session — provider: {managed.provider} (type 'exit' to stop)")
typer.echo(f"Model: {managed._cfg.get('model', 'default')}")
if database_url:
typer.echo(f"DB: {database_url}")
typer.echo("")
turn = 0
while True:
try:
user_input = input("You: ").strip()
except (EOFError, KeyboardInterrupt):
typer.echo("\nSession ended.")
break
if user_input.lower() in ("exit", "quit", "q"):
typer.echo("Session ended.")
break
if not user_input:
continue
turn += 1
result = agent.start(user_input, stream=True)
if result:
print(f"\nAgent: {result}\n")
if verbose:
typer.echo(f"\nTurns: {turn}")
typer.echo(f"Input tokens: {managed.total_input_tokens}")
typer.echo(f"Output tokens: {managed.total_output_tokens}")
# ─────────────────────────────────────────────────────────────────────────────
# sessions sub-commands
# ─────────────────────────────────────────────────────────────────────────────
def _get_client():
"""Return an Anthropic client using env API key."""
try:
import anthropic
except ImportError:
import sys
typer.echo(
"Error: the anthropic package is not installed for the Python running this CLI.\n"
f" Interpreter: {sys.executable}\n"
"Fix either:\n"
" • pip install 'anthropic>=0.94.0' into that environment, or\n"
" • use the same interpreter that already has anthropic, e.g.\n"
" python -m praisonai managed envs list"
)
raise typer.Exit(1)
import os
api_key = os.getenv("ANTHROPIC_API_KEY") or os.getenv("CLAUDE_API_KEY")
if not api_key:
typer.echo("Error: ANTHROPIC_API_KEY not set.")
raise typer.Exit(1)
return anthropic.Anthropic(api_key=api_key)
@sessions_app.command("list")
def sessions_list(
agent_id: str = typer.Argument(..., help="Agent ID (agent_01...)"),
limit: int = typer.Option(10, "--limit", "-l", help="Max sessions to return"),
):
"""List sessions for a given agent ID.
Example:
praisonai managed sessions list agent_01AbCdEf
"""
client = _get_client()
sessions = client.beta.sessions.list(agent_id=agent_id, limit=limit)
rows = getattr(sessions, "data", sessions)
if not rows:
typer.echo("No sessions found.")
return
typer.echo(f"{'ID':<40} {'STATUS':<10} TITLE")
typer.echo("-" * 70)
for s in rows:
typer.echo(f"{s.id:<40} {getattr(s,'status',''):<10} {getattr(s,'title','')}")
@sessions_app.command("get")
def sessions_get(
session_id: str = typer.Argument(..., help="Session ID (sesn_01...)"),
):
"""Get details and usage for a session.
Example:
praisonai managed sessions get sesn_01AbCdEf
"""
client = _get_client()
sess = client.beta.sessions.retrieve(session_id)
typer.echo(f"ID : {sess.id}")
typer.echo(f"Status : {getattr(sess, 'status', 'unknown')}")
typer.echo(f"Title : {getattr(sess, 'title', '')}")
usage = getattr(sess, "usage", None)
if usage:
typer.echo(f"Input tokens : {getattr(usage, 'input_tokens', 0)}")
typer.echo(f"Output tokens: {getattr(usage, 'output_tokens', 0)}")
@sessions_app.command("resume")
def sessions_resume(
session_id: str = typer.Argument(..., help="Session ID to resume (sesn_01...)"),
prompt: str = typer.Argument(..., help="Prompt to send to the resumed session"),
stream: bool = typer.Option(True, "--stream/--no-stream", help="Stream response"),
model: Optional[str] = typer.Option(None, "--model", "-m", help="Model (only used if agent needs re-creating)"),
system: Optional[str] = typer.Option(None, "--system", "-s", help="System prompt override"),
):
"""Resume an existing session by ID and send a prompt.
Example:
praisonai managed sessions resume sesn_01AbCdEf "Continue the task"
praisonai managed sessions resume sesn_01AbCdEf "What did we do?" --stream
"""
try:
from praisonai import Agent, ManagedAgent, ManagedConfig
except ImportError:
typer.echo("Error: pip install praisonai")
raise typer.Exit(1)
cfg_kwargs = {}
if model:
cfg_kwargs["model"] = model
if system:
cfg_kwargs["system"] = system
managed = ManagedAgent(config=ManagedConfig(**cfg_kwargs) if cfg_kwargs else None)
managed.resume_session(session_id)
agent = Agent(name="managed-agent", backend=managed)
result = agent.start(prompt, stream=stream)
if result and not stream:
print(result)
@sessions_app.command("delete")
def sessions_delete(
session_id: str = typer.Argument(..., help="Session ID to delete (sesn_01...)"),
yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt"),
):
"""Delete a session.
Example:
praisonai managed sessions delete sesn_01AbCdEf
praisonai managed sessions delete sesn_01AbCdEf --yes
"""
if not yes:
confirm = typer.confirm(f"Are you sure you want to delete session {session_id}?")
if not confirm:
typer.echo("Deletion cancelled.")
raise typer.Exit(0)
client = _get_client()
try:
client.beta.sessions.delete(session_id)
typer.echo(f"Session {session_id} deleted successfully.")
except Exception as e:
typer.echo(f"Error deleting session: {e}")
raise typer.Exit(1)
# ─────────────────────────────────────────────────────────────────────────────
# agents sub-commands
# ─────────────────────────────────────────────────────────────────────────────
@agents_app.command("list")
def agents_list(
limit: int = typer.Option(10, "--limit", "-l", help="Max agents to return"),
):
"""List managed agents on your Anthropic account.
Example:
praisonai managed agents list
praisonai managed agents list --limit 20
"""
client = _get_client()
agents = client.beta.agents.list(limit=limit)
rows = getattr(agents, "data", agents)
if not rows:
typer.echo("No agents found.")
return
typer.echo(f"{'ID':<40} {'VER':<5} {'MODEL':<25} NAME")
typer.echo("-" * 85)
for a in rows:
model_obj = getattr(a, 'model', '')
model_str = getattr(model_obj, 'id', str(model_obj))
typer.echo(
f"{a.id:<40} {str(getattr(a,'version','')):<5} "
f"{model_str:<25} {getattr(a,'name','')}"
)
@agents_app.command("get")
def agents_get(
agent_id: str = typer.Argument(..., help="Agent ID (agent_01...)"),
):
"""Get details for a specific agent.
Example:
praisonai managed agents get agent_01AbCdEf
"""
client = _get_client()
a = client.beta.agents.retrieve(agent_id)
typer.echo(f"ID : {a.id}")
typer.echo(f"Name : {getattr(a, 'name', '')}")
model_obj = getattr(a, 'model', '')
typer.echo(f"Model : {getattr(model_obj, 'id', str(model_obj))}")
typer.echo(f"Version: {getattr(a, 'version', '')}")
typer.echo(f"System : {getattr(a, 'system', '')[:120]}")
@agents_app.command("update")
def agents_update(
agent_id: str = typer.Argument(..., help="Agent ID (agent_01...)"),
name: Optional[str] = typer.Option(None, "--name", "-n", help="New agent name"),
system: Optional[str] = typer.Option(None, "--system", "-s", help="New system prompt"),
model: Optional[str] = typer.Option(None, "--model", "-m", help="New model"),
version: Optional[int] = typer.Option(None, "--version", help="Agent version (required by API)"),
):
"""Update an existing agent's name, system prompt, or model.
Example:
praisonai managed agents update agent_01AbCdEf --name "New Name" --version 1
praisonai managed agents update agent_01AbCdEf --system "You are a data analyst." --version 2
"""
client = _get_client()
kwargs = {}
if name:
kwargs["name"] = name
if system:
kwargs["system"] = system
if model:
kwargs["model"] = model
if version is not None:
kwargs["version"] = version
if not kwargs:
typer.echo("Nothing to update. Pass --name, --system, or --model.")
raise typer.Exit(0)
updated = client.beta.agents.update(agent_id, **kwargs)
typer.echo(f"Updated agent: {updated.id} (v{getattr(updated,'version','')})")
@agents_app.command("delete")
def agents_delete(
agent_id: str = typer.Argument(..., help="Agent ID to delete (agent_01...)"),
yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt"),
):
"""Delete an agent.
Example:
praisonai managed agents delete agent_01AbCdEf
praisonai managed agents delete agent_01AbCdEf --yes
"""
if not yes:
confirm = typer.confirm(f"Are you sure you want to delete agent {agent_id}?")
if not confirm:
typer.echo("Deletion cancelled.")
raise typer.Exit(0)
client = _get_client()
try:
client.beta.agents.delete(agent_id)
typer.echo(f"Agent {agent_id} deleted successfully.")
except Exception as e:
typer.echo(f"Error deleting agent: {e}")
raise typer.Exit(1)
# ─────────────────────────────────────────────────────────────────────────────
# envs sub-commands
# ─────────────────────────────────────────────────────────────────────────────
@envs_app.command("list")
def envs_list(
limit: int = typer.Option(10, "--limit", "-l", help="Max environments to return"),
):
"""List sandbox environments on your Anthropic account.
Example:
praisonai managed envs list
"""
client = _get_client()
envs = client.beta.environments.list(limit=limit)
rows = getattr(envs, "data", envs)
if not rows:
typer.echo("No environments found.")
return
typer.echo(f"{'ID':<40} {'STATUS':<12} NAME")
typer.echo("-" * 70)
for e in rows:
typer.echo(f"{e.id:<40} {getattr(e,'status',''):<12} {getattr(e,'name','')}")
@envs_app.command("get")
def envs_get(
env_id: str = typer.Argument(..., help="Environment ID (env_01...)"),
):
"""Get details for a specific environment.
Example:
praisonai managed envs get env_01AbCdEf
"""
client = _get_client()
e = client.beta.environments.retrieve(env_id)
typer.echo(f"ID : {e.id}")
typer.echo(f"Name : {getattr(e, 'name', '')}")
typer.echo(f"Status: {getattr(e, 'status', '')}")
cfg = getattr(e, "config", None)
if cfg:
typer.echo(f"Config: {cfg}")
@envs_app.command("update")
def envs_update(
env_id: str = typer.Argument(..., help="Environment ID (env_01...)"),
packages: Optional[str] = typer.Option(None, "--packages", "-p", help="Comma-separated pip packages"),
networking: Optional[str] = typer.Option(None, "--networking", help="Networking type: 'full' or 'limited'"),
):
"""Update an environment's configuration.
Example:
praisonai managed envs update env_01AbCdEf --packages "numpy,pandas"
praisonai managed envs update env_01AbCdEf --networking limited
"""
client = _get_client()
kwargs = {}
if packages:
pkg_list = [p.strip() for p in packages.split(",") if p.strip()]
if not pkg_list:
typer.echo("Error: --packages must include at least one package name")
raise typer.Exit(1)
kwargs["packages"] = {"pip": pkg_list}
if networking:
if networking not in ["full", "limited"]:
typer.echo("Error: --networking must be 'full' or 'limited'")
raise typer.Exit(1)
kwargs["networking"] = {"type": networking}
if not kwargs:
typer.echo("Nothing to update. Pass --packages or --networking.")
raise typer.Exit(0)
try:
updated = client.beta.environments.update(env_id, **kwargs)
typer.echo(f"Updated environment: {updated.id}")
except Exception as e:
typer.echo(f"Error updating environment: {e}")
raise typer.Exit(1)
@envs_app.command("delete")
def envs_delete(
env_id: str = typer.Argument(..., help="Environment ID to delete (env_01...)"),
yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt"),
):
"""Delete an environment.
Example:
praisonai managed envs delete env_01AbCdEf
praisonai managed envs delete env_01AbCdEf --yes
"""
if not yes:
confirm = typer.confirm(f"Are you sure you want to delete environment {env_id}?")
if not confirm:
typer.echo("Deletion cancelled.")
raise typer.Exit(0)
client = _get_client()
try:
client.beta.environments.delete(env_id)
typer.echo(f"Environment {env_id} deleted successfully.")
except Exception as e:
typer.echo(f"Error deleting environment: {e}")
raise typer.Exit(1)
# ─────────────────────────────────────────────────────────────────────────────
# ids sub-commands (save / restore / show — no Anthropic IDs are user-defined)
# ─────────────────────────────────────────────────────────────────────────────
_DEFAULT_IDS_FILE = pathlib.Path("managed_ids.json")
@ids_app.command("show")
def ids_show(
file: pathlib.Path = typer.Option(_DEFAULT_IDS_FILE, "--file", "-f", help="IDs file path"),
):
"""Show saved Anthropic-assigned IDs from a JSON file.
Example:
praisonai managed ids show
praisonai managed ids show --file /tmp/my_ids.json
"""
if not file.exists():
typer.echo(f"No IDs file found at {file}. Run a prompt first or use 'ids save'.")
raise typer.Exit(1)
ids = json.loads(file.read_text())
typer.echo(f"File: {file}")
for k, v in ids.items():
typer.echo(f" {k:<20}: {v}")
@ids_app.command("save")
def ids_save(
agent_id: str = typer.Argument(..., help="Agent ID (agent_01...)"),
environment_id: str = typer.Argument(..., help="Environment ID (env_01...)"),
session_id: str = typer.Argument(..., help="Session ID (sesn_01...)"),
agent_version: Optional[int] = typer.Option(None, "--version", help="Agent version"),
file: pathlib.Path = typer.Option(_DEFAULT_IDS_FILE, "--file", "-f", help="Output file path"),
):
"""Save Anthropic-assigned IDs to a JSON file for later restore.
Anthropic assigns all IDs — you cannot define your own.
Use this to persist them so you can skip re-creating resources.
Example:
praisonai managed ids save agent_01... env_01... sesn_01... --version 1
"""
ids = {
"agent_id": agent_id,
"agent_version": agent_version,
"environment_id": environment_id,
"session_id": session_id,
}
file.write_text(json.dumps(ids, indent=2))
typer.echo(f"Saved IDs to {file}:")
for k, v in ids.items():
typer.echo(f" {k}: {v}")
@ids_app.command("restore")
def ids_restore(
prompt: str = typer.Argument(..., help="Prompt to send after restoring IDs"),
file: pathlib.Path = typer.Option(_DEFAULT_IDS_FILE, "--file", "-f", help="IDs file to restore from"),
stream: bool = typer.Option(True, "--stream/--no-stream", help="Stream response"),
):
"""Restore saved IDs and run a prompt — skips creating agent/env/session.
Example:
praisonai managed ids restore "Continue the previous task"
praisonai managed ids restore "What did we do?" --file /tmp/my_ids.json
"""
if not file.exists():
typer.echo(f"No IDs file at {file}. Run 'praisonai managed ids save ...' first.")
raise typer.Exit(1)
try:
from praisonai import Agent, ManagedAgent
except ImportError:
typer.echo("Error: pip install praisonai")
raise typer.Exit(1)
ids = json.loads(file.read_text())
typer.echo(f"Restoring IDs from {file}:")
for k, v in ids.items():
typer.echo(f" {k}: {v}")
typer.echo("")
managed = ManagedAgent()
managed.restore_ids(ids)
agent = Agent(name="managed-agent", backend=managed)
result = agent.start(prompt, stream=stream)
if result and not stream:
print(result)
# ─────────────────────────────────────────────────────────────────────────────
# Register sub-apps onto main app
# ─────────────────────────────────────────────────────────────────────────────
app.add_typer(sessions_app, name="sessions")
app.add_typer(agents_app, name="agents")
app.add_typer(envs_app, name="envs")
app.add_typer(ids_app, name="ids")
@@ -1,763 +0,0 @@
"""
MCP command group for PraisonAI CLI.
Provides MCP (Model Context Protocol) server management.
"""
from typing import Optional
import typer
from ..output.console import get_output_controller
from ..configuration.loader import get_config_loader
app = typer.Typer(help="MCP server management")
@app.command("list")
def mcp_list(
json_output: bool = typer.Option(False, "--json", help="Output JSON"),
):
"""List configured MCP servers."""
from ..configuration.schema import MCPLocalConfig, MCPRemoteConfig
output = get_output_controller()
loader = get_config_loader()
config = loader.load()
servers = config.mcp.servers
if output.is_json_mode or json_output:
server_data = {}
for name, s in servers.items():
if isinstance(s, MCPRemoteConfig):
server_data[name] = {
"type": "remote",
"url": s.url,
"enabled": s.enabled,
}
else:
server_data[name] = {
"type": "local",
"command": s.command,
"args": s.args,
"enabled": s.enabled,
}
output.print_json({"servers": server_data})
return
if not servers:
output.print_info("No MCP servers configured")
output.print("\nAdd a server with: praisonai mcp add <name> <command>")
return
headers = ["Name", "Type", "Target", "Enabled"]
rows = []
for name, server in servers.items():
if isinstance(server, MCPRemoteConfig):
target = server.url
server_type = "remote"
else:
target = f"{server.command} {' '.join(server.args)}"
server_type = "local"
if len(target) > 45:
target = target[:42] + "..."
rows.append([name, server_type, target, "✓" if server.enabled else "✗"])
output.print_table(headers, rows, title="MCP Servers")
@app.command("add")
def mcp_add(
name: str = typer.Argument(..., help="Server name"),
command: str = typer.Argument(..., help="Command to run"),
args: Optional[str] = typer.Option(None, "--args", "-a", help="Command arguments (space-separated)"),
env: Optional[str] = typer.Option(None, "--env", "-e", help="Environment variables (KEY=VALUE,...)"),
):
"""Add an MCP server."""
output = get_output_controller()
loader = get_config_loader()
# Parse args
arg_list = args.split() if args else []
# Parse env
env_dict = {}
if env:
for pair in env.split(","):
if "=" in pair:
key, value = pair.split("=", 1)
env_dict[key.strip()] = value.strip()
# Build server config
server_config = {
"command": command,
"args": arg_list,
"env": env_dict,
"enabled": True,
}
# Save to config
loader.set(f"mcp.servers.{name}", server_config)
if output.is_json_mode:
output.print_json({"added": name, "config": server_config})
else:
output.print_success(f"Added MCP server: {name}")
@app.command("remove")
def mcp_remove(
name: str = typer.Argument(..., help="Server name to remove"),
confirm: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation"),
):
"""Remove an MCP server."""
output = get_output_controller()
loader = get_config_loader()
config = loader.load()
if name not in config.mcp.servers:
output.print_error(f"Server not found: {name}")
raise typer.Exit(1)
if not confirm:
confirmed = typer.confirm(f"Remove MCP server '{name}'?")
if not confirmed:
output.print_info("Cancelled")
raise typer.Exit(0)
# Remove from config by setting to None (will be cleaned up)
# For now, we need to reload and modify the raw config
from ..configuration.paths import get_user_config_path
from ..configuration.loader import _load_toml, _save_toml
config_path = get_user_config_path()
if config_path.exists():
raw_config = _load_toml(config_path)
if "mcp" in raw_config and "servers" in raw_config["mcp"]:
if name in raw_config["mcp"]["servers"]:
del raw_config["mcp"]["servers"][name]
_save_toml(config_path, raw_config)
if output.is_json_mode:
output.print_json({"removed": name})
else:
output.print_success(f"Removed MCP server: {name}")
@app.command("test")
def mcp_test(
name: str = typer.Argument(..., help="Server name to test"),
timeout: float = typer.Option(10.0, "--timeout", "-t", help="Timeout in seconds"),
):
"""Test an MCP server connection."""
from ..configuration.schema import MCPLocalConfig, MCPRemoteConfig
output = get_output_controller()
loader = get_config_loader()
config = loader.load()
if name not in config.mcp.servers:
output.print_error(f"Server not found: {name}")
raise typer.Exit(1)
server = config.mcp.servers[name]
if isinstance(server, MCPRemoteConfig):
# Test remote server
output.print_info(f"Testing remote MCP server: {name}")
output.print(f" URL: {server.url}")
try:
import httpx
# Simple HTTP probe to check if server is reachable
with httpx.Client(timeout=timeout) as client:
response = client.get(server.url)
if response.status_code == 200:
if output.is_json_mode:
output.print_json({"name": name, "status": "ok", "message": "Remote server reachable"})
else:
output.print_success(f"Remote server '{name}' is reachable")
else:
if output.is_json_mode:
output.print_json({"name": name, "status": "error", "message": f"HTTP {response.status_code}"})
else:
output.print_error(f"Remote server returned HTTP {response.status_code}")
raise typer.Exit(1)
except ImportError:
if output.is_json_mode:
output.print_json({"name": name, "status": "error", "message": "httpx not available for remote testing"})
else:
output.print_error("httpx package not available. Install with: pip install httpx")
raise typer.Exit(1)
except typer.Exit:
# Let typer.Exit propagate, don't catch it
raise
except Exception as e:
if output.is_json_mode:
output.print_json({"name": name, "status": "error", "message": str(e)})
else:
output.print_error(f"Failed to connect to remote server: {e}")
raise typer.Exit(1)
else:
# Test local server
output.print_info(f"Testing local MCP server: {name}")
output.print(f" Command: {server.command} {' '.join(server.args)}")
# Try to start the server and check if it responds
import subprocess
import time
try:
cmd = [server.command] + server.args
# Fix environment inheritance - merge with current env instead of replacing
env = None
if server.env:
import os
env = {**os.environ, **server.env}
# Start process
proc = subprocess.Popen(
cmd,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
env=env,
)
# Wait briefly and check if it's running
time.sleep(0.5)
if proc.poll() is None:
# Process is running
proc.terminate()
proc.wait(timeout=2)
if output.is_json_mode:
output.print_json({"name": name, "status": "ok", "message": "Server started successfully"})
else:
output.print_success(f"Server '{name}' started successfully")
else:
# Process exited
stderr = proc.stderr.read().decode() if proc.stderr else ""
if output.is_json_mode:
output.print_json({"name": name, "status": "error", "message": stderr or "Server exited immediately"})
else:
output.print_error(f"Server exited immediately: {stderr}")
raise typer.Exit(1)
except FileNotFoundError:
if output.is_json_mode:
output.print_json({"name": name, "status": "error", "message": f"Command not found: {server.command}"})
else:
output.print_error(f"Command not found: {server.command}")
raise typer.Exit(1)
except Exception as e:
if output.is_json_mode:
output.print_json({"name": name, "status": "error", "message": str(e)})
else:
output.print_error(f"Test failed: {e}")
raise typer.Exit(1)
@app.command("sync")
def mcp_sync(
server: Optional[str] = typer.Argument(None, help="Server name (None for all)"),
timeout: float = typer.Option(30.0, "--timeout", "-t", help="Timeout in seconds"),
):
"""Sync tool schemas from MCP servers to local index."""
output = get_output_controller()
loader = get_config_loader()
config = loader.load()
servers_to_sync = []
if server:
if server not in config.mcp.servers:
output.print_error(f"Server not found: {server}")
raise typer.Exit(1)
servers_to_sync = [server]
else:
servers_to_sync = list(config.mcp.servers.keys())
if not servers_to_sync:
output.print_info("No MCP servers configured")
return
total_tools = 0
synced_servers = []
failed_servers = []
for srv in servers_to_sync:
output.print_info(f"Syncing {srv}...")
srv_config = config.mcp.servers[srv]
try:
# Import schema types for checking
from ..configuration.schema import MCPLocalConfig, MCPRemoteConfig
# Actually connect to MCP server and fetch tools
from praisonaiagents.mcp import MCP
# Handle different server types
if isinstance(srv_config, MCPRemoteConfig):
# Remote server - connect via URL
if not srv_config.url:
output.print_error(f" Remote server {srv} has no URL configured")
failed_servers.append(srv)
continue
# Create MCP instance for remote server
mcp = MCP(srv_config.url, timeout=int(timeout))
else:
# Local server - use command
cmd_parts = [srv_config.command] + srv_config.args
cmd_string = " ".join(cmd_parts)
# Create MCP instance with env vars
mcp = MCP(cmd_string, timeout=int(timeout), env=srv_config.env or {})
# Get tools
tools = mcp.get_tools()
openai_tools = mcp.to_openai_tool() or []
tool_count = len(tools)
total_tools += tool_count
synced_servers.append(srv)
output.print(f" Found {tool_count} tools:")
for tool_def in openai_tools:
if isinstance(tool_def, dict) and "function" in tool_def:
name = tool_def["function"].get("name", "unknown")
desc = tool_def["function"].get("description", "")[:40]
output.print(f" - {name}: {desc}...")
# Clean up
mcp.shutdown()
except ImportError:
output.print_error(" MCP package not installed. Install with: pip install praisonaiagents[mcp]")
failed_servers.append(srv)
except Exception as e:
output.print_error(f" Failed to sync: {e}")
failed_servers.append(srv)
# Summary
if output.is_json_mode:
output.print_json({
"synced_servers": synced_servers,
"failed_servers": failed_servers,
"total_tools": total_tools
})
else:
if synced_servers:
output.print_success(f"Synced {len(synced_servers)} server(s), {total_tools} tools total")
if failed_servers:
output.print_error(f"Failed to sync {len(failed_servers)} server(s): {', '.join(failed_servers)}")
@app.command("tools")
def mcp_tools(
server: Optional[str] = typer.Argument(None, help="Server name (None for all)"),
json_output: bool = typer.Option(False, "--json", help="Output JSON"),
):
"""List indexed MCP tools."""
output = get_output_controller()
try:
from praisonai.mcp_server.tool_index import MCPToolIndex
index = MCPToolIndex()
if server:
tools = index.list_tools(server)
if not tools:
output.print_info(f"No tools indexed for server: {server}")
output.print("\nRun 'praisonai mcp sync' to index tools")
return
else:
tools = index.get_all_tools()
if not tools:
output.print_info("No tools indexed")
output.print("\nRun 'praisonai mcp sync' to index tools")
return
if output.is_json_mode or json_output:
output.print_json({"tools": [t.to_dict() for t in tools]})
else:
headers = ["Server", "Tool", "Description"]
rows = [[t.server, t.name, t.hint[:50] + "..." if len(t.hint) > 50 else t.hint] for t in tools]
output.print_table(headers, rows, title=f"MCP Tools ({len(tools)} total)")
except ImportError as e:
output.print_error(f"Error: {e}")
raise typer.Exit(1)
@app.command("describe")
def mcp_describe(
server: str = typer.Argument(..., help="Server name"),
tool: str = typer.Argument(..., help="Tool name"),
):
"""Show full schema for an MCP tool."""
output = get_output_controller()
try:
from praisonai.mcp_server.tool_index import MCPToolIndex
import json
index = MCPToolIndex()
schema = index.describe(server, tool)
if not schema:
output.print_error(f"Tool not found: {server}/{tool}")
raise typer.Exit(1)
if output.is_json_mode:
output.print_json(schema)
else:
output.print(json.dumps(schema, indent=2))
except ImportError as e:
output.print_error(f"Error: {e}")
raise typer.Exit(1)
@app.command("status")
def mcp_status(
server: Optional[str] = typer.Argument(None, help="Server name (None for all)"),
json_output: bool = typer.Option(False, "--json", help="Output JSON"),
):
"""Show status of MCP servers."""
output = get_output_controller()
try:
from praisonai.mcp_server.tool_index import MCPToolIndex
from datetime import datetime
index = MCPToolIndex()
servers = [server] if server else index.list_servers()
if not servers:
output.print_info("No servers indexed")
return
statuses = []
for srv in servers:
status = index.get_status(srv)
if status:
statuses.append(status)
if output.is_json_mode or json_output:
output.print_json({"statuses": [s.to_dict() for s in statuses]})
else:
headers = ["Server", "Status", "Tools", "Last Sync"]
rows = []
for s in statuses:
status_str = "✓ Available" if s.available else "✗ Unavailable"
if s.auth_required:
status_str += " (auth required)"
last_sync = datetime.fromtimestamp(s.last_sync).strftime("%Y-%m-%d %H:%M") if s.last_sync else "Never"
rows.append([s.server, status_str, str(s.tool_count), last_sync])
output.print_table(headers, rows, title="MCP Server Status")
except ImportError as e:
output.print_error(f"Error: {e}")
raise typer.Exit(1)
@app.command("run")
def mcp_run(
name: str = typer.Argument(..., help="Server name to run"),
port: int = typer.Option(8080, "--port", "-p", help="Port for SSE transport"),
host: str = typer.Option("127.0.0.1", "--host", "-h", help="Host to bind to"),
transport: str = typer.Option("stdio", "--transport", "-T", help="Transport type: stdio or sse"),
):
"""Run an MCP server from configuration.
DEPRECATED: Use `praisonai serve mcp --name <server>` instead.
This command starts an MCP server using the configuration stored
for the given server name. It can run in stdio mode (default) or
as an SSE server.
Examples:
praisonai mcp run my-server
praisonai mcp run my-server --transport sse --port 8080
"""
import sys
# Print deprecation warning
print("\n\033[93m⚠ DEPRECATION WARNING:\033[0m", file=sys.stderr)
print("\033[93m'praisonai mcp run' is deprecated and will be removed in a future version.\033[0m", file=sys.stderr)
print("\033[93mPlease use 'praisonai serve mcp --name <server>' instead.\033[0m\n", file=sys.stderr)
output = get_output_controller()
loader = get_config_loader()
config = loader.load()
if name not in config.mcp.servers:
output.print_error(f"Server not found: {name}")
raise typer.Exit(1)
server = config.mcp.servers[name]
if transport not in ("stdio", "sse"):
output.print_error(f"Invalid transport: {transport}. Use 'stdio' or 'sse'")
raise typer.Exit(1)
output.print_info(f"Starting MCP server: {name}")
output.print(f" Command: {server.command} {' '.join(server.args)}")
output.print(f" Transport: {transport}")
if transport == "sse":
output.print(f" Endpoint: http://{host}:{port}/sse")
import subprocess
import os
import sys
try:
cmd = [server.command] + server.args
# Build environment
env = os.environ.copy()
if server.env:
env.update(server.env)
if transport == "stdio":
# Run in stdio mode - pass through stdin/stdout
output.print_info("Running in stdio mode. Press Ctrl+C to stop.")
proc = subprocess.Popen(
cmd,
stdin=sys.stdin,
stdout=sys.stdout,
stderr=sys.stderr,
env=env,
)
proc.wait()
else:
# SSE mode - would need to wrap the server
# For now, just run the server and let it handle SSE
output.print_info(f"Running in SSE mode on http://{host}:{port}")
output.print_info("Press Ctrl+C to stop.")
# Try to use praisonaiagents MCP server wrapper
try:
from praisonaiagents.mcp import MCP
# Build command string
cmd_string = " ".join(cmd)
mcp = MCP(cmd_string, timeout=60, env=server.env or {})
# Get tools and expose via SSE
tools = mcp.get_tools()
output.print(f" Exposing {len(tools)} tools via SSE")
# Use ToolsMCPServer to expose tools
from praisonaiagents.mcp import ToolsMCPServer
mcp_server = ToolsMCPServer(name=name, tools=tools)
mcp_server.run_sse(host=host, port=port)
except ImportError:
output.print_error("MCP package not installed. Install with: pip install praisonaiagents[mcp]")
raise typer.Exit(1)
except KeyboardInterrupt:
output.print_info("\nServer stopped.")
except FileNotFoundError:
output.print_error(f"Command not found: {server.command}")
raise typer.Exit(1)
except KeyboardInterrupt:
output.print_info("\nServer stopped.")
except Exception as e:
output.print_error(f"Failed to start server: {e}")
raise typer.Exit(1)
@app.command("auth")
def mcp_auth(
name: str = typer.Argument(..., help="Server name to authenticate"),
timeout: float = typer.Option(300.0, "--timeout", "-t", help="Timeout for OAuth flow in seconds"),
):
"""[EXPERIMENTAL] Authenticate with an OAuth-enabled MCP server.
WARNING: OAuth implementation is currently experimental and stores
placeholder tokens only. Real token exchange is not yet implemented.
Use headers/API key authentication for production use.
This command initiates the OAuth 2.1 authorization flow for a remote
MCP server. It will open your browser for authentication and wait
for the callback.
Examples:
praisonai mcp auth github
praisonai mcp auth tavily --timeout 60
"""
output = get_output_controller()
loader = get_config_loader()
config = loader.load()
if name not in config.mcp.servers:
output.print_error(f"Server not found: {name}")
raise typer.Exit(1)
server = config.mcp.servers[name]
# Check if this is a remote server
from ..configuration.schema import MCPRemoteConfig
if not isinstance(server, MCPRemoteConfig):
output.print_error(f"Server '{name}' is a local server. OAuth is only for remote servers.")
raise typer.Exit(1)
if not server.url:
output.print_error(f"Server '{name}' has no URL configured.")
raise typer.Exit(1)
output.print_info(f"Authenticating with MCP server: {name}")
output.print(f" URL: {server.url}")
try:
from praisonaiagents.mcp import (
MCPAuthStorage,
OAuthCallbackHandler,
generate_state,
generate_code_verifier,
generate_code_challenge,
get_redirect_url,
)
import webbrowser
# Initialize auth storage and callback handler
auth_storage = MCPAuthStorage()
callback_handler = OAuthCallbackHandler()
# Generate PKCE parameters
state = generate_state()
code_verifier = generate_code_verifier()
code_challenge = generate_code_challenge(code_verifier)
# Store state and verifier
auth_storage.set_oauth_state(name, state)
auth_storage.set_code_verifier(name, code_verifier)
# Build authorization URL
# Note: In a real implementation, we'd discover the auth endpoint
# For now, we'll use a simple pattern
redirect_uri = get_redirect_url()
# Get OAuth config
client_id = server.oauth.client_id if server.oauth else None
scopes = " ".join(server.oauth.scopes) if server.oauth and server.oauth.scopes else ""
# Build auth URL (simplified - real impl would use OIDC discovery)
auth_url = f"{server.url}/oauth/authorize"
auth_url += "?response_type=code"
auth_url += f"&state={state}"
auth_url += f"&redirect_uri={redirect_uri}"
auth_url += f"&code_challenge={code_challenge}"
auth_url += "&code_challenge_method=S256"
if client_id:
auth_url += f"&client_id={client_id}"
if scopes:
auth_url += f"&scope={scopes}"
output.print_info("Opening browser for authentication...")
output.print(f" If browser doesn't open, visit: {auth_url}")
# Open browser
webbrowser.open(auth_url)
output.print_info(f"Waiting for callback (timeout: {timeout}s)...")
# Wait for callback
try:
code = callback_handler.wait_for_callback(state, timeout=timeout)
# Clear temporary state
auth_storage.clear_oauth_state(name)
auth_storage.clear_code_verifier(name)
# Store a placeholder token (real impl would exchange code for tokens)
auth_storage.set_tokens(name, {
"access_token": f"oauth_{code[:20]}...",
"refresh_token": None,
}, server_url=server.url)
if output.is_json_mode:
output.print_json({"name": name, "status": "authenticated"})
else:
output.print_success(f"Successfully authenticated with {name}")
except TimeoutError:
auth_storage.clear_oauth_state(name)
auth_storage.clear_code_verifier(name)
output.print_error(f"Authentication timed out after {timeout} seconds")
raise typer.Exit(1)
except ImportError as e:
output.print_error(f"MCP auth modules not available: {e}")
output.print("Install with: pip install praisonaiagents[mcp]")
raise typer.Exit(1)
except Exception as e:
output.print_error(f"Authentication failed: {e}")
raise typer.Exit(1)
@app.command("logout")
def mcp_logout(
name: str = typer.Argument(..., help="Server name to logout from"),
confirm: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation"),
):
"""Remove OAuth credentials for an MCP server.
This command clears stored OAuth tokens and client information
for a remote MCP server.
Examples:
praisonai mcp logout github
praisonai mcp logout tavily --yes
"""
output = get_output_controller()
try:
from praisonaiagents.mcp import MCPAuthStorage
auth_storage = MCPAuthStorage()
# Check if there are credentials to remove
entry = auth_storage.get(name)
if not entry or not entry.get("tokens"):
output.print_info(f"No credentials stored for: {name}")
return
if not confirm:
confirmed = typer.confirm(f"Remove OAuth credentials for '{name}'?")
if not confirmed:
output.print_info("Cancelled")
raise typer.Exit(0)
# Remove credentials
auth_storage.remove(name)
if output.is_json_mode:
output.print_json({"name": name, "status": "logged_out"})
else:
output.print_success(f"Removed OAuth credentials for: {name}")
except ImportError as e:
output.print_error(f"MCP auth modules not available: {e}")
raise typer.Exit(1)
except Exception as e:
output.print_error(f"Logout failed: {e}")
raise typer.Exit(1)
@app.callback(invoke_without_command=True)
def mcp_callback(ctx: typer.Context):
"""Show MCP help or list servers."""
if ctx.invoked_subcommand is None:
mcp_list(json_output=False)
@@ -1,326 +0,0 @@
"""
n8n CLI Commands
CLI commands for n8n workflow integration.
"""
import typer
import logging
from pathlib import Path
from typing import Optional
logger = logging.getLogger(__name__)
app = typer.Typer(
name="n8n",
help="n8n visual workflow editor integration commands",
no_args_is_help=True,
rich_markup_mode="rich"
)
@app.command()
def export(
yaml_path: Path = typer.Argument(..., help="Path to YAML workflow file"),
output: Optional[Path] = typer.Option(None, "--output", "-o", help="Output JSON file path"),
format: str = typer.Option("n8n", "--format", help="Export format (currently only n8n supported)")
):
"""Export PraisonAI YAML workflow to n8n JSON format.
Example:
praisonai n8n export my-workflow.yaml --output workflow.json
"""
if format != 'n8n':
typer.echo(f"Error: Unsupported format '{format}'. Only 'n8n' is supported.", err=True)
raise typer.Exit(1)
try:
from praisonai.n8n import YAMLToN8nConverter
import yaml as yaml_lib
import json
# Load YAML workflow
with open(yaml_path, 'r') as f:
yaml_workflow = yaml_lib.safe_load(f)
# Convert to n8n format
converter = YAMLToN8nConverter()
n8n_json = converter.convert(yaml_workflow)
# Determine output path
if output is None:
output = yaml_path.with_suffix('.json')
# Write JSON file
with open(output, 'w') as f:
json.dump(n8n_json, f, indent=2)
typer.echo(f"✅ Exported workflow to: {output}")
typer.echo(f"💡 Import this file into n8n or use 'praisonai n8n preview {yaml_path}' to open directly")
except ImportError:
typer.echo("Error: n8n dependencies not installed. Run: pip install 'praisonai[n8n]'", err=True)
raise typer.Exit(1)
except Exception as e:
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(1)
@app.command(name="import")
def import_workflow(
json_path: Path = typer.Argument(..., help="Path to n8n JSON workflow file"),
output: Optional[Path] = typer.Option(None, "--output", "-o", help="Output YAML file path"),
format: str = typer.Option("n8n", "--format", help="Import format (currently only n8n supported)")
):
"""Import n8n JSON workflow to PraisonAI YAML format.
Example:
praisonai n8n import workflow.json --output my-workflow.yaml
"""
if format != 'n8n':
typer.echo(f"Error: Unsupported format '{format}'. Only 'n8n' is supported.", err=True)
raise typer.Exit(1)
try:
from praisonai.n8n import N8nToYAMLConverter
import yaml as yaml_lib
import json
# Load n8n JSON workflow
with open(json_path, 'r') as f:
n8n_workflow = json.load(f)
# Convert to YAML format
converter = N8nToYAMLConverter()
yaml_workflow = converter.convert(n8n_workflow)
# Determine output path
if output is None:
output = json_path.with_suffix('.yaml')
# Write YAML file
with open(output, 'w') as f:
yaml_lib.dump(yaml_workflow, f, default_flow_style=False, sort_keys=False)
typer.echo(f"✅ Imported workflow to: {output}")
typer.echo(f"💡 Run 'praisonai run {output}' to execute the workflow")
except ImportError:
typer.echo("Error: n8n dependencies not installed. Run: pip install 'praisonai[n8n]'", err=True)
raise typer.Exit(1)
except Exception as e:
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(1)
@app.command()
def preview(
yaml_path: Path = typer.Argument(..., help="Path to YAML workflow file"),
n8n_url: str = typer.Option("http://localhost:5678", "--n8n-url", help="n8n instance URL"),
api_key: Optional[str] = typer.Option(None, "--api-key", help="n8n API key (or set N8N_API_KEY env var)"),
no_open: bool = typer.Option(False, "--no-open", help="Do not automatically open browser")
):
"""Preview PraisonAI workflow in n8n visual editor.
This command converts your YAML workflow to n8n format and opens it
in the n8n visual editor for preview and editing.
Example:
praisonai n8n preview my-workflow.yaml
praisonai n8n preview my-workflow.yaml --n8n-url http://n8n.example.com
"""
try:
from praisonai.n8n import preview_workflow
# Preview workflow in n8n
editor_url = preview_workflow(
yaml_path=str(yaml_path),
n8n_url=n8n_url,
api_key=api_key,
auto_open=not no_open
)
typer.echo(f"✅ Workflow created in n8n")
typer.echo(f"🌐 Editor URL: {editor_url}")
if no_open:
typer.echo(f"💡 Open the URL above to view/edit your workflow visually")
else:
typer.echo(f"💡 Browser should open automatically. If not, visit the URL above")
except ImportError:
typer.echo("Error: n8n dependencies not installed. Run: pip install 'praisonai[n8n]'", err=True)
raise typer.Exit(1)
except FileNotFoundError as e:
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(1)
except ConnectionError as e:
typer.echo(f"Connection Error: {e}", err=True)
typer.echo("💡 Make sure n8n is running. Start with: npx n8n start")
raise typer.Exit(1)
except Exception as e:
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(1)
@app.command()
def pull(
workflow_id: str = typer.Argument(..., help="n8n workflow ID"),
output_path: Path = typer.Argument(..., help="Path where to save YAML file"),
n8n_url: str = typer.Option("http://localhost:5678", "--n8n-url", help="n8n instance URL"),
api_key: Optional[str] = typer.Option(None, "--api-key", help="n8n API key (or set N8N_API_KEY env var)")
):
"""Pull workflow from n8n and convert to YAML.
Example:
praisonai n8n pull abc123 my-workflow.yaml
"""
try:
from praisonai.n8n import export_from_n8n
# Export from n8n to YAML
export_from_n8n(
workflow_id=workflow_id,
output_path=str(output_path),
n8n_url=n8n_url,
api_key=api_key
)
typer.echo(f"✅ Pulled workflow {workflow_id} to: {output_path}")
typer.echo(f"💡 Run 'praisonai run {output_path}' to execute the workflow")
except ImportError:
typer.echo("Error: n8n dependencies not installed. Run: pip install 'praisonai[n8n]'", err=True)
raise typer.Exit(1)
except ConnectionError as e:
typer.echo(f"Connection Error: {e}", err=True)
typer.echo("💡 Make sure n8n is running and accessible")
raise typer.Exit(1)
except Exception as e:
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(1)
@app.command()
def push(
yaml_path: Path = typer.Argument(..., help="Path to YAML workflow file"),
workflow_id: str = typer.Argument(..., help="Existing n8n workflow ID to update"),
n8n_url: str = typer.Option("http://localhost:5678", "--n8n-url", help="n8n instance URL"),
api_key: Optional[str] = typer.Option(None, "--api-key", help="n8n API key (or set N8N_API_KEY env var)")
):
"""Push YAML workflow updates to existing n8n workflow.
Example:
praisonai n8n push my-workflow.yaml abc123
"""
try:
from praisonai.n8n import sync_workflow
# Sync workflow with n8n
editor_url = sync_workflow(
yaml_path=str(yaml_path),
workflow_id=workflow_id,
n8n_url=n8n_url,
api_key=api_key
)
typer.echo(f"✅ Synced workflow {workflow_id}")
typer.echo(f"🌐 Editor URL: {editor_url}")
except ImportError:
typer.echo("Error: n8n dependencies not installed. Run: pip install 'praisonai[n8n]'", err=True)
raise typer.Exit(1)
except FileNotFoundError as e:
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(1)
except ConnectionError as e:
typer.echo(f"Connection Error: {e}", err=True)
typer.echo("💡 Make sure n8n is running and accessible")
raise typer.Exit(1)
except Exception as e:
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(1)
@app.command(name="test")
def test_connection(
n8n_url: str = typer.Option("http://localhost:5678", "--n8n-url", help="n8n instance URL"),
api_key: Optional[str] = typer.Option(None, "--api-key", help="n8n API key (or set N8N_API_KEY env var)")
):
"""Test connection to n8n instance.
Example:
praisonai n8n test
praisonai n8n test --n8n-url http://n8n.example.com
"""
client = None
try:
from praisonai.n8n import N8nClient
client = N8nClient(base_url=n8n_url, api_key=api_key)
if client.test_connection():
typer.echo(f"✅ Connected to n8n at {n8n_url}")
# Try to list workflows to test API access
try:
workflows = client.list_workflows()
typer.echo(f"📊 Found {len(workflows)} workflows")
except Exception as e:
typer.echo(f"⚠️ Connection successful but API access failed: {e}")
typer.echo("💡 Check your API key or n8n permissions")
else:
typer.echo(f"❌ Cannot connect to n8n at {n8n_url}")
typer.echo("💡 Make sure n8n is running. Start with: npx n8n start")
raise typer.Exit(1)
except typer.Exit:
raise
except ImportError:
typer.echo("Error: n8n dependencies not installed. Run: pip install 'praisonai[n8n]'", err=True)
raise typer.Exit(1)
except Exception as e:
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(1)
finally:
if client is not None:
client.close()
@app.command(name="list")
def list_workflows(
n8n_url: str = typer.Option("http://localhost:5678", "--n8n-url", help="n8n instance URL"),
api_key: Optional[str] = typer.Option(None, "--api-key", help="n8n API key (or set N8N_API_KEY env var)")
):
"""List workflows in n8n instance.
Example:
praisonai n8n list
"""
client = None
try:
from praisonai.n8n import N8nClient
client = N8nClient(base_url=n8n_url, api_key=api_key)
workflows = client.list_workflows()
if not workflows:
typer.echo("No workflows found in n8n")
else:
typer.echo(f"Found {len(workflows)} workflows:")
typer.echo()
for workflow in workflows:
workflow_id = workflow.get('id', 'Unknown')
name = workflow.get('name', 'Untitled')
active = workflow.get('active', False)
status = "✅ Active" if active else "⏸️ Inactive"
typer.echo(f" {workflow_id}: {name} ({status})")
except ImportError:
typer.echo("Error: n8n dependencies not installed. Run: pip install 'praisonai[n8n]'", err=True)
raise typer.Exit(1)
except ConnectionError as e:
typer.echo(f"Connection Error: {e}", err=True)
typer.echo("💡 Make sure n8n is running and accessible")
raise typer.Exit(1)
except Exception as e:
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(1)
finally:
if client is not None:
client.close()
@@ -1,466 +0,0 @@
"""
Profile command group for PraisonAI CLI.
Provides detailed cProfile-based profiling for query execution.
"""
import sys
from typing import Optional
import typer
app = typer.Typer(help="Performance profiling and diagnostics")
@app.command("query")
def profile_query(
prompt: str = typer.Argument(..., help="Prompt to profile"),
model: Optional[str] = typer.Option(None, "--model", "-m", help="Model to use"),
stream: bool = typer.Option(False, "--stream/--no-stream", help="Use streaming mode"),
deep: bool = typer.Option(False, "--deep", help="Enable deep call tracing (higher overhead)"),
limit: int = typer.Option(30, "--limit", "-n", help="Top N functions to show"),
sort: str = typer.Option("cumulative", "--sort", "-s", help="Sort by: cumulative or tottime"),
show_callers: bool = typer.Option(False, "--show-callers", help="Show caller functions"),
show_callees: bool = typer.Option(False, "--show-callees", help="Show callee functions"),
save: Optional[str] = typer.Option(None, "--save", help="Save artifacts to path (creates .prof, .txt, .json)"),
output_format: str = typer.Option("text", "--format", "-f", help="Output format: text or json"),
):
"""
Profile a query execution with detailed timing breakdown.
Uses the unified profiling architecture for consistent results across
CLI direct invocation (--profile) and this command.
Shows per-function timing, call graphs, and latency metrics.
Examples:
praisonai profile query "What is 2+2?"
praisonai profile query "Hello" --limit 20
praisonai profile query "Test" --deep --show-callers --show-callees
praisonai profile query "Test" --save ./profile_results
"""
# Use unified profiler for consistent results
try:
from praisonai.cli.execution import ExecutionRequest, Profiler, ProfilerConfig as UnifiedProfilerConfig
except ImportError as e:
typer.echo(f"Error: Unified profiler not available: {e}", err=True)
raise typer.Exit(1)
# Warn about deep tracing overhead
if deep:
typer.echo("⚠️ Deep call tracing enabled - this adds significant overhead", err=True)
# Create unified execution request
request = ExecutionRequest(
prompt=prompt,
agent_name="ProfiledAgent",
model=model,
stream=stream,
)
# Create unified profiler config
config = UnifiedProfilerConfig(
layer=2 if deep else 1,
limit=limit,
sort_by=sort,
show_callers=show_callers,
show_callees=show_callees,
output_format=output_format,
save_path=save,
)
# Run profiler
typer.echo("🔬 Starting profiled execution...", err=True)
try:
profiler = Profiler(config)
result, report = profiler.profile_sync(request, invocation_method="profile_command")
except ImportError as e:
typer.echo(f"Error: {e}", err=True)
typer.echo("Install praisonaiagents: pip install praisonaiagents", err=True)
raise typer.Exit(1)
except Exception as e:
typer.echo(f"Error during profiling: {e}", err=True)
raise typer.Exit(1)
# Output results
if output_format == "json":
typer.echo(report.to_json())
else:
typer.echo(report.to_text())
# Save artifacts if requested
if save:
try:
import os
os.makedirs(save, exist_ok=True)
# Save JSON report
json_path = os.path.join(save, "profile_report.json")
with open(json_path, 'w') as f:
f.write(report.to_json())
# Save text report
txt_path = os.path.join(save, "profile_report.txt")
with open(txt_path, 'w') as f:
f.write(report.to_text())
typer.echo("\n✅ Artifacts saved:", err=True)
typer.echo(f" {json_path}", err=True)
typer.echo(f" {txt_path}", err=True)
except Exception as e:
typer.echo(f"Warning: Failed to save artifacts: {e}", err=True)
@app.command("imports")
def profile_imports():
"""
Profile module import times.
Shows which modules take longest to import, useful for optimizing startup time.
"""
import subprocess
import re
typer.echo("🔬 Profiling import times...", err=True)
try:
result = subprocess.run(
[sys.executable, "-X", "importtime", "-c", "import praisonaiagents"],
capture_output=True,
text=True,
timeout=60,
)
# Parse import times
import_times = []
for line in result.stderr.split('\n'):
if 'import time:' in line:
match = re.search(r'import time:\s+(\d+)\s+\|\s+(\d+)\s+\|\s+(.+)', line)
if match:
self_time = int(match.group(1))
cumulative = int(match.group(2))
module = match.group(3).strip()
import_times.append((module, self_time, cumulative))
# Sort by cumulative time
import_times.sort(key=lambda x: x[2], reverse=True)
# Display
typer.echo("\n" + "=" * 70)
typer.echo("Import Time Analysis")
typer.echo("=" * 70)
typer.echo(f"{'Module':<45} {'Self (μs)':>10} {'Cumul (μs)':>12}")
typer.echo("-" * 70)
for module, self_time, cumulative in import_times[:30]:
module_name = module
if len(module_name) > 43:
module_name = "..." + module_name[-40:]
typer.echo(f"{module_name:<45} {self_time:>10} {cumulative:>12}")
typer.echo("-" * 70)
if import_times:
total_time = max(t[2] for t in import_times)
typer.echo(f"Total import time: {total_time / 1000:.2f} ms")
except subprocess.TimeoutExpired:
typer.echo("Error: Import profiling timed out", err=True)
raise typer.Exit(1)
except Exception as e:
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(1)
@app.command("startup")
def profile_startup():
"""
Profile CLI startup time.
Measures time from CLI invocation to ready state.
"""
import time
import subprocess
typer.echo("🔬 Profiling startup time...", err=True)
# Measure cold start (new process)
start = time.perf_counter()
subprocess.run(
[sys.executable, "-c", "import praisonai; import praisonai.cli"],
capture_output=True,
text=True,
)
cold_start = (time.perf_counter() - start) * 1000
# Measure warm start (imports cached)
start = time.perf_counter()
subprocess.run(
[sys.executable, "-c", "import praisonai; import praisonai.cli"],
capture_output=True,
text=True,
)
warm_start = (time.perf_counter() - start) * 1000
typer.echo("\n" + "=" * 50)
typer.echo("Startup Time Analysis")
typer.echo("=" * 50)
typer.echo(f"Cold Start: {cold_start:>10.2f} ms")
typer.echo(f"Warm Start: {warm_start:>10.2f} ms")
typer.echo("=" * 50)
if cold_start > 1000:
typer.echo("\n⚠️ Cold start > 1s - consider lazy imports", err=True)
@app.command("suite")
def profile_suite(
output_dir: Optional[str] = typer.Option("/tmp/praisonai_profile_suite", "--output", "-o", help="Output directory for results"),
iterations: int = typer.Option(3, "--iterations", "-n", help="Iterations per scenario"),
quick: bool = typer.Option(False, "--quick", help="Quick mode (fewer iterations)"),
):
"""
Run a comprehensive profiling suite.
Runs multiple scenarios (streaming/non-streaming, simple/complex prompts)
and produces aggregated statistics and reports.
Examples:
praisonai profile suite
praisonai profile suite --output ./my_profile_results
praisonai profile suite --quick
"""
try:
from praisonai_code.cli.features.profiler import run_profile_suite, ScenarioConfig
except ImportError as e:
typer.echo(f"Error: Profiler module not available: {e}", err=True)
raise typer.Exit(1)
# Define scenarios
iters = 1 if quick else iterations
scenarios = [
ScenarioConfig(name="simple_non_stream", prompt="hi", stream=False, iterations=iters, warmup=0 if quick else 1),
ScenarioConfig(name="simple_stream", prompt="hi", stream=True, iterations=iters, warmup=0 if quick else 1),
]
if not quick:
scenarios.extend([
ScenarioConfig(name="medium_non_stream", prompt="Explain Python in 2 sentences", stream=False, iterations=max(1, iters-1)),
ScenarioConfig(name="medium_stream", prompt="Explain Python in 2 sentences", stream=True, iterations=max(1, iters-1)),
])
typer.echo("🔬 Running Profile Suite...", err=True)
typer.echo(f" Output: {output_dir}", err=True)
typer.echo(f" Scenarios: {len(scenarios)}", err=True)
typer.echo(f" Iterations: {iterations}", err=True)
try:
result = run_profile_suite(
output_dir=output_dir,
scenarios=scenarios,
verbose=True,
)
# Print summary
typer.echo("\n" + "=" * 60)
typer.echo("Profile Suite Summary")
typer.echo("=" * 60)
typer.echo(f"Startup Cold: {result.startup_cold_ms:.2f}ms")
typer.echo(f"Startup Warm: {result.startup_warm_ms:.2f}ms")
if result.import_analysis:
typer.echo(f"\nTop Import: {result.import_analysis[0]['module']}")
typer.echo(f" Time: {result.import_analysis[0]['cumulative_ms']:.2f}ms")
typer.echo("\nScenario Results:")
for scenario in result.scenarios:
stats = scenario.get_stats(scenario.total_times)
typer.echo(f" {scenario.name}: {stats['mean']:.2f}ms (±{stats['stdev']:.2f}ms)")
typer.echo(f"\n✅ Full results saved to: {output_dir}")
except Exception as e:
typer.echo(f"Error running suite: {e}", err=True)
raise typer.Exit(1)
@app.command("snapshot")
def profile_snapshot(
name: str = typer.Argument("current", help="Name for the snapshot"),
baseline: bool = typer.Option(False, "--baseline", "-b", help="Save as baseline for comparison"),
compare: bool = typer.Option(False, "--compare", "-c", help="Compare against baseline"),
output_format: str = typer.Option("text", "--format", "-f", help="Output format: text or json"),
):
"""
Create or compare performance snapshots.
Snapshots record timing data for baseline comparison.
Use --baseline to save a reference point, then --compare to check for regressions.
Examples:
praisonai profile snapshot --baseline
praisonai profile snapshot current --compare
praisonai profile snapshot v2.0 --format json
"""
try:
from praisonai_code.cli.features.profiler import (
run_profile_suite,
ScenarioConfig,
PerfSnapshotManager,
create_snapshot_from_suite,
format_comparison_report,
)
except ImportError as e:
typer.echo(f"Error: Profiler module not available: {e}", err=True)
raise typer.Exit(1)
manager = PerfSnapshotManager()
if compare:
# Load baseline and compare
baseline_snap = manager.load_baseline()
if not baseline_snap:
typer.echo("❌ No baseline found. Create one with: praisonai profile snapshot --baseline", err=True)
raise typer.Exit(1)
typer.echo("🔬 Running current profile for comparison...", err=True)
# Run quick suite
scenarios = [
ScenarioConfig(name="simple", prompt="hi", stream=False, iterations=2, warmup=1),
]
result = run_profile_suite(
output_dir="/tmp/praisonai_snapshot_compare",
scenarios=scenarios,
output="minimal",
)
current_snap = create_snapshot_from_suite(result, name)
comparison = manager.compare(current_snap)
if output_format == "json":
import json
typer.echo(json.dumps(comparison.to_dict(), indent=2))
else:
typer.echo(format_comparison_report(comparison))
if comparison.is_regression():
raise typer.Exit(1) # Exit with error on regression
else:
# Create new snapshot
typer.echo(f"🔬 Creating performance snapshot '{name}'...", err=True)
scenarios = [
ScenarioConfig(name="simple", prompt="hi", stream=False, iterations=3, warmup=1),
ScenarioConfig(name="stream", prompt="hi", stream=True, iterations=2, warmup=1),
]
result = run_profile_suite(
output_dir="/tmp/praisonai_snapshot",
scenarios=scenarios,
verbose=True,
)
snapshot = create_snapshot_from_suite(result, name)
if baseline:
path = manager.save_baseline(snapshot)
typer.echo(f"\n✅ Baseline saved to: {path}")
else:
path = manager.save(snapshot)
typer.echo(f"\n✅ Snapshot saved to: {path}")
if output_format == "json":
import json
typer.echo(json.dumps(snapshot.to_dict(), indent=2))
else:
typer.echo("\nSnapshot Summary:")
typer.echo(f" Startup Cold: {snapshot.startup_cold_ms:.2f}ms")
typer.echo(f" Import Time: {snapshot.import_time_ms:.2f}ms")
typer.echo(f" Query Time: {snapshot.query_time_ms:.2f}ms")
@app.command("optimize")
def profile_optimize(
prewarm: bool = typer.Option(False, "--prewarm", help="Enable provider pre-warming"),
lite: bool = typer.Option(False, "--lite", help="Enable lite mode (skip type validation)"),
show_config: bool = typer.Option(False, "--show", help="Show current optimization config"),
):
"""
Configure performance optimizations.
All optimizations are opt-in and safe by default.
Examples:
praisonai profile optimize --show
praisonai profile optimize --prewarm
praisonai profile optimize --lite
"""
try:
from praisonai_code.cli.features.profiler import (
PrewarmManager,
get_lite_mode_config,
get_provider_cache,
)
except ImportError as e:
typer.echo(f"Error: Profiler module not available: {e}", err=True)
raise typer.Exit(1)
if show_config:
typer.echo("=" * 50)
typer.echo("Performance Optimization Status")
typer.echo("=" * 50)
lite_config = get_lite_mode_config()
typer.echo(f"\nLite Mode: {'Enabled' if lite_config.enabled else 'Disabled'}")
if lite_config.enabled:
typer.echo(f" Skip Type Validation: {lite_config.skip_type_validation}")
typer.echo(f" Skip Model Validation: {lite_config.skip_model_validation}")
typer.echo(f" Minimal Imports: {lite_config.minimal_imports}")
typer.echo(f"\nPre-warming: {'Enabled' if PrewarmManager.is_enabled() else 'Disabled'}")
cache = get_provider_cache()
stats = cache.get_stats()
typer.echo("\nProvider Cache:")
typer.echo(f" Hits: {stats['hits']}")
typer.echo(f" Misses: {stats['misses']}")
typer.echo(f" Size: {stats['size']}")
typer.echo("\n" + "=" * 50)
typer.echo("\nTo enable optimizations, set environment variables:")
typer.echo(" PRAISONAI_LITE_MODE=1")
typer.echo(" PRAISONAI_SKIP_TYPE_VALIDATION=1")
typer.echo(" PRAISONAI_MINIMAL_IMPORTS=1")
return
if prewarm:
typer.echo("🔥 Enabling provider pre-warming...")
PrewarmManager.enable()
import os
if os.environ.get('OPENAI_API_KEY'):
typer.echo(" Pre-warming OpenAI...")
PrewarmManager.prewarm_provider('openai')
if os.environ.get('ANTHROPIC_API_KEY'):
typer.echo(" Pre-warming Anthropic...")
PrewarmManager.prewarm_provider('anthropic')
typer.echo("✅ Pre-warming initiated (runs in background)")
if lite:
typer.echo("⚡ Lite mode configuration:")
typer.echo(" Set these environment variables to enable:")
typer.echo(" export PRAISONAI_LITE_MODE=1")
typer.echo(" export PRAISONAI_SKIP_TYPE_VALIDATION=1")
typer.echo(" export PRAISONAI_MINIMAL_IMPORTS=1")
if not prewarm and not lite and not show_config:
typer.echo("Use --show to see current config, or --prewarm/--lite to enable optimizations")
@app.callback(invoke_without_command=True)
def profile_callback(ctx: typer.Context):
"""Show profile help if no subcommand."""
if ctx.invoked_subcommand is None:
typer.echo(ctx.get_help())
@@ -1,913 +0,0 @@
"""
RAG command group for PraisonAI CLI.
Provides RAG (Retrieval Augmented Generation) commands:
- index: Build/update an index from sources
- query: One-shot question -> answer with citations
- chat: Interactive RAG chat loop
- eval: Evaluate retrieval quality
- serve: Start RAG microservice (optional)
All commands support --profile for performance profiling using the existing
praisonai profile infrastructure.
"""
import typer
from typing import Optional, List
from pathlib import Path
from contextlib import contextmanager
import time
import json as json_module
app = typer.Typer(help="RAG (Retrieval Augmented Generation) commands")
@contextmanager
def rag_profiler(enabled: bool, profile_out: Optional[Path], profile_top: int = 20):
"""
Context manager for RAG command profiling.
Uses the existing praisonai.profiler infrastructure when available,
falls back to basic timing if not.
"""
if not enabled:
yield None
return
import sys
start_time = time.perf_counter()
start_modules = set(sys.modules.keys())
# Try to use the full profiler
profiler = None
try:
from praisonai.profiler import Profiler
Profiler.enable()
Profiler.clear()
profiler = Profiler
except ImportError:
pass
# Try tracemalloc for memory
try:
import tracemalloc
tracemalloc.start()
except Exception:
tracemalloc = None
profile_data = {
"command": "rag",
"start_time": time.time(),
"metrics": {},
"imports": [],
"top_functions": [],
}
try:
yield profile_data
finally:
end_time = time.perf_counter()
elapsed_ms = (end_time - start_time) * 1000
# Collect metrics
profile_data["metrics"]["wall_time_ms"] = elapsed_ms
profile_data["metrics"]["wall_time_s"] = elapsed_ms / 1000
# Memory usage
if tracemalloc:
try:
current, peak = tracemalloc.get_traced_memory()
tracemalloc.stop()
profile_data["metrics"]["peak_memory_mb"] = peak / (1024 * 1024)
profile_data["metrics"]["current_memory_mb"] = current / (1024 * 1024)
except Exception:
pass
# New imports during execution
end_modules = set(sys.modules.keys())
new_modules = end_modules - start_modules
profile_data["imports"] = sorted(list(new_modules))[:profile_top]
profile_data["metrics"]["modules_imported"] = len(new_modules)
# Get profiler data if available
if profiler:
try:
profiler.disable()
stats = profiler.get_statistics()
if stats:
profile_data["top_functions"] = stats.get("top_functions", [])[:profile_top]
profile_data["metrics"].update(stats.get("summary", {}))
except Exception:
pass
# Save to file if requested
if profile_out:
try:
profile_out.parent.mkdir(parents=True, exist_ok=True)
with open(profile_out, "w") as f:
json_module.dump(profile_data, f, indent=2, default=str)
except Exception as e:
print(f"Warning: Failed to save profile: {e}")
# Print summary
from rich.console import Console
console = Console(stderr=True)
console.print(f"\n[dim]Profile: {elapsed_ms:.2f}ms wall time[/dim]")
if "peak_memory_mb" in profile_data["metrics"]:
console.print(f"[dim]Profile: {profile_data['metrics']['peak_memory_mb']:.2f}MB peak memory[/dim]")
console.print(f"[dim]Profile: {len(new_modules)} modules imported[/dim]")
if profile_out:
console.print(f"[dim]Profile saved to: {profile_out}[/dim]")
@app.command("index")
def rag_index(
sources: List[str] = typer.Argument(..., help="Source files, directories, or URLs to index"),
collection: str = typer.Option("default", "--collection", "-c", help="Collection/index name"),
chunking: str = typer.Option("recursive", "--chunking", help="Chunking strategy: token, sentence, recursive, semantic"),
chunk_size: int = typer.Option(512, "--chunk-size", help="Chunk size in tokens"),
config: Optional[Path] = typer.Option(None, "--config", "-f", help="Config file path"),
verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
profile: bool = typer.Option(False, "--profile", help="Enable performance profiling"),
profile_out: Optional[Path] = typer.Option(None, "--profile-out", help="Save profile to JSON file"),
profile_top: int = typer.Option(20, "--profile-top", help="Top N items in profile"),
):
"""
Index documents for RAG retrieval.
This command uses the Knowledge substrate for indexing.
Equivalent to: praisonai knowledge index
Examples:
praisonai rag index ./docs
praisonai rag index paper.pdf --collection research
praisonai rag index ./data --chunking semantic --chunk-size 256
praisonai rag index ./docs --profile --profile-out ./profile.json
"""
from rich.console import Console
from rich.progress import Progress, SpinnerColumn, TextColumn
console = Console()
with rag_profiler(profile, profile_out, profile_top) as profile_data:
try:
# Lazy import to avoid startup cost
from praisonaiagents.knowledge import Knowledge
# Build config - use unified knowledge path
knowledge_config = {
"vector_store": {
"provider": "chroma",
"config": {
"collection_name": collection,
"path": f"./.praison/knowledge/{collection}",
}
}
}
# Load config file if provided
if config and config.exists():
import yaml
with open(config) as f:
file_config = yaml.safe_load(f)
if "knowledge" in file_config:
knowledge_config.update(file_config["knowledge"])
# Initialize Knowledge
knowledge = Knowledge(config=knowledge_config, verbose=verbose)
# Index sources
with Progress(
SpinnerColumn(),
TextColumn("[progress.description]{task.description}"),
console=console,
transient=True,
) as progress:
for source in sources:
task = progress.add_task(f"Indexing {source}...", total=None)
try:
result = knowledge.add(source)
count = len(result.get("results", [])) if isinstance(result, dict) else 0
console.print(f"[green]✓[/green] Indexed {source}: {count} chunks")
except Exception as e:
console.print(f"[red]✗[/red] Failed to index {source}: {e}")
progress.remove_task(task)
console.print(f"\n[bold green]Indexing complete![/bold green] Collection: {collection}")
if profile_data:
profile_data["command"] = "rag index"
profile_data["collection"] = collection
profile_data["sources"] = sources
except ImportError as e:
console.print(f"[red]Error:[/red] Missing dependency: {e}")
console.print("Install with: pip install 'praisonaiagents[knowledge]'")
raise typer.Exit(1)
except Exception as e:
console.print(f"[red]Error:[/red] {e}")
raise typer.Exit(1)
@app.command("query")
def rag_query(
question: str = typer.Argument(..., help="Question to answer"),
collection: str = typer.Option("default", "--collection", "-c", help="Collection to query"),
top_k: int = typer.Option(5, "--top-k", "-k", help="Number of results to retrieve"),
hybrid: bool = typer.Option(False, "--hybrid", help="Use hybrid retrieval (dense + BM25)"),
rerank: bool = typer.Option(False, "--rerank", help="Enable reranking of results"),
citations: bool = typer.Option(True, "--citations/--no-citations", help="Include citations"),
config: Optional[Path] = typer.Option(None, "--config", "-f", help="Config file path"),
verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
profile: bool = typer.Option(False, "--profile", help="Enable performance profiling"),
profile_out: Optional[Path] = typer.Option(None, "--profile-out", help="Save profile to JSON file"),
profile_top: int = typer.Option(20, "--profile-top", help="Top N items in profile"),
):
"""
Query the RAG system with a question.
Examples:
praisonai rag query "What is the main finding?"
praisonai rag query "Summarize the document" --collection research
praisonai rag query "Key points?" --top-k 10 --no-citations
praisonai rag query "Summary?" --hybrid --rerank
praisonai rag query "Summary?" --profile --profile-out ./profile.json
"""
from rich.console import Console
from rich.panel import Panel
from rich.markdown import Markdown
console = Console()
with rag_profiler(profile, profile_out, profile_top) as profile_data:
try:
from praisonaiagents.knowledge import Knowledge
from praisonaiagents.rag import RAG, RAGConfig
from praisonaiagents.rag.models import RetrievalStrategy
# Build config - use unified knowledge path
knowledge_config = {
"vector_store": {
"provider": "chroma",
"config": {
"collection_name": collection,
"path": f"./.praison/knowledge/{collection}",
}
}
}
# Add hybrid retrieval config if enabled
if hybrid:
knowledge_config["retrieval"] = {
"strategy": "hybrid",
}
# Load config file if provided
if config and config.exists():
import yaml
with open(config) as f:
file_config = yaml.safe_load(f)
if "knowledge" in file_config:
knowledge_config.update(file_config["knowledge"])
# Initialize
knowledge = Knowledge(config=knowledge_config, verbose=verbose)
# Determine retrieval strategy
retrieval_strategy = RetrievalStrategy.HYBRID if hybrid else RetrievalStrategy.BASIC
rag_config = RAGConfig(
top_k=top_k,
include_citations=citations,
retrieval_strategy=retrieval_strategy,
rerank=rerank,
)
rag = RAG(knowledge=knowledge, config=rag_config)
# Query
if verbose:
strategy_str = "hybrid (dense + BM25)" if hybrid else "dense"
rerank_str = " with reranking" if rerank else ""
console.print(f"[dim]Querying collection '{collection}' using {strategy_str}{rerank_str}, top_k={top_k}...[/dim]")
result = rag.query(question)
# Display answer
console.print(Panel(
Markdown(result.answer),
title="[bold]Answer[/bold]",
border_style="green",
))
# Display citations
if citations and result.citations:
console.print("\n[bold]Sources:[/bold]")
for citation in result.citations:
score_str = f"[dim](score: {citation.score:.2f})[/dim]" if citation.score else ""
console.print(f" [{citation.id}] {citation.source} {score_str}")
if verbose:
snippet = citation.text[:200] + "..." if len(citation.text) > 200 else citation.text
console.print(f" [dim]{snippet}[/dim]")
# Show metadata if verbose
if verbose:
console.print(f"\n[dim]Elapsed: {result.metadata.get('elapsed_seconds', 0):.2f}s[/dim]")
if profile_data:
profile_data["command"] = "rag query"
profile_data["collection"] = collection
profile_data["question"] = question
except ImportError as e:
console.print(f"[red]Error:[/red] Missing dependency: {e}")
console.print("Install with: pip install 'praisonaiagents[knowledge]'")
raise typer.Exit(1)
except Exception as e:
console.print(f"[red]Error:[/red] {e}")
if verbose:
import traceback
console.print(traceback.format_exc())
raise typer.Exit(1)
@app.command("chat")
def rag_chat(
collection: str = typer.Option("default", "--collection", "-c", help="Collection to chat with"),
top_k: int = typer.Option(5, "--top-k", "-k", help="Number of results per query"),
hybrid: bool = typer.Option(False, "--hybrid", help="Use hybrid retrieval (dense + BM25)"),
rerank: bool = typer.Option(False, "--rerank", help="Enable reranking of results"),
config: Optional[Path] = typer.Option(None, "--config", "-f", help="Config file path"),
stream: bool = typer.Option(True, "--stream/--no-stream", help="Stream responses"),
):
"""
Interactive RAG chat session.
Examples:
praisonai rag chat
praisonai rag chat --collection research
praisonai rag chat --hybrid --rerank
praisonai rag chat --no-stream
"""
from rich.console import Console
from rich.panel import Panel
from rich.markdown import Markdown
console = Console()
try:
from praisonaiagents.knowledge import Knowledge
from praisonaiagents.rag import RAG, RAGConfig
from praisonaiagents.rag.models import RetrievalStrategy
# Build config - use unified knowledge path
knowledge_config = {
"vector_store": {
"provider": "chroma",
"config": {
"collection_name": collection,
"path": f"./.praison/knowledge/{collection}",
}
}
}
# Add hybrid retrieval config if enabled
if hybrid:
knowledge_config["retrieval"] = {
"strategy": "hybrid",
}
# Load config file if provided
if config and config.exists():
import yaml
with open(config) as f:
file_config = yaml.safe_load(f)
if "knowledge" in file_config:
knowledge_config.update(file_config["knowledge"])
# Initialize
knowledge = Knowledge(config=knowledge_config)
retrieval_strategy = RetrievalStrategy.HYBRID if hybrid else RetrievalStrategy.BASIC
rag_config = RAGConfig(
top_k=top_k,
include_citations=True,
stream=stream,
retrieval_strategy=retrieval_strategy,
rerank=rerank,
)
rag = RAG(knowledge=knowledge, config=rag_config)
console.print(Panel(
f"[bold]RAG Chat[/bold] - Collection: {collection}\n"
"Type your questions. Use 'exit' or Ctrl+C to quit.",
border_style="blue",
))
while True:
try:
question = console.input("\n[bold blue]You:[/bold blue] ").strip()
if not question:
continue
if question.lower() in ("exit", "quit", "q"):
console.print("[dim]Goodbye![/dim]")
break
console.print("\n[bold green]Assistant:[/bold green]")
if stream:
# Stream response
full_response = ""
for chunk in rag.stream(question):
console.print(chunk, end="")
full_response += chunk
console.print() # Newline after streaming
# Get citations separately
citations = rag.get_citations(question)
else:
result = rag.query(question)
console.print(Markdown(result.answer))
citations = result.citations
# Show citations
if citations:
console.print("\n[dim]Sources:[/dim]")
for c in citations[:3]: # Show top 3
console.print(f" [dim][{c.id}] {c.source}[/dim]")
except KeyboardInterrupt:
console.print("\n[dim]Goodbye![/dim]")
break
except Exception as e:
console.print(f"[red]Error:[/red] {e}")
except ImportError as e:
console.print(f"[red]Error:[/red] Missing dependency: {e}")
console.print("Install with: pip install 'praisonaiagents[knowledge]'")
raise typer.Exit(1)
except Exception as e:
console.print(f"[red]Error:[/red] {e}")
raise typer.Exit(1)
@app.command("eval")
def rag_eval(
test_file: Path = typer.Argument(..., help="JSON file with test queries"),
collection: str = typer.Option("default", "--collection", "-c", help="Collection to evaluate"),
top_k: int = typer.Option(5, "--top-k", "-k", help="Number of results to retrieve"),
output: Optional[Path] = typer.Option(None, "--output", "-o", help="Output results to file"),
verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
profile: bool = typer.Option(False, "--profile", help="Enable performance profiling"),
profile_out: Optional[Path] = typer.Option(None, "--profile-out", help="Save profile to JSON file"),
profile_top: int = typer.Option(20, "--profile-top", help="Top N items in profile"),
):
"""
Evaluate RAG retrieval quality.
Test file format (JSON):
[
{"query": "What is X?", "expected_doc": "doc1.pdf", "expected_answer_contains": "answer"},
...
]
Examples:
praisonai rag eval golden_queries.json
praisonai rag eval tests.json --collection research --output results.json
praisonai rag eval tests.json --profile --profile-out ./profile.json
"""
import json
from rich.console import Console
from rich.table import Table
console = Console()
with rag_profiler(profile, profile_out, profile_top) as profile_data:
try:
from praisonaiagents.knowledge import Knowledge
from praisonaiagents.rag import RAG, RAGConfig
# Load test file
if not test_file.exists():
console.print(f"[red]Error:[/red] Test file not found: {test_file}")
raise typer.Exit(1)
with open(test_file) as f:
tests = json.load(f)
if not isinstance(tests, list):
console.print("[red]Error:[/red] Test file must contain a JSON array")
raise typer.Exit(1)
# Build config - use unified knowledge path
knowledge_config = {
"vector_store": {
"provider": "chroma",
"config": {
"collection_name": collection,
"path": f"./.praison/knowledge/{collection}",
}
}
}
# Initialize
knowledge = Knowledge(config=knowledge_config, verbose=verbose)
rag_config = RAGConfig(top_k=top_k, include_citations=True)
rag = RAG(knowledge=knowledge, config=rag_config)
# Run evaluation
results = []
passed = 0
failed = 0
console.print(f"\n[bold]Evaluating {len(tests)} queries...[/bold]\n")
for i, test in enumerate(tests):
query = test.get("query", "")
expected_doc = test.get("expected_doc", "")
expected_contains = test.get("expected_answer_contains", "")
result = rag.query(query)
# Check if expected doc is in citations
doc_found = any(
expected_doc in c.source for c in result.citations
) if expected_doc else True
# Check if answer contains expected text
answer_ok = (
expected_contains.lower() in result.answer.lower()
) if expected_contains else True
test_passed = doc_found and answer_ok
if test_passed:
passed += 1
status = "[green]PASS[/green]"
else:
failed += 1
status = "[red]FAIL[/red]"
results.append({
"query": query,
"passed": test_passed,
"doc_found": doc_found,
"answer_ok": answer_ok,
"answer": result.answer[:200],
"citations": [c.source for c in result.citations],
})
if verbose:
console.print(f" {status} Query: {query[:50]}...")
# Summary table
table = Table(title="Evaluation Results")
table.add_column("Metric", style="bold")
table.add_column("Value")
table.add_row("Total Tests", str(len(tests)))
table.add_row("Passed", f"[green]{passed}[/green]")
table.add_row("Failed", f"[red]{failed}[/red]")
table.add_row("Pass Rate", f"{passed/len(tests)*100:.1f}%")
console.print(table)
# Save results if output specified
if output:
with open(output, "w") as f:
json.dump(results, f, indent=2)
console.print(f"\n[dim]Results saved to {output}[/dim]")
# Exit with error if any failed
if failed > 0:
raise typer.Exit(1)
if profile_data:
profile_data["command"] = "rag eval"
profile_data["collection"] = collection
profile_data["test_file"] = str(test_file)
profile_data["metrics"]["tests_total"] = len(tests)
profile_data["metrics"]["tests_passed"] = passed
profile_data["metrics"]["tests_failed"] = failed
except ImportError as e:
console.print(f"[red]Error:[/red] Missing dependency: {e}")
console.print("Install with: pip install 'praisonaiagents[knowledge]'")
raise typer.Exit(1)
except typer.Exit:
raise
except Exception as e:
console.print(f"[red]Error:[/red] {e}")
if verbose:
import traceback
console.print(traceback.format_exc())
raise typer.Exit(1)
@app.command("serve")
def rag_serve(
collection: str = typer.Option("default", "--collection", "-c", help="Collection to serve"),
host: str = typer.Option("127.0.0.1", "--host", "-h", help="Host to bind"),
port: int = typer.Option(8080, "--port", "-p", help="Port to bind"),
hybrid: bool = typer.Option(False, "--hybrid", help="Use hybrid retrieval (dense + BM25)"),
rerank: bool = typer.Option(False, "--rerank", help="Enable reranking of results"),
openai_compat: bool = typer.Option(False, "--openai-compat", help="Enable OpenAI-compatible /v1/chat/completions endpoint"),
config: Optional[Path] = typer.Option(None, "--config", "-f", help="Config file path"),
verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
profile: bool = typer.Option(False, "--profile", help="Enable performance profiling"),
profile_out: Optional[Path] = typer.Option(None, "--profile-out", help="Save profile to JSON file"),
profile_top: int = typer.Option(20, "--profile-top", help="Top N items in profile"),
):
"""
Start RAG microservice API.
DEPRECATED: Use `praisonai serve rag` instead.
Endpoints:
GET /health - Health check
POST /rag/query - Query with JSON body {"question": "...", "top_k": 5, "hybrid": false}
POST /rag/chat - Streaming chat (SSE)
POST /v1/chat/completions - OpenAI-compatible endpoint (with --openai-compat)
Examples:
praisonai rag serve
praisonai rag serve --collection research --port 9000
praisonai rag serve --hybrid --rerank
praisonai rag serve --openai-compat --port 8080
praisonai rag serve --profile --profile-out ./profile.json
"""
import sys
# Print deprecation warning
print("\n\033[93m⚠ DEPRECATION WARNING:\033[0m", file=sys.stderr)
print("\033[93m'praisonai rag serve' is deprecated and will be removed in a future version.\033[0m", file=sys.stderr)
print("\033[93mPlease use 'praisonai serve rag' instead.\033[0m\n", file=sys.stderr)
from rich.console import Console
console = Console()
with rag_profiler(profile, profile_out, profile_top) as profile_data:
try:
import uvicorn
from fastapi import FastAPI, HTTPException
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
from typing import List as TypingList, Optional as TypingOptional
except ImportError:
console.print("[red]Error:[/red] FastAPI/uvicorn not installed")
console.print("Install with: pip install 'praisonai[rag-api]'")
raise typer.Exit(1)
try:
from praisonaiagents.knowledge import Knowledge
from praisonaiagents.rag import RAG, RAGConfig
from praisonaiagents.rag.models import RetrievalStrategy
# Build config - use unified knowledge path
knowledge_config = {
"vector_store": {
"provider": "chroma",
"config": {
"collection_name": collection,
"path": f"./.praison/knowledge/{collection}",
}
}
}
# Add hybrid retrieval config if enabled
if hybrid:
knowledge_config["retrieval"] = {
"strategy": "hybrid",
}
# Load config file if provided
if config and config.exists():
import yaml
with open(config) as f:
file_config = yaml.safe_load(f)
if "knowledge" in file_config:
knowledge_config.update(file_config["knowledge"])
# Initialize
knowledge = Knowledge(config=knowledge_config, verbose=verbose)
retrieval_strategy = RetrievalStrategy.HYBRID if hybrid else RetrievalStrategy.BASIC
rag_config = RAGConfig(
retrieval_strategy=retrieval_strategy,
rerank=rerank,
)
rag = RAG(knowledge=knowledge, config=rag_config)
# Create FastAPI app
api = FastAPI(
title="PraisonAI RAG API",
version="1.0.0",
description="RAG microservice with hybrid retrieval and OpenAI-compatible mode",
)
# Request/Response models
class QueryRequest(BaseModel):
question: str
top_k: int = 5
include_citations: bool = True
hybrid: bool = Field(default=False, description="Use hybrid retrieval")
rerank: bool = Field(default=False, description="Enable reranking")
class CitationModel(BaseModel):
id: str
source: str
text: str
score: float = 0.0
doc_id: TypingOptional[str] = None
chunk_id: TypingOptional[str] = None
class QueryResponse(BaseModel):
answer: str
citations: TypingList[CitationModel]
metadata: dict
# OpenAI-compatible models
class ChatMessage(BaseModel):
role: str
content: str
class ChatCompletionRequest(BaseModel):
model: str = "rag"
messages: TypingList[ChatMessage]
temperature: float = 0.7
max_tokens: TypingOptional[int] = None
stream: bool = False
rag: bool = Field(default=True, description="Enable RAG retrieval")
class ChatCompletionChoice(BaseModel):
index: int
message: ChatMessage
finish_reason: str = "stop"
class ChatCompletionResponse(BaseModel):
id: str
object: str = "chat.completion"
created: int
model: str
choices: TypingList[ChatCompletionChoice]
usage: dict = Field(default_factory=dict)
@api.get("/health")
def health():
return {
"status": "healthy",
"collection": collection,
"hybrid": hybrid,
"rerank": rerank,
"openai_compat": openai_compat,
}
@api.post("/rag/query", response_model=QueryResponse)
def query_endpoint(request: QueryRequest):
try:
# Update config based on request
rag.config.top_k = request.top_k
rag.config.include_citations = request.include_citations
# Override retrieval strategy if requested
if request.hybrid:
rag.config.retrieval_strategy = RetrievalStrategy.HYBRID
if request.rerank:
rag.config.rerank = True
result = rag.query(request.question)
return QueryResponse(
answer=result.answer,
citations=[
CitationModel(
id=c.id,
source=c.source,
text=c.text,
score=c.score,
doc_id=c.doc_id,
chunk_id=c.chunk_id,
)
for c in result.citations
],
metadata=result.metadata,
)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@api.post("/rag/chat")
def chat_endpoint(request: QueryRequest):
"""Streaming RAG chat endpoint using Server-Sent Events."""
import json as json_lib
def generate():
try:
# Update config
rag.config.top_k = request.top_k
if request.hybrid:
rag.config.retrieval_strategy = RetrievalStrategy.HYBRID
if request.rerank:
rag.config.rerank = True
for chunk in rag.stream(request.question):
yield f"data: {json_lib.dumps({'content': chunk})}\n\n"
# Send citations at the end
if request.include_citations:
citations = rag.get_citations(request.question)
yield f"data: {json_lib.dumps({'citations': [c.to_dict() for c in citations]})}\n\n"
yield "data: [DONE]\n\n"
except Exception as e:
yield f"data: {json_lib.dumps({'error': str(e)})}\n\n"
return StreamingResponse(
generate(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "Connection": "keep-alive"},
)
# OpenAI-compatible endpoint (only if enabled)
if openai_compat:
@api.post("/v1/chat/completions")
def openai_chat_completions(request: ChatCompletionRequest):
"""OpenAI-compatible chat completions endpoint with RAG."""
import time as time_module
import uuid
try:
# Extract the last user message as the question
user_messages = [m for m in request.messages if m.role == "user"]
if not user_messages:
raise HTTPException(status_code=400, detail="No user message found")
question = user_messages[-1].content
# Use RAG if enabled
if request.rag:
result = rag.query(question)
answer = result.answer
else:
# Direct LLM call without RAG
if rag.llm:
answer = rag._generate(question)
else:
answer = "LLM not available"
response = ChatCompletionResponse(
id=f"chatcmpl-{uuid.uuid4().hex[:8]}",
created=int(time_module.time()),
model=request.model,
choices=[
ChatCompletionChoice(
index=0,
message=ChatMessage(role="assistant", content=answer),
finish_reason="stop",
)
],
usage={
"prompt_tokens": 0, # Not tracked
"completion_tokens": 0,
"total_tokens": 0,
},
)
return response
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
# Print startup info
console.print("\n[bold green]Starting RAG API server[/bold green]")
console.print(f" Collection: {collection}")
console.print(f" Hybrid retrieval: {'enabled' if hybrid else 'disabled'}")
console.print(f" Reranking: {'enabled' if rerank else 'disabled'}")
console.print(f" OpenAI-compat: {'enabled' if openai_compat else 'disabled'}")
console.print(f" URL: http://{host}:{port}")
console.print(f" Docs: http://{host}:{port}/docs")
console.print("\n[dim]Endpoints:[/dim]")
console.print(" GET /health")
console.print(" POST /rag/query")
console.print(" POST /rag/chat (SSE streaming)")
if openai_compat:
console.print(" POST /v1/chat/completions (OpenAI-compatible)")
console.print()
if profile_data:
profile_data["command"] = "rag serve"
profile_data["collection"] = collection
profile_data["hybrid"] = hybrid
profile_data["rerank"] = rerank
profile_data["openai_compat"] = openai_compat
uvicorn.run(api, host=host, port=port)
except ImportError as e:
console.print(f"[red]Error:[/red] Missing dependency: {e}")
console.print("Install with: pip install 'praisonaiagents[knowledge]'")
raise typer.Exit(1)
except Exception as e:
console.print(f"[red]Error:[/red] {e}")
if verbose:
import traceback
console.print(traceback.format_exc())
raise typer.Exit(1)
@@ -1,52 +0,0 @@
"""
Realtime command group for PraisonAI CLI.
Provides realtime interaction commands.
"""
from typing import Optional
import typer
app = typer.Typer(help="Realtime interaction mode")
@app.callback(invoke_without_command=True)
def realtime_main(
ctx: typer.Context,
model: Optional[str] = typer.Option(None, "--model", "-m", help="LLM model to use"),
verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
port: int = typer.Option(8085, "--port", "-p", help="Port for realtime UI"),
):
"""
Start realtime interaction mode.
Now routes to the new aiui-based realtime interface.
Examples:
praisonai realtime
praisonai realtime --model gpt-4o --port 9000
"""
import os
if model:
os.environ["MODEL_NAME"] = model
print("🎤 Launching PraisonAI Realtime Voice Interface...")
print("Note: Migrated from Chainlit to aiui.")
try:
from praisonai.cli.commands.ui import _launch_aiui_app
_launch_aiui_app(
app_dir="ui_realtime",
default_app_name="ui_realtime",
port=port,
host="127.0.0.1",
app_file=None,
reload=False,
ui_name="Realtime Voice"
)
except ImportError:
print('\033[91mERROR: Realtime UI is not installed.\033[0m')
print('Install with: pip install "praisonai[ui]"')
raise typer.Exit(1)
@@ -1,684 +0,0 @@
"""
Recipe command group for PraisonAI CLI.
Provides recipe management commands.
"""
import typer
app = typer.Typer(help="Recipe management")
@app.command("create")
def recipe_create(
goal: str = typer.Argument(..., help="Natural language goal for the recipe"),
output: str = typer.Option(".", "--output", "-o", help="Output directory"),
no_optimize: bool = typer.Option(False, "--no-optimize", help="Skip optimization loop"),
iterations: int = typer.Option(3, "--iterations", help="Max optimization iterations"),
threshold: float = typer.Option(8.0, "--threshold", help="Score threshold to stop"),
agents: str = typer.Option(None, "--agents", help="Custom agents (format: name:role=X,goal=Y;name2:...)"),
tools: str = typer.Option(None, "--tools", help="Custom tools per agent (format: agent:tool1,tool2;...)"),
agent_types: str = typer.Option(None, "--agent-types", help="Agent types (format: agent:image;agent2:audio;...)"),
):
"""Create a new recipe from a natural language goal.
Examples:
praisonai recipe create "Build a web scraper for news"
praisonai recipe create "Research AI trends" --no-optimize
praisonai recipe create "Research AI" --agents "researcher:role=AI Researcher,goal=Find papers"
praisonai recipe create "Research AI" --tools "researcher:internet_search,arxiv"
"""
from praisonai_code.cli.main import PraisonAI
import sys
argv = ['recipe', 'create', goal]
if output != ".":
argv.extend(['--output', output])
if no_optimize:
argv.append('--no-optimize')
if iterations != 3:
argv.extend(['--iterations', str(iterations)])
if threshold != 8.0:
argv.extend(['--threshold', str(threshold)])
if agents:
argv.extend(['--agents', agents])
if tools:
argv.extend(['--tools', tools])
if agent_types:
argv.extend(['--agent-types', agent_types])
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@app.command("list")
def recipe_list():
"""List available recipes."""
from praisonai_code.cli.main import PraisonAI
import sys
argv = ['recipe', 'list']
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@app.command("run")
def recipe_run(
name: str = typer.Argument(..., help="Recipe name"),
model: str = typer.Option(None, "--model", "-m", help="LLM model to use"),
var: list[str] = typer.Option(None, "--var", help="Variable override (key=value), can be used multiple times"),
save: bool = typer.Option(False, "--save", "-s", help="Save replay trace for debugging"),
trace_name: str = typer.Option(None, "--name", "-n", help="Custom trace name (e.g., run-abc123). Overwrites if exists."),
verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
output: str = typer.Option(None, "--output", "-o", help="Output mode: silent, status, trace, verbose, debug"),
debug: bool = typer.Option(False, "--debug", help="Enable debug mode"),
profile: bool = typer.Option(False, "--profile", help="Enable profiling"),
deep_profile: bool = typer.Option(False, "--deep-profile", help="Enable deep profiling"),
dry_run: bool = typer.Option(False, "--dry-run", help="Validate without executing"),
force: bool = typer.Option(False, "--force", help="Force execution even with missing deps"),
json_output: bool = typer.Option(False, "--json", help="Output in JSON format"),
):
"""Run a recipe with optional variable overrides.
Examples:
praisonai recipe run ai-url-blog-generator --var url="https://example.com/article"
praisonai recipe run ai-dynamic-blog-generator --var topic="LangGraph 0.3" --var style="coding"
praisonai recipe run ai-wordpress-post-generator --save --verbose
praisonai recipe run ai-topic-gatherer --save --name my-test-run --var topic="AI"
"""
from praisonai_code.cli.main import PraisonAI
import sys
argv = ['recipe', 'run', name]
if model:
argv.extend(['--model', model])
# Pass --var directly to the recipe handler (features/recipe.py)
# Don't convert to --workflow-var as that gets consumed by main.py's parser
if var:
for v in var:
argv.extend(['--var', v])
# Forward all flags to the recipe handler
if save:
argv.append('--save')
if trace_name:
argv.extend(['--name', trace_name])
if verbose:
argv.append('--verbose')
if output:
argv.extend(['--output', output])
if debug:
argv.append('--debug')
if profile:
argv.append('--profile')
if deep_profile:
argv.append('--deep-profile')
if dry_run:
argv.append('--dry-run')
if force:
argv.append('--force')
if json_output:
argv.append('--json')
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@app.command("info")
def recipe_info(
name: str = typer.Argument(..., help="Recipe name"),
verbose: bool = typer.Option(False, "--verbose", "-v", help="Show full YAML content"),
):
"""Show detailed information about a recipe.
Displays recipe metadata, agents, steps, and tools.
Examples:
praisonai recipe info image-to-blog-generator
praisonai recipe info url-to-blog-generator --verbose
"""
from pathlib import Path
from rich.console import Console
from rich.table import Table
from rich.panel import Panel
from rich.syntax import Syntax
import yaml
console = Console()
# Find recipe path
recipe_path = None
# Check template search paths
try:
from praisonai.recipe.core import get_template_search_paths
for search_path in get_template_search_paths():
candidate = search_path / name
if candidate.exists() and (candidate / "agents.yaml").exists():
recipe_path = candidate
break
except ImportError:
pass
# Also check agent_recipes package
if not recipe_path:
try:
import agent_recipes
if hasattr(agent_recipes, 'get_template_path'):
template_base = Path(agent_recipes.get_template_path(""))
candidate = template_base / name
if candidate.exists() and (candidate / "agents.yaml").exists():
recipe_path = candidate
except ImportError:
pass
# Check current directory
if not recipe_path:
candidate = Path(name)
if candidate.exists() and (candidate / "agents.yaml").exists():
recipe_path = candidate
if not recipe_path:
console.print(f"[red]❌ Recipe not found: {name}[/red]")
console.print("[dim]Hint: Run 'praisonai recipe list' to see available recipes[/dim]")
raise typer.Exit(1)
# Load agents.yaml
yaml_path = recipe_path / "agents.yaml"
try:
with open(yaml_path) as f:
config = yaml.safe_load(f)
except Exception as e:
console.print(f"[red]❌ Error loading recipe: {e}[/red]")
raise typer.Exit(1)
# Display recipe info
console.print()
console.print(Panel(f"[bold cyan]{name}[/bold cyan]", title="Recipe Info"))
# Metadata
metadata = config.get("metadata", {})
if metadata:
console.print("\n[bold]Metadata:[/bold]")
meta_table = Table(show_header=False, box=None)
meta_table.add_column("Key", style="dim")
meta_table.add_column("Value")
for key, value in metadata.items():
if isinstance(value, list):
value = ", ".join(str(v) for v in value)
elif isinstance(value, dict):
value = str(value)
meta_table.add_row(key, str(value))
console.print(meta_table)
# Topic
topic = config.get("topic", "Not specified")
console.print(f"\n[bold]Topic:[/bold] {topic}")
# Agents
agents = config.get("agents", {})
if agents:
console.print(f"\n[bold]Agents ({len(agents)}):[/bold]")
agent_table = Table(show_header=True, header_style="bold")
agent_table.add_column("Name", style="green")
agent_table.add_column("Role")
agent_table.add_column("Tools", style="cyan")
agent_table.add_column("LLM", style="dim")
for agent_name, agent_config in agents.items():
tools = agent_config.get("tools", [])
tools_str = ", ".join(tools) if tools else "-"
llm = agent_config.get("llm", "default")
agent_table.add_row(
agent_name,
agent_config.get("role", "-"),
tools_str,
str(llm)
)
console.print(agent_table)
# Steps
steps = config.get("steps", [])
if steps:
console.print(f"\n[bold]Steps ({len(steps)}):[/bold]")
step_table = Table(show_header=True, header_style="bold")
step_table.add_column("#", style="dim")
step_table.add_column("Name", style="green")
step_table.add_column("Agent", style="cyan")
step_table.add_column("Action (truncated)")
for i, step in enumerate(steps, 1):
step_name = step.get("name", f"step_{i}")
agent = step.get("agent", "-")
action = step.get("action", "")
# Truncate action for display
action_preview = action[:60].replace("\n", " ") + "..." if len(action) > 60 else action.replace("\n", " ")
step_table.add_row(str(i), step_name, agent, action_preview)
console.print(step_table)
# Tools.py
tools_path = recipe_path / "tools.py"
if tools_path.exists():
console.print("\n[bold]Custom Tools:[/bold] [green]✓ tools.py present[/green]")
else:
console.print("\n[bold]Custom Tools:[/bold] [dim]No tools.py[/dim]")
# Full YAML if verbose
if verbose:
console.print("\n[bold]Full YAML:[/bold]")
with open(yaml_path) as f:
yaml_content = f.read()
syntax = Syntax(yaml_content, "yaml", theme="monokai", line_numbers=True)
console.print(syntax)
# Path
console.print(f"\n[dim]Path: {recipe_path}[/dim]")
@app.command("install")
def recipe_install(
source: str = typer.Argument(..., help="Recipe source (path or URL)"),
):
"""Install a recipe."""
from praisonai_code.cli.main import PraisonAI
import sys
argv = ['recipe', 'install', source]
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@app.command("judge")
def recipe_judge(
trace_id: str = typer.Argument(..., help="Trace ID to judge (e.g., run-abc123)"),
yaml_file: str = typer.Option(None, "--yaml", "-y", help="YAML file path for fix recommendations"),
output: str = typer.Option(None, "--output", "-o", help="Output plan file (default: judge_plan.yaml)"),
dry_run: bool = typer.Option(False, "--dry-run", help="Show plan without saving"),
context: bool = typer.Option(False, "--context", help="Evaluate context flow between agents (default mode)"),
memory: bool = typer.Option(False, "--memory", help="Evaluate memory utilization (store/search effectiveness)"),
knowledge: bool = typer.Option(False, "--knowledge", help="Evaluate knowledge retrieval effectiveness"),
goal: str = typer.Option(None, "--goal", "-g", help="Override recipe goal for evaluation (extracted from YAML if not provided)"),
chunked: bool = typer.Option(False, "--chunked", help="Use chunked evaluation for large outputs (preserves all content)"),
no_auto_chunk: bool = typer.Option(False, "--no-auto-chunk", help="Disable automatic chunking (auto-chunk is enabled by default)"),
chunk_size: int = typer.Option(8000, "--chunk-size", help="Max characters per chunk (default: 8000)"),
max_chunks: int = typer.Option(5, "--max-chunks", help="Max chunks per agent (default: 5)"),
aggregation: str = typer.Option("weighted_average", "--aggregation", help="Score aggregation: weighted_average, average, min, max"),
):
"""Judge a recipe execution trace and generate fix recommendations.
Similar to 'terraform plan' - analyzes the trace and generates a plan of fixes.
The judge now includes:
- Dynamic failure detection (LLM determines if task failed)
- Previous step quality context (each agent sees how prior agents performed)
- Recipe goal evaluation (evaluates against overall workflow objective)
- Input validation status (detects unresolved template variables)
Evaluation Modes:
- --context: Evaluate context flow between agents (default)
- --memory: Evaluate memory utilization (store/search effectiveness)
- --knowledge: Evaluate knowledge retrieval effectiveness
Examples:
praisonai recipe judge run-abc123
praisonai recipe judge run-abc123 --goal "Analyze image and create blog post"
praisonai recipe judge run-abc123 --memory
praisonai recipe judge run-abc123 --knowledge
praisonai recipe judge run-abc123 --yaml agents.yaml --output plan.yaml
praisonai recipe judge run-abc123 --dry-run
"""
from praisonai.replay import (
ContextTraceReader,
ContextEffectivenessJudge,
generate_plan_from_report,
format_judge_report,
)
# Determine evaluation mode (default to context)
mode = "context"
if memory:
mode = "memory"
elif knowledge:
mode = "knowledge"
mode_emoji = {"context": "🔄", "memory": "🧠", "knowledge": "📚"}
# auto_chunk is True by default unless --no-auto-chunk is specified
auto_chunk = not no_auto_chunk
chunked_indicator = " [chunked]" if chunked else (" [auto-chunk]" if auto_chunk else "")
print(f"{mode_emoji.get(mode, '🔍')} Judging trace: {trace_id} (mode: {mode}){chunked_indicator}")
try:
reader = ContextTraceReader(trace_id)
events = reader.get_all()
if not events:
print(f"❌ No events found for trace: {trace_id}")
raise typer.Exit(1)
print(f" 📊 Found {len(events)} events")
if chunked:
print(f" 📦 Chunked evaluation: {chunk_size} chars/chunk, max {max_chunks} chunks, {aggregation} aggregation")
elif auto_chunk:
print(" 🔄 Auto-chunk: will chunk if content exceeds model context window")
# Run judge with mode-specific evaluation
judge = ContextEffectivenessJudge(
mode=mode,
chunked=chunked,
auto_chunk=auto_chunk,
chunk_size=chunk_size,
max_chunks=max_chunks,
aggregation_strategy=aggregation,
)
report = judge.judge_trace(events, session_id=trace_id, yaml_file=yaml_file)
# Display report
print(format_judge_report(report))
# Generate plan if yaml_file provided
if yaml_file:
plan = generate_plan_from_report(report, yaml_file=yaml_file)
print(plan.format_summary())
if not dry_run:
output_file = output or "judge_plan.yaml"
plan.save(output_file)
print(f"\n✅ Plan saved to: {output_file}")
print(f" Run 'praisonai recipe apply {output_file}' to apply fixes")
else:
print("\n💡 Tip: Add --yaml <file> to generate actionable fix recommendations")
except Exception as e:
print(f"❌ Error: {e}")
raise typer.Exit(1)
@app.command("optimize")
def recipe_optimize(
recipe_path: str = typer.Argument(..., help="Path to recipe folder"),
target: str = typer.Argument(None, help="Optional optimization target (e.g., 'improve error handling')"),
iterations: int = typer.Option(3, "--iterations", "-i", help="Maximum optimization iterations"),
threshold: float = typer.Option(8.0, "--threshold", "-t", help="Score threshold to stop (1-10)"),
input_data: str = typer.Option("", "--input", help="Input data for recipe runs"),
criteria: str = typer.Option(None, "--criteria", "-c", help="Custom evaluation criteria (for domain-agnostic optimization)"),
verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
):
"""Optimize a recipe using AI judge feedback.
Iteratively runs the recipe, judges output, and applies improvements.
Supports custom criteria for domain-agnostic optimization (e.g., water flow, data pipelines).
Examples:
praisonai recipe optimize my-recipe
praisonai recipe optimize my-recipe "improve error handling"
praisonai recipe optimize my-recipe --iterations 5 --threshold 9.0
praisonai recipe optimize my-recipe --input '{"query": "test"}'
praisonai recipe optimize my-recipe --criteria "Water flow is optimal: no leaks, pressure within range"
"""
from pathlib import Path
import logging
if verbose:
logging.basicConfig(level=logging.INFO)
recipe_path_obj = Path(recipe_path)
if not recipe_path_obj.exists():
print(f"❌ Recipe not found: {recipe_path}")
raise typer.Exit(1)
print(f"🔄 Optimizing recipe: {recipe_path}")
print(f" Max iterations: {iterations}")
print(f" Score threshold: {threshold}")
if target:
print(f" Target: {target}")
try:
from praisonai_code.cli.features.recipe_optimizer import RecipeOptimizer
# Create custom judge if criteria provided
custom_judge = None
if criteria:
try:
from praisonaiagents.eval import Judge
custom_judge = Judge(criteria=criteria)
print(f" Custom criteria: {criteria[:50]}{'...' if len(criteria) > 50 else ''}")
except ImportError:
print(" ⚠️ Custom criteria requires praisonaiagents. Using default judge.")
optimizer = RecipeOptimizer(
max_iterations=iterations,
score_threshold=threshold,
judge=custom_judge,
criteria=criteria,
)
final_report = optimizer.optimize(
recipe_path=recipe_path_obj,
input_data=input_data,
optimization_target=target,
)
if final_report:
score = getattr(final_report, 'overall_score', 0)
print(f"\n{'✅' if score >= threshold else '📊'} Final score: {score}/10")
if score >= threshold:
print(" Recipe optimization complete!")
else:
print(" Threshold not reached. Consider running more iterations.")
else:
print("❌ Optimization failed - no report generated")
raise typer.Exit(1)
except ImportError as e:
print(f"❌ Missing dependency: {e}")
print(" Install with: pip install litellm")
raise typer.Exit(1)
except Exception as e:
print(f"❌ Error: {e}")
raise typer.Exit(1)
@app.command("serve")
def recipe_serve(
recipe: str = typer.Argument(None, help="Recipe name or path to serve (optional, serves all if not specified)"),
host: str = typer.Option("127.0.0.1", "--host", "-h", help="Server host"),
port: int = typer.Option(8765, "--port", "-p", help="Server port"),
reload: bool = typer.Option(False, "--reload", "-r", help="Enable hot reload"),
workers: int = typer.Option(1, "--workers", "-w", help="Number of worker processes"),
config: str = typer.Option(None, "--config", "-c", help="Path to serve.yaml config file"),
api_key: str = typer.Option(None, "--api-key", help="API key for authentication"),
cors: str = typer.Option("*", "--cors", help="CORS origins (comma-separated or *)"),
metrics: bool = typer.Option(False, "--metrics", help="Enable /metrics endpoint"),
admin: bool = typer.Option(False, "--admin", help="Enable /admin endpoints"),
):
"""Start HTTP server for recipe execution.
DEPRECATED: Use `praisonai serve recipe` instead.
Serves recipes as REST API endpoints.
Examples:
praisonai recipe serve
praisonai recipe serve my-recipe --port 8080
praisonai recipe serve --host 0.0.0.0 --port 8000
praisonai recipe serve --config serve.yaml
praisonai recipe serve --api-key my-secret-key
praisonai recipe serve --workers 4
"""
import sys
# Print deprecation warning
print("\n\033[93m⚠ DEPRECATION WARNING:\033[0m", file=sys.stderr)
print("\033[93m'praisonai recipe serve' is deprecated and will be removed in a future version.\033[0m", file=sys.stderr)
print("\033[93mPlease use 'praisonai serve recipe' instead.\033[0m\n", file=sys.stderr)
print(f"🚀 Starting recipe server...")
print(f" Host: {host}")
print(f" Port: {port}")
if recipe:
print(f" Recipe: {recipe}")
if config:
print(f" Config: {config}")
if api_key:
print(f" Auth: API Key enabled")
if workers > 1:
print(f" Workers: {workers}")
auth_mode = serve_config.get("auth", "none")
if host not in ("127.0.0.1", "localhost") and auth_mode == "none" and not api_key:
print(
"\n\033[91mError:\033[0m Auth required for non-localhost binding. "
"Use --api-key or --auth api-key",
file=sys.stderr,
)
raise typer.Exit(1)
try:
from praisonai.recipe.serve import serve, load_config
# Load config from file if provided
serve_config = {}
if config:
serve_config = load_config(config)
# Override with CLI options
if api_key:
serve_config["api_key"] = api_key
serve_config["auth"] = "api-key"
if cors:
serve_config["cors_origins"] = cors
if metrics:
serve_config["enable_metrics"] = True
if admin:
serve_config["enable_admin"] = True
if recipe:
serve_config["recipes"] = [recipe]
print(f"\n📡 Server running at http://{host}:{port}")
print(f" Health: http://{host}:{port}/health")
print(f" Recipes: http://{host}:{port}/v1/recipes")
print(f" OpenAPI: http://{host}:{port}/openapi.json")
if metrics:
print(f" Metrics: http://{host}:{port}/metrics")
print(f"\n Press Ctrl+C to stop\n")
serve(
host=host,
port=port,
reload=reload,
config=serve_config,
workers=workers,
)
except ImportError as e:
print(f"❌ Missing dependency: {e}")
print(" Install with: pip install praisonai[serve]")
raise typer.Exit(1)
except Exception as e:
print(f"❌ Error: {e}")
raise typer.Exit(1)
@app.command("apply")
def recipe_apply(
plan_file: str = typer.Argument(..., help="Plan file to apply (from 'recipe judge')"),
confirm: bool = typer.Option(False, "--confirm", "-c", help="Apply without confirmation"),
fix_ids: str = typer.Option(None, "--fix-ids", help="Comma-separated fix IDs to apply"),
no_backup: bool = typer.Option(False, "--no-backup", help="Skip creating backup"),
dry_run: bool = typer.Option(False, "--dry-run", help="Preview changes without applying"),
):
"""Apply fixes from a judge plan to YAML files.
Similar to 'terraform apply' - applies the planned fixes.
Examples:
praisonai recipe apply judge_plan.yaml
praisonai recipe apply judge_plan.yaml --confirm
praisonai recipe apply judge_plan.yaml --fix-ids fix_001,fix_002
praisonai recipe apply judge_plan.yaml --dry-run
"""
from praisonai.replay import JudgePlan, PlanApplier
from pathlib import Path
print(f"📋 Loading plan: {plan_file}")
try:
if not Path(plan_file).exists():
print(f"❌ Plan file not found: {plan_file}")
raise typer.Exit(1)
plan = JudgePlan.load(plan_file)
applier = PlanApplier(plan)
# Show preview
print(applier.preview())
if dry_run:
print("\n🔍 Dry run - no changes applied")
return
# Confirm before applying
if not confirm:
response = typer.prompt("Apply these fixes? [y/N]", default="n")
if response.lower() != "y":
print("❌ Aborted")
raise typer.Exit(0)
# Parse fix IDs if provided
fix_id_list = None
if fix_ids:
fix_id_list = [f.strip() for f in fix_ids.split(",")]
# Apply fixes
result = applier.apply(backup=not no_backup, fix_ids=fix_id_list)
# Validate
if applier.validate():
print("✅ YAML validation passed")
else:
print("⚠️ YAML validation failed - consider rolling back")
if result.get("backup_path"):
print(f" Backup available at: {result['backup_path']}")
except Exception as e:
print(f"❌ Error: {e}")
raise typer.Exit(1)
@@ -1,830 +0,0 @@
"""
Replay command group for PraisonAI CLI.
Provides context replay functionality:
- replay list: List available traces
- replay context <session_id>: Interactive replay of a trace
"""
from typing import Optional
import typer
app = typer.Typer(help="Context replay for debugging agent execution")
@app.command("list")
def replay_list(
limit: int = typer.Option(
20,
"--limit",
"-n",
help="Maximum number of traces to show",
),
json_output: bool = typer.Option(
False,
"--json",
"-j",
help="Output as JSON",
),
):
"""List available context traces."""
from ..output.console import get_output_controller
output = get_output_controller()
try:
from praisonai.replay import list_traces
traces = list_traces(limit=limit)
except ImportError:
output.print_error("Replay module not available")
raise typer.Exit(1)
if json_output or output.is_json_mode:
output.print_json({
"traces": [t.to_dict() for t in traces]
})
return
if not traces:
output.print_info("No traces found. Run agents with trace=True to create traces.")
return
# Format as table
headers = ["Session ID", "Events", "Size", "Modified"]
rows = []
for trace in traces:
modified = trace.modified_at.strftime("%Y-%m-%d %H:%M")
size_kb = trace.size_bytes / 1024
size_str = f"{size_kb:.1f} KB" if size_kb >= 1 else f"{trace.size_bytes} B"
rows.append([
trace.session_id[:40] + "..." if len(trace.session_id) > 40 else trace.session_id,
str(trace.event_count),
size_str,
modified,
])
output.print_table(headers, rows, title="Available Context Traces")
output.print("\nUse 'praisonai replay context <session_id>' to replay a trace.")
@app.command("context")
def replay_context(
session_id: str = typer.Argument(
...,
help="Session ID or path to trace file",
),
start: int = typer.Option(
0,
"--start",
"-s",
help="Start from event number (1-based)",
),
no_rich: bool = typer.Option(
False,
"--no-rich",
help="Disable Rich formatting",
),
dump: bool = typer.Option(
False,
"--dump",
"-d",
help="Dump all events non-interactively (for scripting)",
),
json_output: bool = typer.Option(
False,
"--json",
"-j",
help="Output as JSON (implies --dump)",
),
full: bool = typer.Option(
False,
"--full",
"-f",
help="Show full context content (messages, responses) without truncation",
),
stats: bool = typer.Option(
False,
"--stats",
help="Show session statistics (tokens, costs, duplicates)",
),
cost: bool = typer.Option(
False,
"--cost",
help="Show cost breakdown only",
),
):
"""Interactive replay of a context trace.
Use --dump for non-interactive output (useful for scripting/verification).
Use --json for machine-readable output.
Use --full to show complete context content at each stage.
Use --stats to show session statistics (tokens, costs, duplicates).
Use --cost to show cost breakdown only.
"""
from ..output.console import get_output_controller
output = get_output_controller()
try:
from praisonai.replay import ContextTraceReader, ReplayPlayer
from praisonai.replay.analyzer import TraceAnalyzer, format_stats_output, format_cost_output
except ImportError:
output.print_error("Replay module not available")
raise typer.Exit(1)
# Load trace
reader = ContextTraceReader(session_id)
if not reader.exists:
output.print_error(f"Trace not found: {session_id}")
output.print_info("Use 'praisonai replay list' to see available traces.")
raise typer.Exit(1)
events = reader.get_all()
if not events:
output.print_error(f"No events in trace: {session_id}")
raise typer.Exit(1)
# Stats mode - show statistics only
if stats:
analyzer = TraceAnalyzer(events)
result = analyzer.analyze()
if json_output:
output.print_json(result.to_dict())
else:
output.print(format_stats_output(result))
return
# Cost mode - show cost breakdown only
if cost:
analyzer = TraceAnalyzer(events)
result = analyzer.analyze()
if json_output:
output.print_json({"cost": result.to_dict()["cost_stats"]})
else:
output.print(format_cost_output(result))
return
# JSON output implies dump mode
if json_output:
dump = True
# Non-interactive dump mode
if dump:
_dump_events(output, events, session_id, json_output, full)
return
output.print_success(f"Loaded {len(events)} events from: {session_id}")
# Create and run player with full mode support
player = ReplayPlayer(reader, use_rich=not no_rich, full_mode=full)
# Set start position if specified
if start > 0:
if player.goto(start - 1): # Convert to 0-based
output.print(f"Starting from event {start}")
else:
output.print_warning(f"Invalid start position. Valid range: 1-{len(events)}")
player.run()
def _dump_events(output, events, session_id: str, json_output: bool, full: bool = False):
"""Dump all events non-interactively.
Args:
output: Output controller
events: List of trace events
session_id: Session identifier
json_output: Whether to output as JSON
full: Whether to show full content without truncation
"""
# Import analyzer for stats
from praisonai.replay.analyzer import TraceAnalyzer
if json_output or output.is_json_mode:
# Include analysis in JSON output
analyzer = TraceAnalyzer(events)
result = analyzer.analyze()
output.print_json({
"session_id": session_id,
"total_events": len(events),
"analysis": result.to_dict(),
"events": [e.to_dict() if hasattr(e, 'to_dict') else e for e in events],
})
return
# Text output - formatted for readability
output.print(f"\n{'='*60}")
output.print(f" CONTEXT REPLAY: {session_id}")
output.print(f" Total Events: {len(events)}")
output.print(f"{'='*60}\n")
# Track running totals
running_prompt_tokens = 0
running_completion_tokens = 0
running_cost = 0.0
for i, event in enumerate(events):
# Get event details
if hasattr(event, 'event_type'):
et = event.event_type
if hasattr(et, 'value'):
et = et.value
else:
et = event.get('event_type', 'unknown')
agent_name = getattr(event, 'agent_name', None) or (event.get('agent_name') if isinstance(event, dict) else None)
seq = getattr(event, 'sequence_num', i) or (event.get('sequence_num', i) if isinstance(event, dict) else i)
branch_id = getattr(event, 'branch_id', None) or (event.get('branch_id') if isinstance(event, dict) else None)
# Get token/cost info
prompt_tokens = getattr(event, 'prompt_tokens', 0) or (event.get('prompt_tokens', 0) if isinstance(event, dict) else 0)
completion_tokens = getattr(event, 'completion_tokens', 0) or (event.get('completion_tokens', 0) if isinstance(event, dict) else 0)
cost_usd = getattr(event, 'cost_usd', 0) or (event.get('cost_usd', 0) if isinstance(event, dict) else 0)
# Update running totals
running_prompt_tokens += prompt_tokens
running_completion_tokens += completion_tokens
running_cost += cost_usd
# Format event header
header = f"[{seq + 1:3d}] {et.upper()}"
if agent_name:
header += f" ({agent_name})"
if branch_id:
header += f" [branch: {branch_id}]"
output.print(f"\n{'-'*50}")
output.print(header)
# Show token/cost info for LLM events
if prompt_tokens or completion_tokens:
output.print(f" prompt_tokens: {prompt_tokens:,}")
output.print(f" completion_tokens: {completion_tokens:,}")
if cost_usd:
output.print(f" cost: ${cost_usd:.6f}")
# Show event-specific data
data = getattr(event, 'data', None) or (event.get('data') if isinstance(event, dict) else None)
if data:
for key, value in data.items():
if value is not None:
value_str = str(value)
# Full mode: show complete content; otherwise truncate
if not full and len(value_str) > 100:
value_str = value_str[:100] + "..."
# For messages array in full mode, format nicely
if full and key == "messages" and isinstance(value, list):
output.print(f" {key}:")
for msg_idx, msg in enumerate(value):
role = msg.get("role", "unknown") if isinstance(msg, dict) else "unknown"
content = msg.get("content", "") if isinstance(msg, dict) else str(msg)
output.print(f" [{msg_idx + 1}] {role}:")
# Indent content lines
for line in str(content).split('\n')[:20]: # Limit lines in full mode
output.print(f" {line}")
if len(str(content).split('\n')) > 20:
output.print(f" ... ({len(str(content).split(chr(10)))} lines total)")
else:
output.print(f" {key}: {value_str}")
# Show token info if available (legacy field)
tokens_used = getattr(event, 'tokens_used', 0) or (event.get('tokens_used', 0) if isinstance(event, dict) else 0)
if tokens_used and not prompt_tokens and not completion_tokens:
output.print(f" tokens_used: {tokens_used}")
# Show summary with running totals
output.print(f"\n{'='*60}")
output.print(f" SESSION SUMMARY")
output.print(f"{'='*60}")
output.print(f" Total Events: {len(events)}")
output.print(f" Prompt Tokens: {running_prompt_tokens:,}")
output.print(f" Completion Tokens: {running_completion_tokens:,}")
output.print(f" Total Tokens: {running_prompt_tokens + running_completion_tokens:,}")
output.print(f" Total Cost: ${running_cost:.6f}")
# Show duplicate analysis if in full mode
if full:
analyzer = TraceAnalyzer(events)
result = analyzer.analyze()
if result.duplicates:
output.print(f"\n DUPLICATE CONTENT DETECTED:")
output.print(f" Duplicates Found: {result.duplicate_count}")
output.print(f" Wasted Tokens: {result.total_wasted_tokens:,}")
for dup in result.duplicates[:3]:
output.print(f" - Hash {dup.content_hash[:12]}... ({dup.duplicate_count}x, ~{dup.wasted_tokens:,} wasted)")
else:
output.print(f"\n CONTEXT EFFICIENCY: No duplicates detected ✓")
output.print(f"{'='*60}\n")
@app.command("show")
def replay_show(
session_id: str = typer.Argument(
...,
help="Session ID or path to trace file",
),
event_num: int = typer.Option(
None,
"--event",
"-e",
help="Show specific event number (1-based)",
),
agent: Optional[str] = typer.Option(
None,
"--agent",
"-a",
help="Filter by agent name",
),
event_type: Optional[str] = typer.Option(
None,
"--type",
"-t",
help="Filter by event type",
),
json_output: bool = typer.Option(
False,
"--json",
"-j",
help="Output as JSON",
),
):
"""Show trace events without interactive mode."""
from ..output.console import get_output_controller
output = get_output_controller()
try:
from praisonai.replay import ContextTraceReader
except ImportError:
output.print_error("Replay module not available")
raise typer.Exit(1)
reader = ContextTraceReader(session_id)
if not reader.exists:
output.print_error(f"Trace not found: {session_id}")
raise typer.Exit(1)
# Get events with filters
if event_num is not None:
events = [reader[event_num - 1]] if 0 < event_num <= len(reader) else []
elif agent:
events = reader.get_by_agent(agent)
elif event_type:
events = reader.get_by_type(event_type)
else:
events = reader.get_all()
if json_output or output.is_json_mode:
output.print_json({
"session_id": session_id,
"total_events": len(reader),
"filtered_events": len(events),
"events": [e.to_dict() if hasattr(e, 'to_dict') else e for e in events],
})
return
if not events:
output.print_info("No events match the filter criteria.")
return
output.print(f"Session: {session_id}")
output.print(f"Showing {len(events)} of {len(reader)} events\n")
for i, event in enumerate(events):
# Get event details
if hasattr(event, 'event_type'):
et = event.event_type
if hasattr(et, 'value'):
et = et.value
else:
et = event.get('event_type', 'unknown')
agent_name = getattr(event, 'agent_name', None) or (event.get('agent_name') if isinstance(event, dict) else None)
seq = getattr(event, 'sequence_num', i) or (event.get('sequence_num', i) if isinstance(event, dict) else i)
line = f"[{seq + 1}] {et.upper()}"
if agent_name:
line += f" ({agent_name})"
output.print(line)
@app.command("delete")
def replay_delete(
session_id: str = typer.Argument(
...,
help="Session ID to delete",
),
force: bool = typer.Option(
False,
"--force",
"-f",
help="Skip confirmation",
),
):
"""Delete a context trace."""
from ..output.console import get_output_controller
output = get_output_controller()
try:
from praisonai.replay import ContextTraceReader
except ImportError:
output.print_error("Replay module not available")
raise typer.Exit(1)
reader = ContextTraceReader(session_id)
if not reader.exists:
output.print_error(f"Trace not found: {session_id}")
raise typer.Exit(1)
if not force:
confirm = typer.confirm(f"Delete trace '{session_id}' ({len(reader)} events)?")
if not confirm:
output.print("Cancelled.")
raise typer.Exit(0)
try:
reader.path.unlink()
output.print_success(f"Deleted trace: {session_id}")
except Exception as e:
output.print_error(f"Failed to delete: {e}")
raise typer.Exit(1)
@app.command("flow")
def replay_flow(
session_id: str = typer.Argument(
...,
help="Session ID or path to trace file",
),
format: str = typer.Option(
"ascii",
"--format",
"-f",
help="Output format: ascii, json",
),
):
"""Visualize agent flow from a context trace."""
from ..output.console import get_output_controller
output = get_output_controller()
try:
from praisonai.replay import ContextTraceReader
except ImportError:
output.print_error("Replay module not available")
raise typer.Exit(1)
reader = ContextTraceReader(session_id)
if not reader.exists:
output.print_error(f"Trace not found: {session_id}")
raise typer.Exit(1)
events = reader.get_all()
if not events:
output.print_error(f"No events in trace: {session_id}")
raise typer.Exit(1)
# Build flow structure from events
flow_data = _build_flow_from_events(events)
if format == "json":
output.print_json(flow_data)
return
# ASCII visualization
_print_ascii_flow(output, flow_data, session_id)
def _build_flow_from_events(events) -> dict:
"""Build flow structure from context events."""
agents = {}
handoffs = []
for event in events:
event_type = event.event_type
if hasattr(event_type, 'value'):
event_type = event_type.value
agent_name = event.agent_name
if event_type == "agent_start" and agent_name:
if agent_name not in agents:
agents[agent_name] = {
"name": agent_name,
"start_time": event.timestamp,
"end_time": None,
"messages": 0,
"tool_calls": [],
"llm_calls": 0,
}
elif event_type == "agent_end" and agent_name:
if agent_name in agents:
agents[agent_name]["end_time"] = event.timestamp
elif event_type == "agent_handoff":
handoffs.append({
"from": event.data.get("from_agent"),
"to": event.data.get("to_agent"),
"reason": event.data.get("reason"),
"timestamp": event.timestamp,
})
elif event_type == "message_added" and agent_name:
if agent_name in agents:
agents[agent_name]["messages"] += 1
elif event_type == "tool_call_start" and agent_name:
tool_name = event.data.get("tool_name", "unknown")
if agent_name in agents:
agents[agent_name]["tool_calls"].append(tool_name)
elif event_type == "llm_request" and agent_name:
if agent_name in agents:
agents[agent_name]["llm_calls"] += 1
# Determine agent order based on start times
agent_order = sorted(
agents.keys(),
key=lambda a: agents[a].get("start_time", 0) or 0
)
return {
"session_id": events[0].session_id if events else "",
"agents": agents,
"agent_order": agent_order,
"handoffs": handoffs,
"total_events": len(events),
}
def _print_ascii_flow(output, flow_data: dict, session_id: str):
"""Print ASCII flow visualization."""
agents = flow_data["agents"]
agent_order = flow_data["agent_order"]
handoffs = flow_data["handoffs"]
output.print(f"\n{'='*60}")
output.print(f" AGENT FLOW: {session_id}")
output.print(f"{'='*60}\n")
if not agent_order:
output.print(" No agents found in trace.")
return
# Print agent boxes with connections
for i, agent_name in enumerate(agent_order):
agent = agents[agent_name]
# Agent box
box_width = 40
output.print(f" ┌{'─' * box_width}┐")
output.print(f" │ {'Agent: ' + agent_name:<{box_width-2}} │")
output.print(f" │{' ' * box_width}│")
output.print(f" │ Messages: {agent['messages']:<{box_width-15}} │")
output.print(f" │ LLM Calls: {agent['llm_calls']:<{box_width-16}} │")
if agent['tool_calls']:
tools_str = ", ".join(agent['tool_calls'][:3])
if len(agent['tool_calls']) > 3:
tools_str += f" (+{len(agent['tool_calls'])-3} more)"
output.print(f" │ Tools: {tools_str:<{box_width-13}} │")
output.print(f" └{'─' * box_width}┘")
# Connection to next agent
if i < len(agent_order) - 1:
next_agent = agent_order[i + 1]
# Find handoff reason
handoff_reason = None
for h in handoffs:
if h["from"] == agent_name and h["to"] == next_agent:
handoff_reason = h.get("reason")
break
output.print(" │")
output.print(" ▼")
if handoff_reason:
reason_short = handoff_reason[:35] + "..." if len(handoff_reason) > 35 else handoff_reason
output.print(f" [{reason_short}]")
output.print("")
# Summary
output.print("\n" + "─"*60)
output.print(" Summary:")
output.print(f" Agents: {len(agent_order)}")
output.print(f" Handoffs: {len(handoffs)}")
output.print(f" Total Events: {flow_data['total_events']}")
output.print("─"*60 + "\n")
@app.command("dashboard")
def replay_dashboard(
session_id: Optional[str] = typer.Argument(
None,
help="Session ID (optional, shows all recent if not specified)",
),
limit: int = typer.Option(
5,
"--limit",
"-n",
help="Number of recent sessions to analyze",
),
json_output: bool = typer.Option(
False,
"--json",
"-j",
help="Output as JSON",
),
):
"""Show context analytics dashboard with usage patterns.
Displays:
- Token usage by agent (bar chart)
- Cost breakdown by tool
- Duplicate content patterns
- Optimization recommendations
"""
from ..output.console import get_output_controller
output = get_output_controller()
try:
from praisonai.replay import list_traces, ContextTraceReader
from praisonai.replay.analyzer import TraceAnalyzer
except ImportError:
output.print_error("Replay module not available")
raise typer.Exit(1)
# Get traces to analyze
if session_id:
traces_to_analyze = [session_id]
else:
traces = list_traces(limit=limit)
if not traces:
output.print_info("No traces found. Run agents with trace=True to create traces.")
return
traces_to_analyze = [t.session_id for t in traces]
# Aggregate stats across sessions
all_stats = []
total_tokens = 0
total_cost = 0.0
total_duplicates = 0
total_wasted = 0
agent_tokens = {}
tool_costs = {}
for sid in traces_to_analyze:
reader = ContextTraceReader(sid)
if not reader.exists:
continue
events = reader.get_all()
if not events:
continue
analyzer = TraceAnalyzer(events)
result = analyzer.analyze()
all_stats.append(result)
# Aggregate using correct attribute paths
total_tokens += result.token_stats.total_tokens
total_cost += result.cost_stats.total_cost
total_duplicates += result.duplicate_count
total_wasted += result.total_wasted_tokens
# By agent - tokens is a dict with {prompt, completion, total}
for agent, tokens in result.token_stats.by_agent.items():
agent_total = tokens.get("total", 0) if isinstance(tokens, dict) else tokens
agent_tokens[agent] = agent_tokens.get(agent, 0) + agent_total
# By tool - cost is a dict with {count, cost}
for tool, cost_info in result.cost_stats.by_tool.items():
tool_cost = cost_info.get("cost", 0.0) if isinstance(cost_info, dict) else cost_info
tool_costs[tool] = tool_costs.get(tool, 0.0) + tool_cost
if json_output:
output.print_json({
"sessions_analyzed": len(all_stats),
"total_tokens": total_tokens,
"total_cost": total_cost,
"total_duplicates": total_duplicates,
"total_wasted_tokens": total_wasted,
"tokens_by_agent": agent_tokens,
"cost_by_tool": tool_costs,
})
return
# Display dashboard
output.print("\n" + "="*70)
output.print(" CONTEXT ANALYTICS DASHBOARD")
output.print("="*70)
output.print(f"\n Sessions Analyzed: {len(all_stats)}")
output.print(f" Total Tokens: {total_tokens:,}")
output.print(f" Total Cost: ${total_cost:.4f}")
# Token usage by agent (bar chart)
if agent_tokens:
output.print("\n TOKEN USAGE BY AGENT:")
output.print(" " + "-"*50)
max_tokens = max(agent_tokens.values()) if agent_tokens else 1
for agent, tokens in sorted(agent_tokens.items(), key=lambda x: -x[1]):
bar_len = int((tokens / max_tokens) * 30)
bar = "█" * bar_len
pct = (tokens / total_tokens * 100) if total_tokens > 0 else 0
output.print(f" {agent[:20]:<20} {bar:<30} {tokens:>10,} ({pct:.1f}%)")
# Cost by tool
if tool_costs:
output.print("\n COST BY TOOL:")
output.print(" " + "-"*50)
for tool, cost in sorted(tool_costs.items(), key=lambda x: -x[1]):
output.print(f" {tool:<25} ${cost:.4f}")
# Context efficiency
output.print("\n CONTEXT EFFICIENCY:")
output.print(" " + "-"*50)
if total_duplicates > 0:
waste_pct = (total_wasted / total_tokens * 100) if total_tokens > 0 else 0
output.print(f" ⚠️ Duplicates Found: {total_duplicates}")
output.print(f" ⚠️ Wasted Tokens: {total_wasted:,} ({waste_pct:.1f}%)")
output.print("\n RECOMMENDATIONS:")
output.print(" • Enable session-level deduplication in workflow")
output.print(" • Consider using context: {strategy: smart} in YAML")
output.print(" • Review tool outputs for redundant content")
else:
output.print(" ✅ Context is optimized! No duplicates detected.")
output.print("\n" + "="*70 + "\n")
@app.command("cleanup")
def replay_cleanup(
max_age_days: int = typer.Option(
7,
"--max-age",
help="Delete traces older than N days",
),
max_size_mb: int = typer.Option(
100,
"--max-size",
help="Delete oldest traces if total size exceeds N MB",
),
dry_run: bool = typer.Option(
False,
"--dry-run",
help="Show what would be deleted without deleting",
),
):
"""Clean up old context traces."""
from ..output.console import get_output_controller
output = get_output_controller()
try:
from praisonai.replay.storage import cleanup_old_traces, list_traces
except ImportError:
output.print_error("Replay module not available")
raise typer.Exit(1)
if dry_run:
from datetime import datetime
traces = list_traces(limit=10000)
now = datetime.now()
to_delete = []
for trace in traces:
age_days = (now - trace.modified_at).days
if age_days > max_age_days:
to_delete.append(trace)
if to_delete:
output.print(f"Would delete {len(to_delete)} traces older than {max_age_days} days:")
for t in to_delete[:10]:
output.print(f" - {t.session_id}")
if len(to_delete) > 10:
output.print(f" ... and {len(to_delete) - 10} more")
else:
output.print("No traces would be deleted.")
return
deleted = cleanup_old_traces(max_age_days=max_age_days, max_size_mb=max_size_mb)
if deleted > 0:
output.print_success(f"Deleted {deleted} old traces.")
else:
output.print_info("No traces needed cleanup.")
@@ -1,529 +0,0 @@
"""
Schedule command group for PraisonAI CLI.
Provides scheduler management.
"""
from typing import Optional
import typer
from ..output.console import get_output_controller
app = typer.Typer(help="Scheduler management")
def _run_schedule(args: list) -> int:
"""Run schedule command with args."""
try:
from praisonai_code.cli.features.agent_scheduler import AgentSchedulerHandler
# Parse subcommand
if args and args[0] in ['start', 'list', 'stop', 'logs', 'restart', 'delete', 'describe', 'save', 'stop-all', 'stats']:
subcommand = args[0]
remaining = args[1:] if len(args) > 1 else []
# Create minimal args namespace
class Args:
pass
fake_args = Args()
return AgentSchedulerHandler.handle_daemon_command(subcommand, fake_args, remaining)
return 0
except ImportError as e:
output = get_output_controller()
output.print_error(f"Scheduler module not available: {e}")
return 4
@app.command("add")
def schedule_add_cmd(
name: str = typer.Argument(..., help="Schedule name (e.g. 'morning-hello')"),
schedule: str = typer.Option(..., "--schedule", "-s", help="When to run: 'hourly', 'daily', '*/30m', 'cron:0 9 * * *', 'at:2026-03-01T09:00', 'in 20 minutes'"),
message: str = typer.Option("", "--message", "-m", help="Prompt / reminder text"),
agent: str = typer.Option("", "--agent", "-a", help="Agent ID to execute this job (default: first registered agent)"),
deliver: str = typer.Option("", "--deliver", "-d", help="Delivery token: 'origin', 'telegram', 'all', or 'platform:chat_id[:thread_id]'"),
channel: str = typer.Option("", "--channel", help="[Legacy] Delivery platform: telegram, discord, slack, whatsapp"),
channel_id: str = typer.Option("", "--channel-id", help="[Legacy] Target chat/channel ID on the platform"),
session_id: str = typer.Option("", "--session-id", help="Session ID to preserve conversation context"),
pre_run: str = typer.Option("", "--pre-run", help="Cheap pre-run gate command: exit 0 + output => run (output seeds the prompt); non-zero => skip (no model tokens, no delivery)"),
condition: str = typer.Option("", "--condition", help="Natural-language / expression alias for the pre-run gate"),
json_output: bool = typer.Option(False, "--json", help="Output JSON"),
):
"""Add a job to the schedule store (with optional delivery target).
Examples:
praisonai schedule add "morning-hello" -s "cron:0 9 * * *" -m "say hello"
praisonai schedule add "news" -s daily -m "news summary" --deliver telegram
praisonai schedule add "report" -s hourly -m "status report" --deliver all
praisonai schedule add "tg-reminder" -s daily -m "check email" --agent support --channel telegram --channel-id 12345
praisonai schedule add "inbox-watch" -s "*/5m" -m "Summarise new emails" --pre-run "scripts/new_mail.sh" --deliver telegram
"""
output = get_output_controller()
try:
from praisonaiagents.tools.schedule_tools import schedule_add as _schedule_add
# Build delivery target based on new or legacy format
delivery_kwargs = {}
if deliver:
# New token-based delivery
delivery_kwargs["deliver"] = deliver
elif channel or channel_id:
# Legacy explicit channel/channel_id
delivery_kwargs["channel"] = channel
delivery_kwargs["channel_id"] = channel_id
if session_id:
delivery_kwargs["session_id"] = session_id
result = _schedule_add(
name=name,
schedule=schedule,
message=message,
agent_id=agent,
**delivery_kwargs
)
# ``pre_run``/``condition`` run an arbitrary host shell command, so they
# are NOT part of the LLM-callable schedule_add surface. The CLI is a
# trusted, human-driven surface, so set them on the stored job here.
if (pre_run or condition) and "Error" not in result:
try:
from praisonaiagents.tools.schedule_tools import _get_store
store = _get_store()
job = store.get_by_name(name)
if job is not None:
job.pre_run = pre_run or None
job.condition = condition or None
store.update(job)
except Exception as e:
output.print_error(f"Failed to set pre-run gate: {e}")
raise typer.Exit(1)
if json_output:
import json as _json
print(_json.dumps({"result": result}))
else:
if "Error" in result:
output.print_error(result)
raise typer.Exit(1)
else:
output.print_success(result)
except ImportError as e:
output.print_error(f"Schedule tools not available: {e}")
raise typer.Exit(4)
@app.command("start")
def schedule_start(
agents_file: str = typer.Argument("agents.yaml", help="Agents YAML file"),
interval: Optional[str] = typer.Option(None, "--interval", "-i", help="Schedule interval (e.g., 'hourly', '*/30m')"),
daemon: bool = typer.Option(True, "--daemon/--no-daemon", help="Run as daemon"),
name: Optional[str] = typer.Option(None, "--name", "-n", help="Job name"),
):
"""Start scheduled agent execution."""
args = ["start", agents_file]
if interval:
args.extend(["--interval", interval])
if not daemon:
args.append("--no-daemon")
if name:
args.extend(["--name", name])
raise typer.Exit(_run_schedule(args))
@app.command("stop")
def schedule_stop(
job_id: Optional[str] = typer.Argument(None, help="Job ID to stop (or 'all')"),
):
"""Stop scheduled job(s)."""
if job_id == "all":
raise typer.Exit(_run_schedule(["stop-all"]))
elif job_id:
raise typer.Exit(_run_schedule(["stop", job_id]))
else:
raise typer.Exit(_run_schedule(["stop"]))
@app.command("list")
def schedule_list(
json_output: bool = typer.Option(False, "--json", help="Output JSON"),
):
"""List scheduled jobs."""
args = ["list"]
if json_output:
args.append("--json")
raise typer.Exit(_run_schedule(args))
@app.command("logs")
def schedule_logs(
job_id: Optional[str] = typer.Argument(None, help="Job ID"),
tail: int = typer.Option(50, "--tail", "-n", help="Number of lines"),
follow: bool = typer.Option(False, "--follow", "-f", help="Follow log output"),
):
"""View scheduler logs."""
args = ["logs"]
if job_id:
args.append(job_id)
args.extend(["--tail", str(tail)])
if follow:
args.append("--follow")
raise typer.Exit(_run_schedule(args))
@app.command("restart")
def schedule_restart(
job_id: str = typer.Argument(..., help="Job ID to restart"),
):
"""Restart a scheduled job."""
raise typer.Exit(_run_schedule(["restart", job_id]))
@app.command("delete")
def schedule_delete(
job_id: str = typer.Argument(..., help="Job ID to delete"),
confirm: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation"),
):
"""Delete a scheduled job."""
if not confirm:
confirmed = typer.confirm(f"Delete job {job_id}?")
if not confirmed:
output = get_output_controller()
output.print_info("Cancelled")
raise typer.Exit(0)
raise typer.Exit(_run_schedule(["delete", job_id]))
@app.command("describe")
def schedule_describe(
job_id: str = typer.Argument(..., help="Job ID"),
json_output: bool = typer.Option(False, "--json", help="Output JSON"),
):
"""Show job details."""
args = ["describe", job_id]
if json_output:
args.append("--json")
raise typer.Exit(_run_schedule(args))
@app.command("stats")
def schedule_stats(
json_output: bool = typer.Option(False, "--json", help="Output JSON"),
):
"""Show scheduler statistics."""
args = ["stats"]
if json_output:
args.append("--json")
raise typer.Exit(_run_schedule(args))
# ── Blueprint commands ─────────────────────────────────────────────────
@app.command("blueprint")
def schedule_blueprint(
blueprint_name: str = typer.Argument(..., help="Blueprint name (morning-brief, important-mail, weekly-review)"),
hour: Optional[int] = typer.Option(None, "--hour", help="Delivery hour (0-23)"),
minute: Optional[int] = typer.Option(None, "--minute", help="Delivery minute (0-59)"),
weekdays: Optional[str] = typer.Option(None, "--weekdays", help="Days: mon-fri, daily, weekends, or a single day"),
focus: Optional[str] = typer.Option(None, "--focus", help="Focus area"),
interval: Optional[int] = typer.Option(None, "--interval", help="Interval in minutes (for interval-based blueprints)"),
keywords: Optional[str] = typer.Option(None, "--keywords", help="Priority keywords (for important-mail)"),
deliver: str = typer.Option("", "--deliver", "-d", help="Delivery target"),
agent: str = typer.Option("", "--agent", "-a", help="Agent ID"),
json_output: bool = typer.Option(False, "--json", help="Output JSON"),
):
"""Create a schedule from a blueprint template.
Examples:
praisonai schedule blueprint morning-brief --hour 8 --weekdays mon-fri --deliver telegram
praisonai schedule blueprint important-mail --interval 30 --keywords urgent,deadline --deliver telegram
praisonai schedule blueprint weekly-review --hour 17 --weekdays fri --focus tech
"""
output = get_output_controller()
try:
from praisonai.scheduler.blueprint_catalogue import BlueprintCatalogue
from praisonaiagents.tools.schedule_tools import schedule_add as _schedule_add
catalogue = BlueprintCatalogue()
bp = catalogue.get_blueprint(blueprint_name)
if bp is None:
available = [b.name for b in catalogue.list_blueprints()]
output.print_error(
f"Blueprint '{blueprint_name}' not found. "
f"Available: {', '.join(available)}"
)
raise typer.Exit(1)
# Build slots dict from CLI args
cli_slot_map: dict = {
"hour": hour, "minute": minute, "weekdays": weekdays,
"focus": focus, "interval_minutes": interval, "keywords": keywords,
}
slots = {}
for slot in bp.slots:
cli_val = cli_slot_map.get(slot.name)
if cli_val is not None:
slots[slot.name] = cli_val
resolved = catalogue.resolve_slots(bp, slots)
prompt = catalogue.materialize_prompt(bp, resolved)
schedule_expr = catalogue.materialize_schedule(bp, resolved)
final_deliver = deliver or bp.default_deliver
result = _schedule_add(
name=blueprint_name,
schedule=schedule_expr,
message=prompt,
deliver=final_deliver,
agent_id=agent or bp.default_agent,
)
if json_output:
import json as _json
print(_json.dumps({"result": result, "blueprint": blueprint_name,
"schedule": schedule_expr}))
else:
output.print_success(result)
except typer.Exit:
raise
except Exception as e:
output.print_error(str(e))
raise typer.Exit(1)
@app.command("blueprint-list")
def schedule_blueprint_list(
category: Optional[str] = typer.Option(None, "--category", "-c", help="Filter by category"),
json_output: bool = typer.Option(False, "--json", help="Output JSON"),
):
"""List available blueprints."""
output = get_output_controller()
try:
from praisonai.scheduler.blueprint_catalogue import BlueprintCatalogue
catalogue = BlueprintCatalogue()
blueprints = catalogue.list_blueprints(category=category)
if json_output:
import json as _json
print(_json.dumps([{
"name": bp.name,
"description": bp.description,
"category": bp.category,
"tags": bp.tags,
"slots": [
{"name": s.name, "type": s.type, "label": s.label,
"default": s.default, "choices": s.choices}
for s in bp.slots
],
"default_deliver": bp.default_deliver,
"builtin": bp.builtin,
} for bp in blueprints]))
else:
if not blueprints:
output.print_info("No blueprints found.")
else:
output.print_header(f"Available blueprints ({len(blueprints)}):")
for bp in blueprints:
slot_desc = ", ".join(
f"{s.name}={s.default}" if s.default is not None else s.name
for s in bp.slots
)
tag = " [builtin]" if bp.builtin else " [custom]"
output.print_info(f" {bp.name} [{bp.category}]{tag} — {bp.description}")
output.print_info(f" Slots: {slot_desc}")
if bp.default_deliver:
output.print_info(f" Default delivery: {bp.default_deliver}")
except Exception as e:
output.print_error(str(e))
raise typer.Exit(1)
# ── Suggestion commands ─────────────────────────────────────────────────
@app.command("suggestions")
def schedule_suggestions(
json_output: bool = typer.Option(False, "--json", help="Output JSON"),
):
"""List pending automation suggestions."""
output = get_output_controller()
try:
from praisonai.scheduler.suggestion_engine import SuggestionEngine
engine = SuggestionEngine()
pending = engine.pending()
if json_output:
import json as _json
print(_json.dumps([{
"id": s.id,
"blueprint_name": s.blueprint_name,
"slots": s.slots,
"reason": s.reason,
"created_at": s.created_at,
"expires_at": s.expires_at,
} for s in pending]))
else:
if not pending:
output.print_info("No pending suggestions.")
else:
output.print_header(f"Suggestions ({len(pending)}):")
for s in pending:
slot_str = ", ".join(f"{k}={v}" for k, v in s.slots.items())
output.print_info(f" [{s.id}] Blueprint: {s.blueprint_name}")
output.print_info(f" Reason: {s.reason or 'N/A'}")
output.print_info(f" Slots: {slot_str or '(defaults)'}")
output.print_info(f" Accept: praisonai schedule suggestion-accept {s.id}")
output.print_info(f" Dismiss: praisonai schedule suggestion-dismiss {s.id}")
except Exception as e:
output.print_error(str(e))
raise typer.Exit(1)
@app.command("suggestion-accept")
def schedule_suggestion_accept(
suggestion_id: str = typer.Argument(..., help="Suggestion ID to accept"),
deliver: str = typer.Option("", "--deliver", "-d", help="Override delivery target"),
):
"""Accept a suggestion and create the schedule job."""
output = get_output_controller()
try:
from praisonai.scheduler.suggestion_engine import SuggestionEngine
from praisonai.scheduler.blueprint_catalogue import BlueprintCatalogue
from praisonaiagents.tools.schedule_tools import schedule_add as _schedule_add
engine = SuggestionEngine()
sug = engine.get_suggestion(suggestion_id)
if sug is None or sug.dismissed or sug.accepted:
output.print_error(f"Suggestion '{suggestion_id}' not found or already handled.")
raise typer.Exit(1)
# Reject expired suggestions (expires_at == 0 means no expiry)
import time as _time
if sug.expires_at != 0 and sug.expires_at <= _time.time():
output.print_error(f"Suggestion '{suggestion_id}' has expired.")
raise typer.Exit(1)
catalogue = BlueprintCatalogue()
bp = catalogue.get_blueprint(sug.blueprint_name)
if bp is None:
output.print_error(f"Blueprint '{sug.blueprint_name}' for suggestion not found.")
raise typer.Exit(1)
resolved = catalogue.resolve_slots(bp, sug.slots)
prompt = catalogue.materialize_prompt(bp, resolved)
schedule_expr = catalogue.materialize_schedule(bp, resolved)
final_deliver = deliver or sug.deliver or bp.default_deliver
result = _schedule_add(
name=sug.blueprint_name,
schedule=schedule_expr,
message=prompt,
deliver=final_deliver,
accept_suggestion=suggestion_id,
)
if result.startswith("Error") or "already exists" in result:
output.print_error(result)
raise typer.Exit(1)
output.print_success(f"Suggestion accepted. {result}")
except typer.Exit:
raise
except Exception as e:
output.print_error(str(e))
raise typer.Exit(1)
@app.command("suggestion-dismiss")
def schedule_suggestion_dismiss(
suggestion_id: str = typer.Argument(..., help="Suggestion ID to dismiss"),
):
"""Dismiss a suggestion without creating a job."""
output = get_output_controller()
try:
from praisonai.scheduler.suggestion_engine import SuggestionEngine
engine = SuggestionEngine()
ok = engine.dismiss(suggestion_id)
if ok:
output.print_info(f"Suggestion '{suggestion_id}' dismissed.")
else:
output.print_error(f"Suggestion '{suggestion_id}' not found.")
raise typer.Exit(1)
except typer.Exit:
raise
except Exception as e:
output.print_error(str(e))
raise typer.Exit(1)
@app.command("suggestion-propose")
def schedule_suggestion_propose(
blueprint_name: str = typer.Argument(..., help="Blueprint to suggest"),
reason: str = typer.Option("", "--reason", "-r", help="Why this is being suggested"),
hour: Optional[int] = typer.Option(None, "--hour"),
minute: Optional[int] = typer.Option(None, "--minute"),
weekdays: Optional[str] = typer.Option(None, "--weekdays"),
focus: Optional[str] = typer.Option(None, "--focus"),
interval: Optional[int] = typer.Option(None, "--interval"),
keywords: Optional[str] = typer.Option(None, "--keywords", help="Priority keywords (for important-mail)"),
deliver: str = typer.Option("", "--deliver", "-d", help="Suggested delivery target"),
):
"""Propose a blueprint as a suggestion (manual/CLI trigger)."""
output = get_output_controller()
try:
from praisonai.scheduler.blueprint_catalogue import BlueprintCatalogue
from praisonai.scheduler.suggestion_engine import SuggestionEngine
catalogue = BlueprintCatalogue()
bp = catalogue.get_blueprint(blueprint_name)
if bp is None:
available = [b.name for b in catalogue.list_blueprints()]
output.print_error(
f"Blueprint '{blueprint_name}' not found. "
f"Available: {', '.join(available)}"
)
raise typer.Exit(1)
cli_slot_map: dict = {
"hour": hour, "minute": minute, "weekdays": weekdays,
"focus": focus, "interval_minutes": interval,
"keywords": keywords,
}
slots = {}
for slot in bp.slots:
val = cli_slot_map.get(slot.name)
if val is not None:
slots[slot.name] = val
engine = SuggestionEngine()
sug_id = engine.propose(
blueprint_name=blueprint_name,
slots=slots,
deliver=deliver,
reason=reason or f"Suggestion from CLI for {blueprint_name}",
)
if sug_id:
output.print_success(f"Suggestion created (id: {sug_id}).")
output.print_info(f" Accept: praisonai schedule suggestion-accept {sug_id}")
output.print_info(f" Dismiss: praisonai schedule suggestion-dismiss {sug_id}")
else:
output.print_warning("Suggestion not created (cap reached or duplicate).")
except typer.Exit:
raise
except Exception as e:
output.print_error(str(e))
raise typer.Exit(1)
@app.callback(invoke_without_command=True)
def schedule_callback(ctx: typer.Context):
"""Show schedule help or list jobs."""
if ctx.invoked_subcommand is None:
schedule_list(json_output=False)
@@ -60,7 +60,9 @@ def serve_start(
args.append("--reload")
try:
from praisonai.cli.features.serve import handle_serve_command
from praisonai_code._wrapper_bridge import import_wrapper_module
_mod = import_wrapper_module('praisonai.cli.features.serve')
handle_serve_command = getattr(_mod, 'handle_serve_command')
exit_code = handle_serve_command(args)
raise typer.Exit(exit_code)
except ImportError as e:
@@ -74,7 +76,9 @@ def serve_stop():
output = get_output_controller()
try:
from praisonai.cli.features.serve import handle_serve_command
from praisonai_code._wrapper_bridge import import_wrapper_module
_mod = import_wrapper_module('praisonai.cli.features.serve')
handle_serve_command = getattr(_mod, 'handle_serve_command')
exit_code = handle_serve_command(["stop"])
raise typer.Exit(exit_code)
except ImportError as e:
@@ -94,7 +98,9 @@ def serve_status(
args.append("--json")
try:
from praisonai.cli.features.serve import handle_serve_command
from praisonai_code._wrapper_bridge import import_wrapper_module
_mod = import_wrapper_module('praisonai.cli.features.serve')
handle_serve_command = getattr(_mod, 'handle_serve_command')
exit_code = handle_serve_command(args)
raise typer.Exit(exit_code)
except ImportError as e:
@@ -170,7 +176,9 @@ def serve_agents(
praisonai serve agents --file my-agents.yaml --port 8000
"""
try:
from praisonai.cli.features.serve import handle_serve_command
from praisonai_code._wrapper_bridge import import_wrapper_module
_mod = import_wrapper_module('praisonai.cli.features.serve')
handle_serve_command = getattr(_mod, 'handle_serve_command')
args = ["agents", "--host", host, "--port", str(port), "--file", file]
if reload:
args.append("--reload")
@@ -202,7 +210,9 @@ def serve_gateway(
output = get_output_controller()
try:
from praisonai.cli.features.gateway import GatewayHandler
from praisonai_code._wrapper_bridge import import_wrapper_module
_mod = import_wrapper_module('praisonai.cli.features.gateway')
GatewayHandler = getattr(_mod, 'GatewayHandler')
handler = GatewayHandler()
# ``start`` returns a sysexits-based exit code (0 OK, 75 restart,
# 78 fatal config) so supervisors react correctly; propagate it
@@ -396,7 +406,9 @@ def serve_ui_gateway(
output = get_output_controller()
try:
from praisonai.integration.gateway_host import run_integrated_gateway
from praisonai_code._wrapper_bridge import import_wrapper_module
_mod = import_wrapper_module('praisonai.integration.gateway_host')
run_integrated_gateway = getattr(_mod, 'run_integrated_gateway')
run_integrated_gateway(
host=host,
port=port,
@@ -532,7 +544,9 @@ def serve_recipe(
praisonai serve recipe --port 8765 --reload
"""
try:
from praisonai.cli.features.serve import handle_serve_command
from praisonai_code._wrapper_bridge import import_wrapper_module
_mod = import_wrapper_module('praisonai.cli.features.serve')
handle_serve_command = getattr(_mod, 'handle_serve_command')
args = ["recipe", "--host", host, "--port", str(port)]
if recipe:
args.extend(["--recipe", recipe])
@@ -563,7 +577,9 @@ def serve_a2a(
praisonai serve a2a --port 8001
"""
try:
from praisonai.cli.features.serve import handle_serve_command
from praisonai_code._wrapper_bridge import import_wrapper_module
_mod = import_wrapper_module('praisonai.cli.features.serve')
handle_serve_command = getattr(_mod, 'handle_serve_command')
args = ["a2a", "--host", host, "--port", str(port), "--file", file]
exit_code = handle_serve_command(args)
raise typer.Exit(exit_code)
@@ -587,7 +603,9 @@ def serve_a2u(
praisonai serve a2u --port 8002
"""
try:
from praisonai.cli.features.serve import handle_serve_command
from praisonai_code._wrapper_bridge import import_wrapper_module
_mod = import_wrapper_module('praisonai.cli.features.serve')
handle_serve_command = getattr(_mod, 'handle_serve_command')
args = ["a2u", "--host", host, "--port", str(port)]
exit_code = handle_serve_command(args)
raise typer.Exit(exit_code)
@@ -613,7 +631,9 @@ def serve_unified(
praisonai serve unified --port 8765 --reload
"""
try:
from praisonai.cli.features.serve import handle_serve_command
from praisonai_code._wrapper_bridge import import_wrapper_module
_mod = import_wrapper_module('praisonai.cli.features.serve')
handle_serve_command = getattr(_mod, 'handle_serve_command')
args = ["unified", "--host", host, "--port", str(port), "--file", file]
if reload:
args.append("--reload")
@@ -646,9 +666,17 @@ def serve_openai(
try:
import uvicorn
from praisonai.endpoints.providers import OpenAICompatProvider, AgentsAPIProvider
from praisonai.endpoints.server import create_unified_app, mount_provider_routes
from praisonai.cli.features.serve import _install_api_key_middleware
from praisonai_code._wrapper_bridge import import_wrapper_module
_mod = import_wrapper_module('praisonai.endpoints.providers')
OpenAICompatProvider = getattr(_mod, 'OpenAICompatProvider')
AgentsAPIProvider = getattr(_mod, 'AgentsAPIProvider')
from praisonai_code._wrapper_bridge import import_wrapper_module
_mod = import_wrapper_module('praisonai.endpoints.server')
create_unified_app = getattr(_mod, 'create_unified_app')
mount_provider_routes = getattr(_mod, 'mount_provider_routes')
from praisonai_code._wrapper_bridge import import_wrapper_module
_mod = import_wrapper_module('praisonai.cli.features.serve')
_install_api_key_middleware = getattr(_mod, '_install_api_key_middleware')
output.print_info(f"Starting OpenAI-compatible server on {host}:{port}")
@@ -1,711 +0,0 @@
"""
CLI commands for the FDEP standardisation system.
Commands:
- praisonai standardise check - Check for standardisation issues
- praisonai standardise report - Generate detailed report
- praisonai standardise fix - Fix issues (with --apply)
- praisonai standardise init - Initialise a new feature
- praisonai standardise ai - AI-powered generation
- praisonai standardise checkpoint - Create undo checkpoint
- praisonai standardise undo - Undo to previous checkpoint
- praisonai standardise redo - Redo after undo
"""
import sys
from pathlib import Path
def _lazy_import_engine():
"""Lazy import to avoid heavy imports at CLI startup."""
from praisonai.standardise import StandardiseEngine, StandardiseConfig
return StandardiseEngine, StandardiseConfig
def standardise_command(args=None):
"""Main entry point for standardise command."""
import argparse
parser = argparse.ArgumentParser(
prog="praisonai standardise",
description="Standardise documentation and examples (FDEP)",
)
subparsers = parser.add_subparsers(dest="subcommand", help="Subcommands")
# check subcommand
check_parser = subparsers.add_parser(
"check",
help="Check for standardisation issues (default)",
)
_add_common_args(check_parser)
# report subcommand
report_parser = subparsers.add_parser(
"report",
help="Generate detailed report",
)
_add_common_args(report_parser)
report_parser.add_argument(
"--format", "-f",
choices=["text", "json", "markdown"],
default="text",
help="Report format (default: text)",
)
report_parser.add_argument(
"--output", "-o",
type=str,
help="Output file path (default: stdout)",
)
# fix subcommand
fix_parser = subparsers.add_parser(
"fix",
help="Fix standardisation issues",
)
_add_common_args(fix_parser)
fix_parser.add_argument(
"--apply",
action="store_true",
help="Actually apply changes (default: dry-run)",
)
fix_parser.add_argument(
"--no-backup",
action="store_true",
help="Don't create backups before changes",
)
# init subcommand
init_parser = subparsers.add_parser(
"init",
help="Initialise a new feature with all required artifacts",
)
init_parser.add_argument(
"feature",
type=str,
help="Feature slug to initialise",
)
_add_common_args(init_parser)
init_parser.add_argument(
"--apply",
action="store_true",
help="Actually create files (default: dry-run)",
)
# ai subcommand
ai_parser = subparsers.add_parser(
"ai",
help="AI-powered generation of docs/examples",
)
ai_parser.add_argument(
"feature",
type=str,
help="Feature slug to generate content for",
)
ai_parser.add_argument(
"--type", "-t",
choices=["docs", "examples", "all"],
default="all",
help="Type of content to generate",
)
ai_parser.add_argument(
"--apply",
action="store_true",
help="Actually create files (default: dry-run preview)",
)
ai_parser.add_argument(
"--verify",
action="store_true",
help="Verify generated content with AI",
)
ai_parser.add_argument(
"--model",
type=str,
default="gpt-4o-mini",
help="LLM model to use (default: gpt-4o-mini)",
)
_add_common_args(ai_parser)
# checkpoint subcommand
checkpoint_parser = subparsers.add_parser(
"checkpoint",
help="Create an undo checkpoint",
)
checkpoint_parser.add_argument(
"--message", "-m",
type=str,
help="Checkpoint message",
)
checkpoint_parser.add_argument(
"--path", "-p",
type=str,
default=".",
help="Repository path",
)
# undo subcommand
undo_parser = subparsers.add_parser(
"undo",
help="Undo to a previous checkpoint",
)
undo_parser.add_argument(
"--checkpoint",
type=str,
help="Specific checkpoint ID to restore",
)
undo_parser.add_argument(
"--list",
action="store_true",
help="List available checkpoints",
)
undo_parser.add_argument(
"--path", "-p",
type=str,
default=".",
help="Repository path",
)
# redo subcommand
redo_parser = subparsers.add_parser(
"redo",
help="Redo after an undo",
)
redo_parser.add_argument(
"--path", "-p",
type=str,
default=".",
help="Repository path",
)
# Parse args
parsed = parser.parse_args(args)
# Default to check if no subcommand
if not parsed.subcommand:
parsed.subcommand = "check"
parsed = parser.parse_args(["check"] + (args or []))
# Execute subcommand
if parsed.subcommand == "check":
return _run_check(parsed)
elif parsed.subcommand == "report":
return _run_report(parsed)
elif parsed.subcommand == "fix":
return _run_fix(parsed)
elif parsed.subcommand == "init":
return _run_init(parsed)
elif parsed.subcommand == "ai":
return _run_ai(parsed)
elif parsed.subcommand == "checkpoint":
return _run_checkpoint(parsed)
elif parsed.subcommand == "undo":
return _run_undo(parsed)
elif parsed.subcommand == "redo":
return _run_redo(parsed)
return 0
def _add_common_args(parser):
"""Add common arguments to a parser."""
parser.add_argument(
"--path", "-p",
type=str,
default=".",
help="Project root path (default: current directory)",
)
parser.add_argument(
"--feature",
type=str,
help="Specific feature slug to check",
)
parser.add_argument(
"--scope",
choices=["all", "docs", "examples", "sdk", "cli"],
default="all",
help="Scope of check (default: all)",
)
parser.add_argument(
"--ci",
action="store_true",
help="CI mode (no prompts, machine-readable output)",
)
parser.add_argument(
"--dry-run",
action="store_true",
default=True,
help="Don't make changes (default: true)",
)
def _create_config(parsed):
"""Create config from parsed arguments."""
_, StandardiseConfig = _lazy_import_engine()
config = StandardiseConfig(
project_root=Path(parsed.path).resolve() if hasattr(parsed, "path") else None,
dry_run=getattr(parsed, "dry_run", True) and not getattr(parsed, "apply", False),
backup=not getattr(parsed, "no_backup", False),
scope=getattr(parsed, "scope", "all"),
feature_filter=getattr(parsed, "feature", None),
ci_mode=getattr(parsed, "ci", False),
report_format=getattr(parsed, "format", "text"),
)
return config
def _run_check(parsed) -> int:
"""Run the check subcommand."""
StandardiseEngine, StandardiseConfig = _lazy_import_engine()
config = _create_config(parsed)
engine = StandardiseEngine(config)
report = engine.check()
# Print report
output = engine.reports.generate(report, "text")
print(output)
# Return exit code
if config.ci_mode:
return engine.get_exit_code(report)
return 0
def _run_report(parsed) -> int:
"""Run the report subcommand."""
StandardiseEngine, StandardiseConfig = _lazy_import_engine()
config = _create_config(parsed)
engine = StandardiseEngine(config)
report = engine.check()
output = engine.reports.generate(report, parsed.format)
if parsed.output:
Path(parsed.output).write_text(output, encoding="utf-8")
print(f"Report saved to {parsed.output}")
else:
print(output)
if config.ci_mode:
return engine.get_exit_code(report)
return 0
def _run_fix(parsed) -> int:
"""Run the fix subcommand."""
StandardiseEngine, StandardiseConfig = _lazy_import_engine()
config = _create_config(parsed)
engine = StandardiseEngine(config)
actions = engine.fix(
feature=parsed.feature,
apply=parsed.apply,
)
if not actions["planned"]:
print("No fixes needed.")
return 0
print(f"Planned actions ({len(actions['planned'])}):")
for action in actions["planned"]:
print(f" • {action}")
if parsed.apply:
print(f"\nApplied actions ({len(actions['applied'])}):")
for action in actions["applied"]:
print(f" ✓ {action}")
else:
print("\nRun with --apply to execute these changes.")
return 0
def _run_init(parsed) -> int:
"""Run the init subcommand."""
StandardiseEngine, StandardiseConfig = _lazy_import_engine()
config = _create_config(parsed)
engine = StandardiseEngine(config)
try:
created = engine.init(parsed.feature)
except ValueError as e:
print(f"Error: {e}")
return 2
if not created:
print(f"Feature '{parsed.feature}' already has all required artifacts.")
return 0
print(f"{'Would create' if config.dry_run else 'Created'} artifacts for '{parsed.feature}':")
for artifact_type, path in created.items():
status = "📝" if config.dry_run else "✓"
print(f" {status} {artifact_type}: {path}")
if config.dry_run:
print("\nRun with --apply to create these files.")
return 0
async def _run_ai_with_runtime(parsed, config, slug):
"""Helper function to run AI generation with proper runtime lifecycle."""
from praisonai.standardise.ai_generator import AIGenerator
from praisonai.standardise.models import ArtifactType
# Start ACP/LSP runtime for context gathering
runtime = None
try:
from praisonai_code.cli.features.interactive_runtime import create_runtime
runtime = create_runtime(
workspace=str(config.sdk_root or config.project_root or "."),
lsp=True,
acp=False # ACP not needed for generation
)
# Start runtime
await runtime.start()
if runtime.lsp_ready:
print("🔧 LSP server ready for code intelligence")
except Exception:
# Runtime is optional, continue without it
pass
try:
return await _run_generation_logic(parsed, config, slug, runtime)
finally:
# Cleanup runtime
if runtime:
try:
await runtime.stop()
except Exception:
pass
async def _run_generation_logic(parsed, config, slug, runtime):
"""Main AI generation logic."""
from praisonai.standardise.ai_generator import AIGenerator
from praisonai.standardise.models import ArtifactType
generator = AIGenerator(
model=parsed.model,
sdk_root=config.sdk_root,
docs_root=config.docs_root,
examples_root=config.examples_root,
use_fast_context=True,
)
# Determine which artifacts to generate
artifact_types = []
gen_type = getattr(parsed, "type", "all")
if gen_type in ["docs", "all"]:
artifact_types.extend([
ArtifactType.DOCS_CONCEPT,
ArtifactType.DOCS_FEATURE,
ArtifactType.DOCS_CLI,
ArtifactType.DOCS_SDK,
])
if gen_type in ["examples", "all"]:
artifact_types.extend([
ArtifactType.EXAMPLE_BASIC,
ArtifactType.EXAMPLE_ADVANCED,
])
print(f"🤖 AI Generation for '{slug.normalised}'")
print("=" * 50)
results = []
for artifact_type in artifact_types:
print(f"\n📝 Generating {artifact_type.value}...")
try:
# Generate with execution verification for examples
content, output_path, verification_info = generator.generate(
slug, artifact_type,
dry_run=not parsed.apply,
verify_execution=True,
)
# Show verification results for examples
if verification_info:
if verification_info.get("execution_passed"):
print(" ✅ Code verified: runs successfully")
elif verification_info.get("requires_external"):
libs = ", ".join(verification_info.get("missing_libraries", []))
print(f" ⚠️ Requires external libraries: {libs}")
elif not verification_info.get("syntax_valid"):
print(f" ❌ Syntax error: {verification_info.get('error', 'Unknown')[:100]}")
else:
print(f" ❌ Execution failed after {verification_info.get('attempt', 1)} attempts")
if verification_info.get("error"):
print(f" Error: {verification_info['error'][:150]}")
# AI content verification (optional)
if parsed.verify:
print(" 🔍 AI content review...")
is_valid, summary, issues = generator.verify(content, artifact_type, slug)
if is_valid:
print(f" ✅ {summary}")
else:
print(f" ⚠️ {summary}")
for issue in issues[:3]:
print(f" - {issue}")
# Check if we should write
can_write = True
if verification_info and not verification_info.get("success"):
if not verification_info.get("requires_external"):
can_write = False
print(" ⛔ Not writing: code doesn't run")
if parsed.apply and output_path and can_write:
print(f" ✓ Created: {output_path}")
results.append((artifact_type.value, True, output_path))
elif not parsed.apply:
print(f" 📋 Preview ({len(content)} chars)")
# Show first few lines
preview_lines = content.split("\n")[:10]
for line in preview_lines:
print(f" {line[:60]}")
if len(content.split("\n")) > 10:
print(" ...")
results.append((artifact_type.value, True, None))
else:
results.append((artifact_type.value, False, "Verification failed"))
except Exception as e:
print(f" ❌ Error: {e}")
results.append((artifact_type.value, False, str(e)))
print("\n" + "=" * 50)
print("Summary:")
success_count = sum(1 for _, success, _ in results if success)
print(f" Generated: {success_count}/{len(results)}")
if not parsed.apply:
print("\nRun with --apply to create these files.")
return 0
def _run_ai(parsed) -> int:
"""Run the AI generation subcommand with ACP/LSP runtime support."""
import asyncio
from praisonai.standardise.models import FeatureSlug
config = _create_config(parsed)
slug = FeatureSlug.from_string(parsed.feature)
if not slug.is_valid:
print(f"Error: Invalid feature slug: {slug.validation_error}")
return 2
# Run the async workflow with single event loop
return asyncio.run(_run_ai_with_runtime(parsed, config, slug))
def _run_checkpoint(parsed) -> int:
"""Run the checkpoint subcommand."""
from praisonai.standardise.undo_redo import UndoRedoManager
manager = UndoRedoManager(Path(parsed.path).resolve())
success, result = manager.create_checkpoint(parsed.message)
if success:
print(f"✓ Checkpoint created: {result}")
else:
print(f"❌ Failed: {result}")
return 1
return 0
def _run_undo(parsed) -> int:
"""Run the undo subcommand."""
from praisonai.standardise.undo_redo import UndoRedoManager
manager = UndoRedoManager(Path(parsed.path).resolve())
# List checkpoints if requested
if getattr(parsed, "list", False):
checkpoints = manager.list_checkpoints()
if not checkpoints:
print("No checkpoints found.")
return 0
print("Available checkpoints:")
for cp_id, date, message in checkpoints:
print(f" • {cp_id} ({date}): {message[:50]}")
return 0
# Undo to checkpoint
success, result = manager.undo(parsed.checkpoint)
if success:
print(f"✓ {result}")
else:
print(f"❌ {result}")
return 1
return 0
def _run_redo(parsed) -> int:
"""Run the redo subcommand."""
from praisonai.standardise.undo_redo import UndoRedoManager
manager = UndoRedoManager(Path(parsed.path).resolve())
success, result = manager.redo()
if success:
print(f"✓ {result}")
else:
print(f"❌ {result}")
return 1
return 0
# Click-based alternative for integration with main CLI
def register_click_commands(app):
"""Register click commands with the main CLI app."""
try:
import click
except ImportError:
return
@app.group(name="standardise")
def standardise_group():
"""Standardise documentation and examples (FDEP)."""
pass
@standardise_group.command(name="check")
@click.option("--path", "-p", default=".", help="Project root path")
@click.option("--feature", help="Specific feature slug to check")
@click.option("--scope", type=click.Choice(["all", "docs", "examples", "sdk", "cli"]), default="all")
@click.option("--ci", is_flag=True, help="CI mode")
def check_cmd(path, feature, scope, ci):
"""Check for standardisation issues."""
StandardiseEngine, StandardiseConfig = _lazy_import_engine()
config = StandardiseConfig(
project_root=Path(path).resolve(),
feature_filter=feature,
scope=scope,
ci_mode=ci,
)
engine = StandardiseEngine(config)
report = engine.check()
output = engine.reports.generate(report, "text")
click.echo(output)
if ci:
sys.exit(engine.get_exit_code(report))
@standardise_group.command(name="report")
@click.option("--path", "-p", default=".", help="Project root path")
@click.option("--format", "-f", type=click.Choice(["text", "json", "markdown"]), default="text")
@click.option("--output", "-o", help="Output file path")
@click.option("--ci", is_flag=True, help="CI mode")
def report_cmd(path, format, output, ci):
"""Generate detailed report."""
StandardiseEngine, StandardiseConfig = _lazy_import_engine()
config = StandardiseConfig(
project_root=Path(path).resolve(),
ci_mode=ci,
)
engine = StandardiseEngine(config)
report = engine.check()
content = engine.reports.generate(report, format)
if output:
Path(output).write_text(content, encoding="utf-8")
click.echo(f"Report saved to {output}")
else:
click.echo(content)
@standardise_group.command(name="fix")
@click.option("--path", "-p", default=".", help="Project root path")
@click.option("--feature", help="Specific feature slug to fix")
@click.option("--apply", is_flag=True, help="Actually apply changes")
@click.option("--no-backup", is_flag=True, help="Don't create backups")
def fix_cmd(path, feature, apply, no_backup):
"""Fix standardisation issues."""
StandardiseEngine, StandardiseConfig = _lazy_import_engine()
config = StandardiseConfig(
project_root=Path(path).resolve(),
feature_filter=feature,
dry_run=not apply,
backup=not no_backup,
)
engine = StandardiseEngine(config)
actions = engine.fix(feature=feature, apply=apply)
if not actions["planned"]:
click.echo("No fixes needed.")
return
click.echo(f"Planned actions ({len(actions['planned'])}):")
for action in actions["planned"]:
click.echo(f" • {action}")
if apply:
click.echo(f"\nApplied actions ({len(actions['applied'])}):")
for action in actions["applied"]:
click.echo(f" ✓ {action}")
else:
click.echo("\nRun with --apply to execute these changes.")
@standardise_group.command(name="init")
@click.argument("feature")
@click.option("--path", "-p", default=".", help="Project root path")
@click.option("--apply", is_flag=True, help="Actually create files")
def init_cmd(feature, path, apply):
"""Initialise a new feature with all required artifacts."""
StandardiseEngine, StandardiseConfig = _lazy_import_engine()
config = StandardiseConfig(
project_root=Path(path).resolve(),
dry_run=not apply,
)
engine = StandardiseEngine(config)
try:
created = engine.init(feature)
except ValueError as e:
click.echo(f"Error: {e}", err=True)
sys.exit(2)
if not created:
click.echo(f"Feature '{feature}' already has all required artifacts.")
return
action_word = "Would create" if config.dry_run else "Created"
click.echo(f"{action_word} artifacts for '{feature}':")
for artifact_type, artifact_path in created.items():
status = "📝" if config.dry_run else "✓"
click.echo(f" {status} {artifact_type}: {artifact_path}")
if config.dry_run:
click.echo("\nRun with --apply to create these files.")
return standardise_group
if __name__ == "__main__":
sys.exit(standardise_command())
@@ -1,613 +0,0 @@
"""
Train command group for PraisonAI CLI.
Provides two types of training:
1. LLM Fine-tuning: praisonai train dataset.json
2. Agent Training: praisonai train agents [OPTIONS]
"""
import json
from pathlib import Path
from typing import Optional
import typer
app = typer.Typer(help="Training commands for models and agents")
@app.callback()
def train_callback():
"""
Training commands for models and agents.
For LLM fine-tuning:
praisonai train llm dataset.json
For agent training (iterative improvement):
praisonai train agents --input "What is Python?"
praisonai train agents --input "Explain AI" --human
"""
pass
@app.command("llm")
def train_llm(
dataset: str = typer.Argument(..., help="Training dataset path"),
model: Optional[str] = typer.Option(None, "--model", "-m", help="Base model to fine-tune"),
verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
):
"""
Fine-tune LLM models using Unsloth.
Examples:
praisonai train llm dataset.json
praisonai train llm --model llama-3.1 dataset.json
"""
from praisonai_code.cli.main import PraisonAI
import sys
argv = ['train', dataset]
if model:
argv.extend(['--model', model])
if verbose:
argv.append('--verbose')
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@app.command("agents")
def train_agents(
agent_file: Optional[str] = typer.Argument(
None,
help="Path to agent YAML file or Python file"
),
iterations: int = typer.Option(
3,
"--iterations", "-n",
help="Number of training iterations"
),
human: bool = typer.Option(
False,
"--human", "-h",
help="Use human feedback instead of LLM grading"
),
scenarios: Optional[str] = typer.Option(
None,
"--scenarios", "-s",
help="Path to scenarios JSON file"
),
input_text: Optional[str] = typer.Option(
None,
"--input", "-i",
help="Single input text for training (alternative to scenarios file)"
),
expected: Optional[str] = typer.Option(
None,
"--expected", "-e",
help="Expected output for the input (optional)"
),
output_dir: Optional[str] = typer.Option(
None,
"--output", "-o",
help="Output directory for training data"
),
model: Optional[str] = typer.Option(
None,
"--model", "-m",
help="LLM model for grading (default: gpt-4o-mini)"
),
verbose: bool = typer.Option(
True,
"--verbose/--quiet", "-v/-q",
help="Show detailed progress"
),
dry_run: bool = typer.Option(
False,
"--dry-run",
help="Show what would happen without running"
),
storage_backend: Optional[str] = typer.Option(
None,
"--storage-backend",
help="Storage backend: 'file', 'sqlite', or 'redis://url'. Default: file"
),
storage_path: Optional[str] = typer.Option(
None,
"--storage-path",
help="Path for storage backend (file dir or sqlite db path)"
),
):
"""
Train agents through iterative feedback loops.
Two modes available:
1. LLM-as-Judge (default): Agent runs, LLM grades output,
provides suggestions, agent re-runs with feedback.
Fully automated, no human intervention needed.
2. Human-in-the-Loop (--human): Agent runs, you review
and provide feedback, agent re-runs with your feedback.
Examples:
# Simple: Train with a single input
praisonai train agents --input "What is Python?"
# With expected output
praisonai train agents --input "What is 2+2?" --expected "4"
# With scenarios file
praisonai train agents --scenarios scenarios.json
# Human feedback mode
praisonai train agents --input "Explain AI" --human
# More iterations
praisonai train agents --input "Hello" --iterations 5
# With agent file
praisonai train agents my_agent.yaml --scenarios scenarios.json
"""
from ..output.console import get_output_controller
output = get_output_controller()
# Validate inputs
if not input_text and not scenarios:
output.print_error(
"No training input provided",
remediation="Use --input 'text' or --scenarios file.json"
)
raise typer.Exit(1)
if dry_run:
output.print_panel(
f"Mode: {'Human-in-the-Loop' if human else 'LLM-as-Judge'}\n"
f"Iterations: {iterations}\n"
f"Input: {input_text or 'from scenarios file'}\n"
f"Scenarios file: {scenarios or 'N/A'}\n"
f"Agent file: {agent_file or 'default agent'}\n"
f"Output dir: {output_dir or '~/.praison/train/'}",
title="Dry Run - Would execute:"
)
return
# Lazy import to avoid overhead
try:
from praisonai.train.agents import AgentTrainer, TrainingScenario
except ImportError as e:
output.print_error(f"Failed to import training module: {e}")
raise typer.Exit(1)
# Create or load agent
agent = None
if agent_file:
agent = _load_agent_from_file(agent_file, output)
if agent is None:
raise typer.Exit(1)
else:
# Create a simple default agent
try:
from praisonaiagents import Agent
agent = Agent(
instructions="You are a helpful assistant. Provide clear, accurate, and helpful responses."
)
except ImportError:
output.print_error(
"praisonaiagents not installed",
remediation="pip install praisonaiagents"
)
raise typer.Exit(1)
# Create trainer
storage_dir = Path(output_dir) if output_dir else None
from praisonai.train.agents.grader import TrainingGrader
grader = TrainingGrader(model=model) if model else None
# Create storage backend if specified
backend = None
if storage_backend:
backend = _create_storage_backend(storage_backend, storage_path, output)
if backend is None and storage_backend not in ("file", None):
raise typer.Exit(1)
trainer = AgentTrainer(
agent=agent,
iterations=iterations,
human_mode=human,
grader=grader,
storage_dir=storage_dir,
storage_backend=backend,
verbose=verbose,
)
# Add scenarios
if scenarios:
scenarios_list = _load_scenarios_from_file(scenarios, output)
if scenarios_list is None:
raise typer.Exit(1)
trainer.add_scenarios(scenarios_list)
elif input_text:
trainer.add_scenario(TrainingScenario(
id="cli-input",
input_text=input_text,
expected_output=expected,
))
# Run training
try:
report = trainer.run()
if not verbose:
report.print_summary()
output.print_success(
f"Training complete! Session: {report.session_id}\n"
f"Average score: {report.avg_score:.1f}/10\n"
f"Improvement: {report.improvement:+.1f}"
)
except KeyboardInterrupt:
output.print_warning("Training interrupted by user")
raise typer.Exit(130)
except Exception as e:
output.print_error(f"Training failed: {e}")
raise typer.Exit(1)
@app.command("list")
def train_list(
limit: int = typer.Option(20, "--limit", "-n", help="Max sessions to show"),
json_output: bool = typer.Option(False, "--json", "-j", help="Output as JSON"),
):
"""List all training sessions."""
from ..output.console import get_output_controller
output = get_output_controller()
try:
from praisonai.train.agents.storage import list_training_sessions
except ImportError:
output.print_error("Training module not available")
raise typer.Exit(1)
sessions = list_training_sessions(limit=limit)
if json_output or output.is_json_mode:
output.print_json({
"sessions": [s.to_dict() for s in sessions]
})
return
if not sessions:
output.print_info("No training sessions found")
return
headers = ["Session ID", "Iterations", "Size", "Modified"]
rows = []
for s in sessions:
modified = s.modified_at.strftime("%Y-%m-%d %H:%M")
size_kb = s.size_bytes / 1024
size_str = f"{size_kb:.1f} KB" if size_kb >= 1 else f"{s.size_bytes} B"
rows.append([
s.session_id[:30] + "..." if len(s.session_id) > 30 else s.session_id,
str(s.iteration_count),
size_str,
modified,
])
output.print_table(headers, rows, title="Training Sessions")
@app.command("show")
def train_show(
session_id: str = typer.Argument(..., help="Session ID to show"),
iterations_flag: bool = typer.Option(False, "--iterations", "-i", help="Show detailed iteration info"),
json_output: bool = typer.Option(False, "--json", "-j", help="Output as JSON"),
):
"""
Show details of a training session.
Examples:
# Show session summary
praisonai train show train-abc123
# Show with detailed iterations
praisonai train show train-abc123 --iterations
# Output as JSON
praisonai train show train-abc123 --json
"""
from ..output.console import get_output_controller
output = get_output_controller()
try:
from praisonai.train.agents.storage import TrainingStorage
except ImportError:
output.print_error("Training module not available")
raise typer.Exit(1)
storage = TrainingStorage(session_id=session_id)
if not storage.storage_path.exists():
output.print_error(f"Session not found: {session_id}")
raise typer.Exit(1)
report = storage.load_report()
iterations = storage.load_iterations()
if json_output or output.is_json_mode:
output.print_json({
"session_id": session_id,
"report": report.to_dict() if report else None,
"iterations": [it.to_dict() for it in iterations],
})
return
if report:
report.print_summary()
# Show best iteration
best = report.get_best_iteration()
if best:
output.print(f"\n✨ Best Iteration: #{best.iteration_num} (Score: {best.score}/10)")
if iterations:
output.print("\nIterations:")
# Find best score for highlighting
best_score = max(it.score for it in iterations) if iterations else 0
for it in iterations:
# Highlight best iteration
marker = "★" if it.score == best_score else " "
feedback_preview = it.feedback[:50] + "..." if len(it.feedback) > 50 else it.feedback
output.print(f" {marker} [{it.iteration_num}] Score: {it.score}/10 - {feedback_preview}")
# Show suggestions if --iterations flag
if iterations_flag and it.suggestions:
for suggestion in it.suggestions[:3]: # Show top 3 suggestions
output.print(f" → {suggestion}")
@app.command("apply")
def train_apply(
session_id: str = typer.Argument(..., help="Training session ID to apply"),
agent_file: Optional[str] = typer.Option(
None,
"--agent", "-a",
help="Path to agent YAML file"
),
iteration: Optional[int] = typer.Option(
None,
"--iteration", "-n",
help="Specific iteration number (default: best score)"
),
run_prompt: Optional[str] = typer.Option(
None,
"--run", "-r",
help="Run agent with this prompt after applying training"
),
json_output: bool = typer.Option(False, "--json", "-j", help="Output as JSON"),
):
"""
Apply training to an agent.
Loads training suggestions from a session and applies them to an agent
via hooks. By default, uses the iteration with the best score.
Examples:
# Apply best iteration to default agent
praisonai train apply train-abc123
# Apply specific iteration
praisonai train apply train-abc123 --iteration 2
# Apply to agent from YAML file
praisonai train apply train-abc123 --agent my_agent.yaml
# Apply and run immediately
praisonai train apply train-abc123 --run "Hello, how are you?"
"""
from ..output.console import get_output_controller
output = get_output_controller()
try:
from praisonai.train.agents import apply_training, get_training_profile
from praisonai.train.agents.storage import TrainingStorage
except ImportError as e:
output.print_error(f"Training module not available: {e}")
raise typer.Exit(1)
# Verify session exists
storage = TrainingStorage(session_id=session_id)
if not storage.storage_path.exists():
output.print_error(f"Session not found: {session_id}")
raise typer.Exit(1)
# Get the training profile
profile = get_training_profile(
session_id=session_id,
iteration=iteration,
agent_name="agent",
)
if profile is None:
output.print_error(f"Could not load training profile from session {session_id}")
raise typer.Exit(1)
if json_output or output.is_json_mode:
output.print_json({
"session_id": session_id,
"iteration": profile.iteration_num,
"score": profile.quality_score,
"suggestions": profile.suggestions,
"summary": profile.summary,
})
if not run_prompt:
return
# Show profile info
output.print_panel(
f"Session: {profile.session_id}\n"
f"Iteration: {profile.iteration_num}\n"
f"Score: {profile.quality_score}/10\n"
f"Suggestions: {len(profile.suggestions)}",
title="Training Profile"
)
if profile.suggestions:
output.print("\nSuggestions:")
for i, suggestion in enumerate(profile.suggestions, 1):
output.print(f" {i}. {suggestion}")
# If run_prompt provided, create agent and run
if run_prompt:
# Create or load agent
agent = None
if agent_file:
agent = _load_agent_from_file(agent_file, output)
if agent is None:
raise typer.Exit(1)
else:
try:
from praisonaiagents import Agent
agent = Agent(
instructions="You are a helpful assistant."
)
except ImportError:
output.print_error(
"praisonaiagents not installed",
remediation="pip install praisonaiagents"
)
raise typer.Exit(1)
# Apply training
success = apply_training(agent, profile=profile)
if not success:
output.print_error("Failed to apply training to agent")
raise typer.Exit(1)
output.print_success("Training applied successfully!")
output.print(f"\nRunning agent with prompt: {run_prompt}\n")
# Run the agent
try:
if hasattr(agent, 'chat'):
response = agent.chat(run_prompt)
elif hasattr(agent, 'start'):
response = agent.start(run_prompt)
else:
output.print_error("Agent has no chat or start method")
raise typer.Exit(1)
output.print_panel(str(response), title="Agent Response")
except Exception as e:
output.print_error(f"Agent execution failed: {e}")
raise typer.Exit(1)
else:
output.print_info(
"\nTo apply this training to an agent, use:\n"
f" praisonai train apply {session_id} --run 'Your prompt here'\n"
"\nOr in Python:\n"
f" from praisonai.train.agents import apply_training\n"
f" apply_training(agent, session_id='{session_id}')"
)
def _load_agent_from_file(file_path: str, output) -> Optional[object]:
"""Load agent from YAML or Python file."""
path = Path(file_path)
if not path.exists():
output.print_error(f"File not found: {file_path}")
return None
if path.suffix in ('.yaml', '.yml'):
try:
from praisonai_code.cli.main import PraisonAI
praison = PraisonAI(agent_file=str(path))
# Return the agents object
if hasattr(praison, 'agents') and praison.agents:
return praison.agents
output.print_error("Could not load agents from YAML")
return None
except Exception as e:
output.print_error(f"Failed to load YAML: {e}")
return None
elif path.suffix == '.py':
output.print_warning(
"Python file support coming soon. "
"For now, use YAML files or --input flag."
)
return None
else:
output.print_error(f"Unsupported file type: {path.suffix}")
return None
def _create_storage_backend(backend_type: str, storage_path: Optional[str], output):
"""Create storage backend from CLI options."""
try:
if backend_type == "file":
from praisonaiagents.storage import FileBackend
return FileBackend(storage_dir=storage_path or "~/.praison/train")
elif backend_type == "sqlite":
from praisonaiagents.storage import SQLiteBackend
db_path = storage_path or "~/.praison/train.db"
return SQLiteBackend(db_path=db_path)
elif backend_type.startswith("redis://"):
from praisonaiagents.storage import RedisBackend
return RedisBackend(url=backend_type, prefix="train:")
else:
output.print_error(
f"Unknown storage backend: {backend_type}",
remediation="Use 'file', 'sqlite', or 'redis://url'"
)
return None
except ImportError as e:
output.print_error(f"Failed to import storage backend: {e}")
return None
except Exception as e:
output.print_error(f"Failed to create storage backend: {e}")
return None
def _load_scenarios_from_file(file_path: str, output) -> Optional[list]:
"""Load scenarios from JSON file."""
path = Path(file_path)
if not path.exists():
output.print_error(f"Scenarios file not found: {file_path}")
return None
try:
with open(path) as f:
data = json.load(f)
if isinstance(data, list):
return data
elif isinstance(data, dict) and "scenarios" in data:
return data["scenarios"]
else:
output.print_error("Invalid scenarios format. Expected list or {scenarios: [...]}")
return None
except json.JSONDecodeError as e:
output.print_error(f"Invalid JSON: {e}")
return None
@@ -1,291 +0,0 @@
"""
YAML configuration validation command.
Validates agents/tasks/workflow YAML configurations with comprehensive error checking.
"""
import sys
from pathlib import Path
from typing import Optional
import typer
from rich.console import Console
from rich.table import Table
app = typer.Typer(
help="Validate YAML configuration files",
no_args_is_help=True
)
console = Console()
@app.command()
def validate(
file: str = typer.Argument(
...,
help="Path to YAML configuration file to validate"
),
strict: bool = typer.Option(
False,
"--strict",
help="Treat warnings as errors (strict validation mode)"
),
quiet: bool = typer.Option(
False,
"--quiet", "-q",
help="Only show errors, suppress success messages"
),
json_output: bool = typer.Option(
False,
"--json",
help="Output validation results as JSON"
),
):
"""
Validate a YAML configuration file for agents, tasks, and workflows.
This command performs comprehensive validation including:
- Schema validation for required and optional fields
- Type checking for all configuration values
- Cross-reference validation (tasks -> agents, tools -> definitions)
- Detection of unknown fields with suggestions
Examples:
praisonai validate agents.yaml
praisonai validate agents.yaml --strict
praisonai validate my-config.yaml --json
"""
from praisonai.config.validator import ConfigValidator
import json as json_module
file_path = Path(file)
# Handle JSON output for missing file
if not file_path.exists():
if json_output:
output = {
"file": str(file_path),
"valid": False,
"errors": [f"File not found: {file}"],
"warnings": [],
"strict_mode": strict
}
sys.stdout.write(json_module.dumps(output, indent=2) + "\n")
else:
console.print(f"[red]✗ File not found:[/red] {file}", style="bold")
sys.exit(1)
# Initialize validator
validator = ConfigValidator()
# Validate the file
result = validator.validate_yaml_file(str(file_path), strict=strict)
# Output JSON if requested
if json_output:
output = {
"file": str(file_path),
"valid": result.valid,
"errors": result.errors,
"warnings": result.warnings,
"strict_mode": strict
}
sys.stdout.write(json_module.dumps(output, indent=2) + "\n")
sys.exit(0 if result.valid else 1)
# Display results
if result.valid:
if not quiet:
console.print(f"[green]✓ Configuration is valid[/green]: {file_path}", style="bold")
if result.warnings:
console.print(f"\n[yellow]Warnings ({len(result.warnings)}):[/yellow]")
for i, warning in enumerate(result.warnings, 1):
console.print(f" {i}. {warning}")
sys.exit(0)
else:
console.print(f"[red]✗ Configuration validation failed[/red]: {file_path}", style="bold")
if result.errors:
console.print(f"\n[red]Errors ({len(result.errors)}):[/red]")
for i, error in enumerate(result.errors, 1):
console.print(f" {i}. {error}")
if result.warnings and not strict:
console.print(f"\n[yellow]Warnings ({len(result.warnings)}):[/yellow]")
for i, warning in enumerate(result.warnings, 1):
console.print(f" {i}. {warning}")
if not quiet:
console.print(f"\n[dim]Fix the errors above and run validation again.[/dim]")
if result.warnings and not strict:
console.print("[dim]Use --strict to treat warnings as errors.[/dim]")
sys.exit(1)
@app.command()
def check(
directory: str = typer.Argument(
".",
help="Directory to search for YAML files"
),
pattern: str = typer.Option(
"*.yaml",
"--pattern", "-p",
help="Glob pattern for YAML files"
),
strict: bool = typer.Option(
False,
"--strict",
help="Treat warnings as errors"
),
stop_on_error: bool = typer.Option(
False,
"--stop-on-error",
help="Stop checking files after first error"
),
):
"""
Check all YAML configuration files in a directory.
Examples:
praisonai validate check .
praisonai validate check ./configs --pattern "*.yml"
praisonai validate check . --strict --stop-on-error
"""
from praisonai.config.validator import ConfigValidator
import glob
# Find all matching files
dir_path = Path(directory)
if not dir_path.exists():
console.print(f"[red]✗ Directory not found:[/red] {directory}", style="bold")
sys.exit(1)
# Find files matching pattern
yaml_files = list(dir_path.glob(pattern))
if pattern == "*.yaml":
yaml_files.extend(dir_path.glob("*.yml"))
if not yaml_files:
console.print(f"[yellow]No YAML files found matching pattern:[/yellow] {pattern}")
sys.exit(0)
# Initialize validator
validator = ConfigValidator()
# Create results table
table = Table(title=f"Validation Results for {len(yaml_files)} file(s)")
table.add_column("File", style="cyan")
table.add_column("Status", style="bold")
table.add_column("Errors", justify="center")
table.add_column("Warnings", justify="center")
total_valid = 0
total_invalid = 0
total_errors = 0
total_warnings = 0
for yaml_file in sorted(yaml_files):
result = validator.validate_yaml_file(str(yaml_file), strict=strict)
if result.valid:
status = "[green]✓ Valid[/green]"
total_valid += 1
else:
status = "[red]✗ Invalid[/red]"
total_invalid += 1
if stop_on_error:
console.print(f"\n[red]Validation failed for:[/red] {yaml_file}")
console.print(result.format_message())
sys.exit(1)
total_errors += len(result.errors)
total_warnings += len(result.warnings)
table.add_row(
str(yaml_file.relative_to(dir_path)),
status,
str(len(result.errors)) if result.errors else "-",
str(len(result.warnings)) if result.warnings else "-"
)
# Display results
console.print(table)
# Summary
console.print(f"\nSummary:")
console.print(f" Valid files: [green]{total_valid}[/green]")
console.print(f" Invalid files: [red]{total_invalid}[/red]")
console.print(f" Total errors: {total_errors}")
console.print(f" Total warnings: {total_warnings}")
sys.exit(0 if total_invalid == 0 else 1)
@app.command()
def schema():
"""
Display the YAML configuration schema documentation.
Shows all available fields, their types, and descriptions for:
- Agent/Role configuration
- Task configuration
- Workflow configuration
- Global settings
"""
from praisonai.config.schema import YAMLConfig, AgentConfig, TaskConfig
import json
console.print("[bold cyan]PraisonAI YAML Configuration Schema[/bold cyan]\n")
# Display main sections
console.print("[bold]Main Configuration Sections:[/bold]")
console.print(" • roles/agents - Define agent roles and capabilities")
console.print(" • tasks - Define tasks and their assignments")
console.print(" • workflow - Configure workflow execution")
console.print(" • tools/toolsets - Global tool configuration")
console.print(" • config - Global settings (acp, lsp)")
console.print()
# Display agent fields
console.print("[bold]Agent/Role Fields:[/bold]")
agent_schema = AgentConfig.model_json_schema()
required_fields = agent_schema.get('required', [])
for field_name, field_info in agent_schema.get('properties', {}).items():
required = " [red](required)[/red]" if field_name in required_fields else ""
description = field_info.get('description', 'No description')
field_type = field_info.get('type', 'any')
# Handle complex types
if 'anyOf' in field_info:
types = [t.get('type', 'object') for t in field_info['anyOf'] if 'type' in t]
field_type = ' | '.join(types) if types else 'mixed'
elif '$ref' in field_info:
field_type = field_info['$ref'].split('/')[-1]
console.print(f" • {field_name}{required} ({field_type}): {description}")
console.print()
# Display task fields
console.print("[bold]Task Fields:[/bold]")
task_schema = TaskConfig.model_json_schema()
required_fields = task_schema.get('required', [])
for field_name, field_info in task_schema.get('properties', {}).items():
required = " [red](required)[/red]" if field_name in required_fields else ""
description = field_info.get('description', 'No description')
field_type = field_info.get('type', 'any')
console.print(f" • {field_name}{required} ({field_type}): {description}")
console.print()
console.print("[dim]Use 'praisonai validate <file>' to validate your configuration.[/dim]")
if __name__ == "__main__":
app()
@@ -34,24 +34,30 @@ def check_praisonai_package_structure(config: DoctorConfig) -> CheckResult:
metadata = {}
try:
import praisonai
package_path = praisonai.__file__
if package_path:
package_dir = os.path.dirname(package_path)
metadata["package_path"] = package_path
metadata["package_dir"] = package_dir
# Check for __main__.py
main_py_path = os.path.join(package_dir, "__main__.py")
if os.path.exists(main_py_path):
metadata["has_main_py"] = True
metadata["main_py_path"] = main_py_path
else:
issues.append("Missing __main__.py - python -m praisonai will not work")
metadata["has_main_py"] = False
from praisonai_code._wrapper_bridge import import_wrapper_module, wrapper_available
if not wrapper_available():
issues.append("praisonai wrapper not installed")
metadata["import_error"] = "wrapper not available"
else:
issues.append("praisonai.__file__ is None - namespace package detected")
praisonai = import_wrapper_module("praisonai")
package_path = praisonai.__file__
if package_path:
package_dir = os.path.dirname(package_path)
metadata["package_path"] = package_path
metadata["package_dir"] = package_dir
# Check for __main__.py
main_py_path = os.path.join(package_dir, "__main__.py")
if os.path.exists(main_py_path):
metadata["has_main_py"] = True
metadata["main_py_path"] = main_py_path
else:
issues.append("Missing __main__.py - python -m praisonai will not work")
metadata["has_main_py"] = False
else:
issues.append("praisonai.__file__ is None - namespace package detected")
except ImportError as e:
issues.append(f"Cannot import praisonai: {e}")
metadata["import_error"] = str(e)
@@ -332,72 +338,78 @@ def check_packaging_metadata(config: DoctorConfig) -> CheckResult:
issues = []
try:
import praisonai
metadata["praisonai_file"] = getattr(praisonai, "__file__", None)
metadata["praisonai_version"] = getattr(praisonai, "__version__", "unknown")
# Check if installed via pip (using modern importlib.metadata)
try:
from praisonai_code._wrapper_bridge import import_wrapper_module, wrapper_available
if not wrapper_available():
issues.append("praisonai wrapper not installed")
metadata["import_error"] = "wrapper not available"
else:
praisonai = import_wrapper_module("praisonai")
metadata["praisonai_file"] = getattr(praisonai, "__file__", None)
metadata["praisonai_version"] = getattr(praisonai, "__version__", "unknown")
# Check if installed via pip (using modern importlib.metadata)
try:
from importlib.metadata import distribution, PackageNotFoundError
try:
from importlib.metadata import distribution, PackageNotFoundError
except ImportError:
# Python < 3.8 fallback
from importlib_metadata import distribution, PackageNotFoundError
try:
dist = distribution("praisonai")
metadata["pip_version"] = dist.version
metadata["pip_location"] = str(dist.locate_file(""))
metadata["pip_installed"] = True
except PackageNotFoundError:
metadata["pip_installed"] = False
issues.append("Package not found in pip registry (editable install?)")
except ImportError:
# Python < 3.8 fallback
from importlib_metadata import distribution, PackageNotFoundError
try:
dist = distribution("praisonai")
metadata["pip_version"] = dist.version
metadata["pip_location"] = str(dist.locate_file(""))
metadata["pip_installed"] = True
except PackageNotFoundError:
metadata["pip_installed"] = False
issues.append("Package not found in pip registry (editable install?)")
except ImportError:
metadata["importlib_metadata_available"] = False
# Check for editable install markers
if praisonai.__file__:
package_dir = os.path.dirname(praisonai.__file__)
# Look for .egg-link or similar editable install markers
site_packages = []
try:
import site
site_packages.extend(site.getsitepackages())
if hasattr(site, 'getusersitepackages'):
site_packages.append(site.getusersitepackages())
except Exception as e:
metadata["site_packages_probe_error"] = str(e)
is_editable = False
for sp in site_packages:
if sp and os.path.exists(sp):
# Check for both case variations of egg-link files
for egg_link_name in ("praisonai.egg-link", "PraisonAI.egg-link"):
egg_link = os.path.join(sp, egg_link_name)
if os.path.exists(egg_link):
is_editable = True
metadata["editable_install"] = True
metadata["egg_link_path"] = egg_link
break
if is_editable:
break
# Check for modern editable install markers (PEP 660)
try:
for file in os.listdir(sp):
if file.startswith("__editable__") and "praisonai" in file.lower() and file.endswith(".pth"):
metadata["importlib_metadata_available"] = False
# Check for editable install markers
if praisonai.__file__:
package_dir = os.path.dirname(praisonai.__file__)
# Look for .egg-link or similar editable install markers
site_packages = []
try:
import site
site_packages.extend(site.getsitepackages())
if hasattr(site, 'getusersitepackages'):
site_packages.append(site.getusersitepackages())
except Exception as e:
metadata["site_packages_probe_error"] = str(e)
is_editable = False
for sp in site_packages:
if sp and os.path.exists(sp):
# Check for both case variations of egg-link files
for egg_link_name in ("praisonai.egg-link", "PraisonAI.egg-link"):
egg_link = os.path.join(sp, egg_link_name)
if os.path.exists(egg_link):
is_editable = True
metadata["editable_install"] = True
metadata["editable_pth_path"] = os.path.join(sp, file)
metadata["egg_link_path"] = egg_link
break
except OSError:
pass
if is_editable:
break
if not is_editable:
metadata["editable_install"] = False
if is_editable:
break
# Check for modern editable install markers (PEP 660)
try:
for file in os.listdir(sp):
if file.startswith("__editable__") and "praisonai" in file.lower() and file.endswith(".pth"):
is_editable = True
metadata["editable_install"] = True
metadata["editable_pth_path"] = os.path.join(sp, file)
break
except OSError:
pass
if is_editable:
break
if not is_editable:
metadata["editable_install"] = False
except ImportError as e:
issues.append(f"Cannot import praisonai: {e}")
metadata["import_error"] = str(e)
@@ -50,7 +50,8 @@ def check_performance_praisonai_import(config: DoctorConfig) -> CheckResult:
start = time.perf_counter()
try:
# Import version module (lightweight)
from praisonai import version
from praisonai_code._wrapper_bridge import import_wrapper_module
import_wrapper_module("praisonai.version")
elapsed = (time.perf_counter() - start) * 1000
if elapsed > budget_ms:
@@ -267,7 +267,9 @@ class InteractiveRuntime:
try:
# Lazy import ACP
from praisonai.acp.session import ACPSession
from praisonai_code._wrapper_bridge import import_wrapper_module
_mod = import_wrapper_module('praisonai.acp.session')
ACPSession = getattr(_mod, 'ACPSession')
from pathlib import Path
# Create a session for the workspace
@@ -1,360 +0,0 @@
"""
Langfuse API Client for PraisonAI CLI.
Provides a simple HTTP client for fetching traces, sessions, and observations
from the Langfuse API. Used by the `praisonai langfuse` CLI commands to enable
trace and session viewing without opening the web UI.
The client reads configuration from:
1. Explicit parameters passed to constructor
2. Environment variables (LANGFUSE_PUBLIC_KEY, LANGFUSE_SECRET_KEY, LANGFUSE_HOST)
3. ~/.praisonai/langfuse.env file (created by `praisonai langfuse config`)
Example:
from praisonai.cli.langfuse_client import LangfuseClient
# Load from config file automatically
client = LangfuseClient.from_config_file()
# Fetch traces
traces = client.get_traces(limit=10)
# Fetch sessions
sessions = client.get_sessions()
# Get trace details
trace = client.get_trace("trace-id-123")
"""
import os
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional
import requests
from requests.auth import HTTPBasicAuth
class LangfuseAPIError(Exception):
"""Raised when Langfuse API returns an error."""
def __init__(self, message: str, status_code: Optional[int] = None):
super().__init__(message)
self.status_code = status_code
class LangfuseClient:
"""
HTTP client for Langfuse API.
Provides methods to fetch traces, sessions, and detailed trace information
from a Langfuse instance (local Docker or cloud).
Authentication uses HTTP Basic Auth with public_key as username and
secret_key as password (standard Langfuse API authentication).
Attributes:
public_key: Langfuse public API key
secret_key: Langfuse secret API key
host: Langfuse host URL (default: http://localhost:3000)
"""
def __init__(
self,
public_key: str,
secret_key: str,
host: str = "http://localhost:3000"
):
"""
Initialize Langfuse API client.
Args:
public_key: Langfuse public API key
secret_key: Langfuse secret API key
host: Langfuse host URL (default: http://localhost:3000)
Raises:
ValueError: If public_key or secret_key is empty
"""
if not public_key or not secret_key:
raise ValueError("Both public_key and secret_key are required")
self.public_key = public_key
self.secret_key = secret_key
self.host = host.rstrip("/") # Remove trailing slash
self._auth = HTTPBasicAuth(public_key, secret_key)
@classmethod
def from_config_file(cls, config_path: Optional[Path] = None) -> "LangfuseClient":
"""
Create client from praisonai config file.
Reads configuration from ~/.praisonai/langfuse.env file which is
created by `praisonai langfuse config` command.
Args:
config_path: Path to config file (default: ~/.praisonai/langfuse.env)
Returns:
Configured LangfuseClient instance
Raises:
FileNotFoundError: If config file doesn't exist
ValueError: If config file is missing required credentials
"""
if config_path is None:
config_path = Path.home() / ".praisonai" / "langfuse.env"
if not config_path.exists():
raise FileNotFoundError(
f"Langfuse config not found at {config_path}. "
f"Run 'praisonai langfuse config --public-key ... --secret-key ...' to set up."
)
# Load env vars from file
env_vars = {}
with open(config_path, "r") as f:
for line in f:
line = line.strip()
if line and not line.startswith("#") and "=" in line:
key, value = line.split("=", 1)
env_vars[key.strip()] = value.strip()
public_key = env_vars.get("LANGFUSE_PUBLIC_KEY") or os.getenv("LANGFUSE_PUBLIC_KEY")
secret_key = env_vars.get("LANGFUSE_SECRET_KEY") or os.getenv("LANGFUSE_SECRET_KEY")
host = env_vars.get("LANGFUSE_HOST") or env_vars.get("LANGFUSE_BASE_URL") or os.getenv("LANGFUSE_HOST") or os.getenv("LANGFUSE_BASE_URL", "http://localhost:3000")
if not public_key or not secret_key:
raise ValueError(
f"Missing Langfuse credentials in {config_path}. "
f"Ensure LANGFUSE_PUBLIC_KEY and LANGFUSE_SECRET_KEY are set."
)
return cls(public_key=public_key, secret_key=secret_key, host=host)
def _make_request(self, endpoint: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""
Make authenticated GET request to Langfuse API.
Args:
endpoint: API endpoint path (e.g., "/api/public/traces")
params: Optional query parameters
Returns:
JSON response as dictionary
Raises:
LangfuseAPIError: If API request fails
"""
url = f"{self.host}{endpoint}"
try:
response = requests.get(url, auth=self._auth, params=params, timeout=30)
response.raise_for_status()
return response.json()
except requests.exceptions.HTTPError as e:
if response.status_code == 401:
raise LangfuseAPIError(
"Authentication failed. Check your LANGFUSE_PUBLIC_KEY and LANGFUSE_SECRET_KEY.",
status_code=401
)
elif response.status_code == 404:
raise LangfuseAPIError(
f"Resource not found: {endpoint}",
status_code=404
)
else:
raise LangfuseAPIError(
f"API error: {e}",
status_code=response.status_code
)
except requests.exceptions.ConnectionError:
raise LangfuseAPIError(
f"Cannot connect to Langfuse at {self.host}. "
f"Ensure the server is running (try 'praisonai langfuse status')."
)
except requests.exceptions.Timeout:
raise LangfuseAPIError("Request timed out. Langfuse server may be slow or unavailable.")
except Exception as e:
raise LangfuseAPIError(f"Unexpected error: {e}")
def get_traces(
self,
limit: int = 20,
session_id: Optional[str] = None,
user_id: Optional[str] = None,
name: Optional[str] = None,
from_timestamp: Optional[datetime] = None,
to_timestamp: Optional[datetime] = None
) -> List[Dict[str, Any]]:
"""
Fetch traces from Langfuse API.
Args:
limit: Maximum number of traces to return (default: 20, max: 100)
session_id: Filter by session ID
user_id: Filter by user ID
name: Filter by trace name (e.g., agent name)
from_timestamp: Filter traces after this time
to_timestamp: Filter traces before this time
Returns:
List of trace dictionaries
Example:
traces = client.get_traces(limit=10, session_id="abc-123")
for trace in traces:
print(f"Trace {trace['id']}: {trace['name']}")
"""
params: Dict[str, Any] = {"limit": min(limit, 100)}
if session_id:
params["sessionId"] = session_id
if user_id:
params["userId"] = user_id
if name:
params["name"] = name
if from_timestamp:
params["fromTimestamp"] = from_timestamp.isoformat()
if to_timestamp:
params["toTimestamp"] = to_timestamp.isoformat()
response = self._make_request("/api/public/traces", params)
return response.get("data", [])
def get_trace(self, trace_id: str) -> Dict[str, Any]:
"""
Fetch detailed information about a specific trace.
Args:
trace_id: Unique trace identifier
Returns:
Trace detail dictionary including observations and scores
Raises:
LangfuseAPIError: If trace not found or API error
Example:
trace = client.get_trace("trace-123")
print(f"Name: {trace['name']}")
print(f"Observations: {len(trace['observations'])}")
"""
return self._make_request(f"/api/public/traces/{trace_id}")
def get_sessions(
self,
limit: int = 20,
from_timestamp: Optional[datetime] = None,
to_timestamp: Optional[datetime] = None
) -> List[Dict[str, Any]]:
"""
Fetch sessions from Langfuse API.
Args:
limit: Maximum number of sessions to return (default: 20, max: 100)
from_timestamp: Filter sessions after this time
to_timestamp: Filter sessions before this time
Returns:
List of session dictionaries with trace counts
Example:
sessions = client.get_sessions(limit=10)
for session in sessions:
print(f"Session {session['id']}: {session['traceCount']} traces")
"""
params: Dict[str, Any] = {"limit": min(limit, 100)}
if from_timestamp:
params["fromTimestamp"] = from_timestamp.isoformat()
if to_timestamp:
params["toTimestamp"] = to_timestamp.isoformat()
response = self._make_request("/api/public/sessions", params)
return response.get("data", [])
def get_session(self, session_id: str) -> Dict[str, Any]:
"""
Fetch detailed information about a specific session.
Args:
session_id: Unique session identifier
Returns:
Session detail dictionary with traces
Raises:
LangfuseAPIError: If session not found or API error
"""
return self._make_request(f"/api/public/sessions/{session_id}")
def get_observations(
self,
trace_id: Optional[str] = None,
type: Optional[str] = None, # "SPAN", "EVENT", "GENERATION"
limit: int = 100
) -> List[Dict[str, Any]]:
"""
Fetch observations from Langfuse API.
Args:
trace_id: Filter by trace ID
type: Filter by observation type (SPAN, EVENT, GENERATION)
limit: Maximum number of observations (default: 100)
Returns:
List of observation dictionaries
"""
params: Dict[str, Any] = {"limit": min(limit, 100)}
if trace_id:
params["traceId"] = trace_id
if type:
params["type"] = type
response = self._make_request("/api/public/observations", params)
return response.get("data", [])
def get_stats(self) -> Dict[str, Any]:
"""
Get basic statistics about the Langfuse project.
Returns:
Dictionary with trace count, session count, and time range
"""
# Fetch traces with limit=1 to get total count from meta
response = self._make_request("/api/public/traces", {"limit": 1})
total_traces = response.get("meta", {}).get("totalCount", 0)
# Fetch sessions with limit=1 to get total count
sessions_response = self._make_request("/api/public/sessions", {"limit": 1})
total_sessions = sessions_response.get("meta", {}).get("totalCount", 0)
return {
"total_traces": total_traces,
"total_sessions": total_sessions,
"host": self.host
}
def load_env_from_file(file_path: Path) -> Dict[str, str]:
"""
Load environment variables from a .env file.
Args:
file_path: Path to the .env file
Returns:
Dictionary of environment variables
"""
env_vars = {}
if file_path.exists():
with open(file_path, "r") as f:
for line in f:
line = line.strip()
if line and not line.startswith("#") and "=" in line:
key, value = line.split("=", 1)
env_vars[key.strip()] = value.strip()
return env_vars
@@ -0,0 +1,10 @@
"""Standalone-safe legacy entry helpers (C8.4)."""
from __future__ import annotations
def dispatch_version_only() -> int:
"""Print version and exit — no wrapper import."""
from praisonai_code._version import get_package_version
print(get_package_version())
return 0
@@ -0,0 +1,9 @@
"""Standalone prompt dispatch without wrapper imports."""
from __future__ import annotations
def run_standalone_help() -> None:
"""Show minimal help when wrapper is not installed."""
print("PraisonAI Code — standalone terminal agent runtime.")
print("Install the full wrapper for YAML workflows: pip install praisonai")
+59 -21
View File
@@ -95,23 +95,29 @@ def _validate_env_key(key) -> None:
# Lazy import helpers for inbuilt_tools and config
def _get_inbuilt_tools():
"""Lazy import inbuilt_tools only when crewai/autogen features are used."""
from praisonai import inbuilt_tools
return inbuilt_tools
from praisonai_code._wrapper_bridge import import_wrapper_module
return import_wrapper_module("praisonai.inbuilt_tools")
def _get_generate_config():
"""Lazy import generate_config only when training features are used."""
from praisonai.inc.config import generate_config
from praisonai_code._wrapper_bridge import import_wrapper_module
_mod = import_wrapper_module('praisonai.inc.config')
generate_config = getattr(_mod, 'generate_config')
return generate_config
# Lazy import helpers for heavy modules
def _get_auto_generator():
"""Lazy import AutoGenerator to avoid loading heavy deps at CLI startup."""
from praisonai.auto import AutoGenerator
from praisonai_code._wrapper_bridge import import_wrapper_module
_mod = import_wrapper_module('praisonai.auto')
AutoGenerator = getattr(_mod, 'AutoGenerator')
return AutoGenerator
def _get_agents_generator():
"""Lazy import AgentsGenerator to avoid loading heavy deps at CLI startup."""
from praisonai.agents_generator import AgentsGenerator
from praisonai_code._wrapper_bridge import import_wrapper_module
_mod = import_wrapper_module('praisonai.agents_generator')
AgentsGenerator = getattr(_mod, 'AgentsGenerator')
return AgentsGenerator
@@ -231,7 +237,9 @@ def _get_call_module():
raise ImportError(
"Call feature is not installed. Install with: pip install \"praisonai[call]\""
)
from praisonai.api import call as call_module
from praisonai_code._wrapper_bridge import import_wrapper_module
_mod = import_wrapper_module('praisonai.api')
call_module = getattr(_mod, 'call')
return call_module
def _get_gradio():
@@ -626,7 +634,9 @@ class PraisonAI:
if args.deploy:
if args.schedule or args.schedule_config:
# Scheduled deployment
from praisonai.scheduler import create_scheduler
from praisonai_code._wrapper_bridge import import_wrapper_module
_mod = import_wrapper_module('praisonai.scheduler')
create_scheduler = getattr(_mod, 'create_scheduler')
# Load configuration from file if provided
config = {"max_retries": args.max_retries}
@@ -717,7 +727,9 @@ class PraisonAI:
if getattr(args, 'realtime', False):
try:
from praisonai.cli.commands.ui import _launch_aiui_app
from praisonai_code._wrapper_bridge import import_wrapper_module
_mod = import_wrapper_module('praisonai.cli.commands.ui')
_launch_aiui_app = getattr(_mod, '_launch_aiui_app')
_launch_aiui_app("ui_realtime", "ui_realtime", 8085, "127.0.0.1", None, False, "Realtime Voice")
except ImportError:
print("\033[91mERROR: Realtime UI is not installed.\033[0m")
@@ -780,7 +792,9 @@ class PraisonAI:
config["ollama_save"] = "true"
if 'init' in sys.argv:
from praisonai.setup.setup_conda_env import main as setup_conda_main
from praisonai_code._wrapper_bridge import import_wrapper_module
_mod = import_wrapper_module('praisonai.setup.setup_conda_env')
setup_conda_main = getattr(_mod, 'main')
setup_conda_main()
print("All packages installed")
return
@@ -796,7 +810,9 @@ class PraisonAI:
conda_env_exists = True
else:
print("Conda environment 'praison_env' not found. Setting it up...")
from praisonai.setup.setup_conda_env import main as setup_conda_main
from praisonai_code._wrapper_bridge import import_wrapper_module
_mod = import_wrapper_module('praisonai.setup.setup_conda_env')
setup_conda_main = getattr(_mod, 'main')
setup_conda_main()
print("All packages installed.")
# Check again if environment was created successfully
@@ -946,7 +962,9 @@ class PraisonAI:
run_id = f"run-{uuid.uuid4().hex[:12]}"
if save_replay:
try:
from praisonai.replay import ContextTraceWriter
from praisonai_code._wrapper_bridge import import_wrapper_module
_mod = import_wrapper_module('praisonai.replay')
ContextTraceWriter = getattr(_mod, 'ContextTraceWriter')
from praisonaiagents.trace.context_events import ContextTraceEmitter, set_context_emitter
trace_writer = ContextTraceWriter(session_id=run_id)
@@ -1442,7 +1460,9 @@ class PraisonAI:
elif args.command == 'realtime':
try:
from praisonai.cli.commands.ui import _launch_aiui_app
from praisonai_code._wrapper_bridge import import_wrapper_module
_mod = import_wrapper_module('praisonai.cli.commands.ui')
_launch_aiui_app = getattr(_mod, '_launch_aiui_app')
_launch_aiui_app("ui_realtime", "ui_realtime", 8085, "127.0.0.1", None, False, "Realtime Voice")
except ImportError:
print("\033[91mERROR: Realtime UI is not installed.\033[0m")
@@ -1893,7 +1913,9 @@ class PraisonAI:
elif args.command == 'browser':
# Browser agent command - delegate to browser CLI Typer app
# Uses sys.argv replacement for proper arg passthrough (matches profile command pattern)
from praisonai.browser.cli import app as browser_app
from praisonai_code._wrapper_bridge import import_wrapper_module
_mod = import_wrapper_module('praisonai.browser.cli')
browser_app = getattr(_mod, 'app')
import sys as _sys
_sys.argv = ['praisonai', 'browser'] + unknown_args
try:
@@ -2888,7 +2910,9 @@ class PraisonAI:
run_id = f"run-{uuid.uuid4().hex[:12]}"
if save_replay:
try:
from praisonai.replay import ContextTraceWriter
from praisonai_code._wrapper_bridge import import_wrapper_module
_mod = import_wrapper_module('praisonai.replay')
ContextTraceWriter = getattr(_mod, 'ContextTraceWriter')
from praisonaiagents.trace.context_events import ContextTraceEmitter, set_context_emitter
from pathlib import Path
@@ -3264,7 +3288,9 @@ class PraisonAI:
print(f"[cyan]Generating {pattern} workflow for: {topic}[/cyan]")
try:
from praisonai.auto import WorkflowAutoGenerator
from praisonai_code._wrapper_bridge import import_wrapper_module
_mod = import_wrapper_module('praisonai.auto')
WorkflowAutoGenerator = getattr(_mod, 'WorkflowAutoGenerator')
generator = WorkflowAutoGenerator(
topic=topic,
@@ -3518,7 +3544,9 @@ class PraisonAI:
# Also handle 'cli' subcommand group for CLI validation
code_validation_actions = {'run', 'run-all', 'stats', 'report', 'generate', 'serve', 'cli'}
if action in code_validation_actions:
from praisonai.cli.commands.docs import app as docs_app
from praisonai_code._wrapper_bridge import import_wrapper_module
_mod = import_wrapper_module('praisonai.cli.commands.docs')
docs_app = getattr(_mod, 'app')
import typer
# Build args list for typer
typer_args = [action] + action_args
@@ -3727,19 +3755,25 @@ class PraisonAI:
elif action == 'serve':
# Start MCP server
from praisonai.mcp_server.cli import handle_mcp_command as mcp_serve_handler
from praisonai_code._wrapper_bridge import import_wrapper_module
_mod = import_wrapper_module('praisonai.mcp_server.cli')
mcp_serve_handler = getattr(_mod, 'handle_mcp_command')
exit_code = mcp_serve_handler(['serve'] + action_args)
sys.exit(exit_code)
elif action == 'list-tools':
# List MCP tools
from praisonai.mcp_server.cli import handle_mcp_command as mcp_serve_handler
from praisonai_code._wrapper_bridge import import_wrapper_module
_mod = import_wrapper_module('praisonai.mcp_server.cli')
mcp_serve_handler = getattr(_mod, 'handle_mcp_command')
exit_code = mcp_serve_handler(['list-tools'] + action_args)
sys.exit(exit_code)
elif action == 'config-generate':
# Generate client config
from praisonai.mcp_server.cli import handle_mcp_command as mcp_serve_handler
from praisonai_code._wrapper_bridge import import_wrapper_module
_mod = import_wrapper_module('praisonai.mcp_server.cli')
mcp_serve_handler = getattr(_mod, 'handle_mcp_command')
exit_code = mcp_serve_handler(['config-generate'] + action_args)
sys.exit(exit_code)
@@ -5708,7 +5742,9 @@ Now, {final_instruction.lower()}:"""
Routes to the new `praisonai ui agents` subcommand.
"""
try:
from praisonai.cli.commands.ui import _launch_aiui_app
from praisonai_code._wrapper_bridge import import_wrapper_module
_mod = import_wrapper_module('praisonai.cli.commands.ui')
_launch_aiui_app = getattr(_mod, '_launch_aiui_app')
print("🤖 Launching PraisonAI Agents Dashboard (aiui)...")
_launch_aiui_app(
app_dir="ui_agents",
@@ -5958,7 +5994,9 @@ Now, {final_instruction.lower()}:"""
context = ""
try:
from praisonai.integration.context_files import load_context_files
from praisonai_code._wrapper_bridge import import_wrapper_module
_mod = import_wrapper_module('praisonai.integration.context_files')
load_context_files = getattr(_mod, 'load_context_files')
context = load_context_files(walk_up=True) or ""
except ImportError:
context = "" # Context files helper is optional
+16 -44
View File
@@ -1,50 +1,22 @@
"""
PraisonAI Adapters - Implementations for core protocols.
"""Wrapper-side adapters for C8.5 CLI protocols."""
This module provides concrete implementations of:
- Reader adapters (AutoReader, LlamaIndexReaderAdapter, MarkItDownReaderAdapter)
- Vector store adapters (ChromaAdapter, PineconeAdapter, etc.)
- Retriever implementations
- Reranker implementations
"""
from __future__ import annotations
# Lazy loading to avoid heavy imports at package load time
_LAZY_IMPORTS = {
# Readers
"AutoReader": ("praisonai.adapters.readers", "AutoReader"),
"MarkItDownReader": ("praisonai.adapters.readers", "MarkItDownReader"),
"TextReader": ("praisonai.adapters.readers", "TextReader"),
"DirectoryReader": ("praisonai.adapters.readers", "DirectoryReader"),
"register_default_readers": ("praisonai.adapters.readers", "register_default_readers"),
# Vector stores
"ChromaVectorStore": ("praisonai.adapters.vector_stores", "ChromaVectorStore"),
"register_default_vector_stores": ("praisonai.adapters.vector_stores", "register_default_vector_stores"),
# Retrievers
"BasicRetriever": ("praisonai.adapters.retrievers", "BasicRetriever"),
"FusionRetriever": ("praisonai.adapters.retrievers", "FusionRetriever"),
"register_default_retrievers": ("praisonai.adapters.retrievers", "register_default_retrievers"),
# Rerankers
"LLMReranker": ("praisonai.adapters.rerankers", "LLMReranker"),
"register_default_rerankers": ("praisonai.adapters.rerankers", "register_default_rerankers"),
}
from typing import Any, Optional
def __getattr__(name: str):
"""Lazy load adapters."""
if name in _LAZY_IMPORTS:
module_path, attr_name = _LAZY_IMPORTS[name]
import importlib
module = importlib.import_module(module_path)
return getattr(module, attr_name)
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
class ServeHandlerAdapter:
"""Adapter wrapping ``praisonai.cli.features.serve.handle_serve_command``."""
def handle(self, args: list[str]) -> int:
from praisonai.cli.features.serve import handle_serve_command
return int(handle_serve_command(args) or 0)
def __dir__():
"""List available attributes."""
return list(_LAZY_IMPORTS.keys())
__all__ = list(_LAZY_IMPORTS.keys())
def get_template_store() -> Optional[Any]:
"""Return template store when wrapper templates feature is available."""
try:
from praisonai.cli.features import templates as templates_mod
return templates_mod
except ImportError:
return None
@@ -112,4 +112,10 @@ def __getattr__(name: str):
elif name == 'command_app':
from .command import app as command_app
return command_app
# C8: allow ``praisonai.cli.commands.<name>`` attribute access for patching
import importlib
try:
return importlib.import_module(f".{name}", __package__)
except ModuleNotFoundError:
pass
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
+134 -15
View File
@@ -1,20 +1,139 @@
"""Backward-compatibility shim for :mod:`praisonai.cli.commands.app`.
"""
CLI command for AgentOS - production deployment of AI agents.
The implementation moved to :mod:`praisonai_code.cli.commands.app` as part
of the praisonai-code extraction (issue #2516 / parent #2512).
This shim aliases the moved module into ``sys.modules`` under the old dotted
path so that:
* ``from praisonai.cli.commands.app import X`` keeps working, and
* ``unittest.mock.patch("praisonai.cli.commands.app.X")`` patches the very
same module object that the implementation executes against.
Usage:
praisonai app # Start server with default config
praisonai app --port 9000 # Start on custom port
praisonai app --config app.yaml # Load from config file
praisonai app --reload # Enable auto-reload for development
"""
import sys as _sys
import click
from typing import Optional
from praisonai_code.cli.commands import app as _impl
# Make the old dotted path resolve to the exact same module object so that
# attribute patching / monkeypatching stays transparent across both paths.
_sys.modules[__name__] = _impl
@click.command("app")
@click.option("--port", "-p", type=int, default=8000, help="Port to listen on")
@click.option("--host", "-h", type=str, default="0.0.0.0", help="Host to bind to")
@click.option("--config", "-c", type=click.Path(exists=True), help="Path to config file (YAML)")
@click.option("--reload", "-r", is_flag=True, help="Enable auto-reload for development")
@click.option("--debug", "-d", is_flag=True, help="Enable debug mode")
@click.option("--name", "-n", type=str, default="PraisonAI App", help="Application name")
def app_command(
port: int,
host: str,
config: Optional[str],
reload: bool,
debug: bool,
name: str,
):
"""
Start an AgentOS server for production deployment.
AgentOS provides a FastAPI-based web service for deploying AI agents
with REST and WebSocket endpoints.
Examples:
# Start with defaults (port 8000)
praisonai app
# Start on custom port
praisonai app --port 9000
# Enable auto-reload for development
praisonai app --reload
# Load agents from config file
praisonai app --config agents.yaml
"""
from rich.console import Console
console = Console()
try:
from praisonai import AgentOS
from praisonaiagents import AgentOSConfig
except ImportError as e:
console.print(f"[red]Error importing AgentOS: {e}[/red]")
console.print("[yellow]Install with: pip install praisonai[api][/yellow]")
raise click.Abort()
# Load agents from config file if provided
agents = []
if config:
agents = _load_agents_from_config(config, console)
# Create config
app_config = AgentOSConfig(
name=name,
host=host,
port=port,
reload=reload,
debug=debug,
)
# Create and start app
console.print(f"\n[bold green]🚀 Starting {name}[/bold green]")
console.print(f"[dim]Host: {host}:{port}[/dim]")
if agents:
console.print(f"[dim]Agents: {len(agents)}[/dim]")
if reload:
console.print("[yellow]Auto-reload enabled (development mode)[/yellow]")
console.print()
try:
agent_app = AgentOS(
name=name,
agents=agents,
config=app_config,
)
agent_app.serve()
except ImportError as e:
console.print(f"[red]Missing dependency: {e}[/red]")
console.print("[yellow]Install with: pip install praisonai[api][/yellow]")
raise click.Abort()
except Exception as e:
console.print(f"[red]Error starting server: {e}[/red]")
raise click.Abort()
def _load_agents_from_config(config_path: str, console) -> list:
"""Load agents from a YAML config file."""
import yaml
from pathlib import Path
try:
with open(config_path, 'r') as f:
config_data = yaml.safe_load(f)
except Exception as e:
console.print(f"[red]Error loading config: {e}[/red]")
return []
if not config_data:
return []
agents = []
# Try to load agents from config
agents_config = config_data.get('agents', [])
if not agents_config and 'agent' in config_data:
agents_config = [config_data['agent']]
if agents_config:
try:
from praisonaiagents import Agent
for agent_data in agents_config:
if isinstance(agent_data, dict):
agent = Agent(
name=agent_data.get('name', 'Agent'),
role=agent_data.get('role'),
instructions=agent_data.get('instructions', agent_data.get('goal', '')),
llm=agent_data.get('llm'),
)
agents.append(agent)
console.print(f"[green]✓ Loaded agent: {agent.name}[/green]")
except Exception as e:
console.print(f"[yellow]Warning: Could not load agents from config: {e}[/yellow]")
return agents
+97 -15
View File
@@ -1,20 +1,102 @@
"""Backward-compatibility shim for :mod:`praisonai.cli.commands.audit`.
"""
CLI command for agent-centric compliance auditing.
The implementation moved to :mod:`praisonai_code.cli.commands.audit` as part
of the praisonai-code extraction (issue #2516 / parent #2512).
This shim aliases the moved module into ``sys.modules`` under the old dotted
path so that:
* ``from praisonai.cli.commands.audit import X`` keeps working, and
* ``unittest.mock.patch("praisonai.cli.commands.audit.X")`` patches the very
same module object that the implementation executes against.
Usage:
praisonai audit agent-centric --scan /path/to/examples
praisonai audit agent-centric --fix /path/to/examples
praisonai audit agent-centric --check /path/to/examples
"""
import sys as _sys
import click
import json
import sys
from pathlib import Path
from praisonai_code.cli.commands import audit as _impl
# Make the old dotted path resolve to the exact same module object so that
# attribute patching / monkeypatching stays transparent across both paths.
_sys.modules[__name__] = _impl
@click.group()
def audit():
"""Audit commands for compliance checking."""
pass
@audit.command("agent-centric")
@click.option("--scan", "scan_path", type=click.Path(exists=True), help="Scan path for compliance")
@click.option("--fix", "fix_path", type=click.Path(exists=True), help="Fix non-compliant files")
@click.option("--check", "check_path", type=click.Path(exists=True), help="Check and fail if non-compliant")
@click.option("--json", "output_json", is_flag=True, help="Output as JSON")
@click.option("--line-limit", default=40, help="Line limit for header scan (default: 40)")
@click.option("--only-examples", is_flag=True, help="Only scan Python examples")
@click.option("--only-docs", is_flag=True, help="Only scan documentation")
@click.option("--verbose", "-v", is_flag=True, help="Verbose output")
def agent_centric(scan_path, fix_path, check_path, output_json, line_limit, only_examples, only_docs, verbose):
"""
Audit agent-centric compliance for examples and docs.
Examples:
praisonai audit agent-centric --scan ./examples
praisonai audit agent-centric --fix ./examples --verbose
praisonai audit agent-centric --check ./examples # Fails if non-compliant
"""
from praisonai.audit.agent_centric import AgentCentricAuditor, ComplianceStatus
path = scan_path or fix_path or check_path
if not path:
click.echo("Error: Must specify --scan, --fix, or --check with a path", err=True)
sys.exit(1)
auditor = AgentCentricAuditor(line_limit=line_limit, verbose=verbose)
if fix_path:
# Fix mode
click.echo(f"Fixing non-compliant files in: {fix_path}")
report = auditor.scan(fix_path)
fixed_count = 0
for result in report.results:
if result.status == ComplianceStatus.NON_COMPLIANT:
success, reason = auditor.fix_file(result.file_path)
if success:
fixed_count += 1
if verbose:
click.echo(f" Fixed: {result.file_path} - {reason}")
click.echo(f"\nFixed {fixed_count} files")
# Re-scan to verify
report = auditor.scan(fix_path)
click.echo(f"Compliance rate: {report.compliance_rate():.1f}%")
else:
# Scan or check mode
report = auditor.scan(path)
if output_json:
click.echo(auditor.to_json(report))
else:
click.echo(f"\n{'='*60}")
click.echo("AGENT-CENTRIC COMPLIANCE REPORT")
click.echo(f"{'='*60}")
click.echo(f"Total files: {report.total_files}")
click.echo(f"Compliant: {report.compliant}")
click.echo(f"Non-compliant: {report.non_compliant}")
click.echo(f"Skipped: {report.skipped}")
click.echo(f"Compliance rate: {report.compliance_rate():.1f}%")
if report.by_reason:
click.echo(f"\nBy Reason:")
for reason, count in sorted(report.by_reason.items()):
click.echo(f" {reason}: {count}")
if verbose and report.non_compliant > 0:
click.echo(f"\nNon-compliant files:")
for r in report.results:
if r.status == ComplianceStatus.NON_COMPLIANT:
click.echo(f" {r.file_path}")
click.echo(f" Reason: {r.reason.value if r.reason else 'unknown'}")
# Check mode - fail if non-compliant
if check_path and report.non_compliant > 0:
click.echo(f"\n❌ FAILED: {report.non_compliant} non-compliant files found", err=True)
sys.exit(1)
elif check_path:
click.echo(f"\n✓ PASSED: All files are agent-centric compliant")
+842 -14
View File
@@ -1,20 +1,848 @@
"""Backward-compatibility shim for :mod:`praisonai.cli.commands.batch`.
"""
PraisonAI Batch CLI Command.
The implementation moved to :mod:`praisonai_code.cli.commands.batch` as part
of the praisonai-code extraction (issue #2516 / parent #2512).
Run all Python files with PraisonAI imports in the current directory.
Designed for quick debugging - running multiple local scripts at once.
This shim aliases the moved module into ``sys.modules`` under the old dotted
path so that:
* ``from praisonai.cli.commands.batch import X`` keeps working, and
* ``unittest.mock.patch("praisonai.cli.commands.batch.X")`` patches the very
same module object that the implementation executes against.
Usage:
praisonai batch # Run all PraisonAI scripts in current dir
praisonai batch --sub # Include subdirectories
praisonai batch --sub --depth 2 # Limit recursion depth
praisonai batch --parallel # Run in parallel with async reporting
praisonai batch list # List discovered files
praisonai batch stats # Show statistics
praisonai batch report # View latest report
"""
import sys as _sys
from datetime import datetime
from pathlib import Path
from typing import Optional
from praisonai_code.cli.commands import batch as _impl
import typer
# Make the old dotted path resolve to the exact same module object so that
# attribute patching / monkeypatching stays transparent across both paths.
_sys.modules[__name__] = _impl
app = typer.Typer(
help="Run all PraisonAI scripts in current folder for quick debugging",
no_args_is_help=False,
)
def _get_default_report_dir() -> Path:
"""Get default report directory with timestamp."""
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
return Path.home() / "Downloads" / "reports" / "batch" / timestamp
def _get_latest_report_dir() -> Optional[Path]:
"""Get the latest report directory."""
base_dir = Path.home() / "Downloads" / "reports" / "batch"
if not base_dir.exists():
return None
# Find most recent timestamp directory
dirs = [d for d in base_dir.iterdir() if d.is_dir()]
if not dirs:
return None
return max(dirs, key=lambda d: d.name)
@app.callback(invoke_without_command=True)
def batch_run(
ctx: typer.Context,
path: Optional[Path] = typer.Option(
None,
"--path", "-p",
help="Path to search (default: current directory)",
),
recursive: bool = typer.Option(
False,
"--sub", "--recursive", "-r",
help="Include subdirectories",
),
depth: Optional[int] = typer.Option(
None,
"--depth", "-d",
help="Maximum recursion depth (only with --sub)",
),
include_tests: bool = typer.Option(
False,
"--include-tests",
help="Include test files (test_*.py, *_test.py)",
),
timeout: int = typer.Option(
60,
"--timeout", "-t",
help="Per-script timeout in seconds",
),
max_items: Optional[int] = typer.Option(
None,
"--max-items",
help="Maximum scripts to run",
),
fail_fast: bool = typer.Option(
False,
"--fail-fast", "-x",
help="Stop on first failure",
),
parallel: bool = typer.Option(
True,
"--parallel/--sequential",
help="Run in parallel (default: parallel)",
),
max_workers: int = typer.Option(
4,
"--workers", "-w",
help="Max parallel workers (only with --parallel)",
),
no_stream: bool = typer.Option(
False,
"--no-stream",
help="Don't stream output to terminal",
),
report_dir: Optional[Path] = typer.Option(
None,
"--report-dir",
help="Directory for reports (default: ~/Downloads/reports/batch/<timestamp>)",
),
no_report: bool = typer.Option(
False,
"--no-report",
help="Skip report generation",
),
quiet: bool = typer.Option(
False,
"--quiet", "-q",
help="Minimal output",
),
server: bool = typer.Option(
False,
"--server",
help="Run only server scripts (uvicorn, Flask, streamlit, etc.) with 10s timeout",
),
filter_type: Optional[str] = typer.Option(
None,
"--filter", "-f",
help="Filter by type: 'agent', 'agents', or 'workflow'",
),
ci: bool = typer.Option(
False,
"--ci",
help="CI-friendly output (no colors, proper exit codes)",
),
):
"""
Run all PraisonAI scripts in the current folder.
Quick debugging tool for running multiple Python files that use
praisonaiagents or praisonai. Unlike 'praisonai run' (which runs
agent configs) or 'praisonai examples' (which runs repo examples),
this command finds and executes local scripts for rapid testing.
Only files containing 'from praisonaiagents' or 'from praisonai'
are executed. Test files (test_*.py) are excluded by default.
Examples:
praisonai batch # Run all in current dir
praisonai batch --sub # Include subdirectories
praisonai batch --sub --depth 2 # Limit depth
praisonai batch --parallel # Run in parallel
praisonai batch --timeout 120 # Custom timeout
"""
# If a subcommand was invoked, skip the default run
if ctx.invoked_subcommand is not None:
return
# Lazy import to avoid loading at CLI startup
from praisonai.suite_runner import BatchSource, SuiteExecutor, RunResult
# Resolve paths
search_path = Path(path).resolve() if path else Path.cwd().resolve()
output_dir = report_dir or _get_default_report_dir()
if not search_path.exists():
if ci:
print(f"ERROR: Path not found: {search_path}")
else:
typer.echo(f"❌ Path not found: {search_path}")
raise typer.Exit(2)
# Validate filter_type
if filter_type and filter_type not in ("agent", "agents", "workflow"):
if ci:
print(f"ERROR: Invalid filter type: {filter_type}. Use 'agent', 'agents', or 'workflow'")
else:
typer.echo(f"❌ Invalid filter type: {filter_type}. Use 'agent', 'agents', or 'workflow'")
raise typer.Exit(2)
# Server mode uses 10s timeout by default
effective_timeout = 10 if server else timeout
# Create source
source = BatchSource(
root=search_path,
recursive=recursive,
depth=depth,
exclude_tests=not include_tests,
exclude_servers=not server, # Exclude servers by default, include when --server
server_only=server, # Only servers when --server flag
filter_type=filter_type,
)
# Discover items
items = source.discover()
if not items:
if ci:
print(f"INFO: No PraisonAI scripts found in {search_path}")
else:
typer.echo(f"ℹ️ No PraisonAI scripts found in {search_path}")
if not recursive:
typer.echo(" Tip: Use --sub to include subdirectories")
raise typer.Exit(0)
# Parallel execution
if parallel:
_run_parallel(
items=items,
source=source,
search_path=search_path,
output_dir=output_dir,
timeout=effective_timeout,
max_workers=max_workers,
quiet=quiet,
ci=ci,
)
return
# Sequential execution (default)
executor = SuiteExecutor(
suite="batch",
source_path=search_path,
timeout=effective_timeout,
fail_fast=fail_fast,
stream_output=not no_stream,
max_items=max_items,
report_dir=output_dir if not no_report else None,
generate_json=not no_report,
generate_md=not no_report,
generate_csv=not no_report,
pythonpath_additions=source.get_pythonpath(),
)
# Status icons (CI mode uses text)
if ci:
icons = {
"passed": "PASS",
"failed": "FAIL",
"skipped": "SKIP",
"timeout": "TIMEOUT",
"not_run": "NOT_RUN",
"xfail": "XFAIL",
}
else:
icons = {
"passed": "✅",
"failed": "❌",
"skipped": "⏭️",
"timeout": "⏱️",
"not_run": "📝",
"xfail": "⚠️",
}
def on_item_start(item, idx: int, total: int):
if not quiet:
if ci:
print(f"[{idx}/{total}] Running: {item.display_name}")
else:
typer.echo(f"\n[{idx}/{total}] Running: {item.display_name}")
def on_item_end(result: RunResult, idx: int, total: int):
icon = icons.get(result.status, "?")
duration = f"{result.duration_seconds:.2f}s" if result.duration_seconds else ""
if quiet:
if ci:
print(f"{icon} {result.display_name}")
else:
typer.echo(f"{icon} {result.display_name}")
else:
msg = f" {icon} {result.status.upper()}"
if duration:
msg += f" ({duration})"
if result.skip_reason:
msg += f" - {result.skip_reason}"
if result.error_message and not no_stream:
msg += f"\n Error: {result.error_message[:100]}"
if ci:
print(msg)
else:
typer.echo(msg)
def on_output(line: str, stream: str):
if not quiet and not no_stream:
prefix = " | " if ci else " │ "
if stream != "stdout":
prefix += "[err] "
if ci:
print(f"{prefix}{line.rstrip()}")
else:
typer.echo(f"{prefix}{line.rstrip()}")
# Print header
if not quiet:
if ci:
print("=" * 60)
print("PraisonAI Batch Runner")
print("=" * 60)
print(f"Path: {search_path}")
print(f"Recursive: {recursive}")
if depth:
print(f"Depth: {depth}")
print(f"Timeout: {effective_timeout}s")
print(f"Scripts: {len(items)}")
if server:
print("Mode: Server scripts only")
if filter_type:
print(f"Filter: {filter_type}")
if not no_report:
print(f"Reports: {output_dir}")
else:
typer.echo("=" * 60)
typer.echo("PraisonAI Batch Runner")
typer.echo("=" * 60)
typer.echo(f"Path: {search_path}")
typer.echo(f"Recursive: {recursive}")
if depth:
typer.echo(f"Depth: {depth}")
typer.echo(f"Timeout: {effective_timeout}s")
typer.echo(f"Scripts: {len(items)}")
if server:
typer.echo("Mode: Server scripts only")
if filter_type:
typer.echo(f"Filter: {filter_type}")
if not no_report:
typer.echo(f"Reports: {output_dir}")
# Run
report = executor.run(
items=items,
on_item_start=on_item_start,
on_item_end=on_item_end,
on_output=on_output if not no_stream else None,
)
# Print summary
totals = report.totals
total_count = sum(totals.values())
if not quiet:
if ci:
print("")
print("=" * 60)
print("SUMMARY")
print("=" * 60)
print(f" PASSED: {totals['passed']}")
print(f" FAILED: {totals['failed']}")
print(f" SKIPPED: {totals['skipped']}")
print(f" TIMEOUT: {totals['timeout']}")
print(" -----------------")
print(f" TOTAL: {total_count}")
print("=" * 60)
if not no_report and output_dir.exists():
print(f"Reports: {output_dir}")
else:
typer.echo("\n" + "=" * 60)
typer.echo("SUMMARY")
typer.echo("=" * 60)
typer.echo(f" ✅ Passed: {totals['passed']}")
typer.echo(f" ❌ Failed: {totals['failed']}")
typer.echo(f" ⏭️ Skipped: {totals['skipped']}")
typer.echo(f" ⏱️ Timeout: {totals['timeout']}")
typer.echo(" ─────────────────")
typer.echo(f" Total: {total_count}")
typer.echo("=" * 60)
if not no_report and output_dir.exists():
typer.echo(f"\n📁 Reports saved to: {output_dir}")
# Exit code
if totals['failed'] > 0 or totals['timeout'] > 0:
raise typer.Exit(1)
raise typer.Exit(0)
def _run_parallel(
items,
source,
search_path: Path,
output_dir: Path,
timeout: int,
max_workers: int,
quiet: bool,
ci: bool = False,
):
"""Run items in parallel with async reporting."""
import subprocess
import sys
from concurrent.futures import ThreadPoolExecutor, as_completed
from praisonai.suite_runner import RunResult, RunReport, SuiteReporter
from datetime import timezone
if ci:
print("=" * 60)
print("PraisonAI Batch Runner (Parallel Mode)")
print("=" * 60)
print(f"Path: {search_path}")
print(f"Workers: {max_workers}")
print(f"Timeout: {timeout}s")
print(f"Scripts: {len(items)}")
print(f"Reports: {output_dir}")
print("")
print("Running... Progress:")
print("-" * 60)
else:
typer.echo("=" * 60)
typer.echo("PraisonAI Batch Runner (Parallel Mode)")
typer.echo("=" * 60)
typer.echo(f"Path: {search_path}")
typer.echo(f"Workers: {max_workers}")
typer.echo(f"Timeout: {timeout}s")
typer.echo(f"Scripts: {len(items)}")
typer.echo(f"Reports: {output_dir}")
typer.echo("")
typer.echo("Running in background... Progress:")
typer.echo("-" * 60)
results = []
completed = 0
total = len(items)
# Build environment
import os
env = os.environ.copy()
pythonpath = source.get_pythonpath()
if pythonpath:
existing = env.get('PYTHONPATH', '')
env['PYTHONPATH'] = os.pathsep.join(pythonpath + ([existing] if existing else []))
def run_item(item):
"""Run a single item and return result."""
nonlocal completed
if not item.runnable:
completed += 1
return RunResult(
item_id=item.item_id,
suite="batch",
group=item.group,
source_path=item.source_path,
status="skipped" if item.skip else "not_run",
skip_reason=item.skip_reason,
code_hash=item.code_hash,
)
start_time = datetime.now(timezone.utc)
try:
result = subprocess.run(
[sys.executable, '-u', str(item.script_path)],
capture_output=True,
text=True,
timeout=timeout,
env=env,
cwd=item.script_path.parent,
)
end_time = datetime.now(timezone.utc)
duration = (end_time - start_time).total_seconds()
status = "passed" if result.returncode == 0 else "failed"
if item.xfail and result.returncode != 0:
status = "xfail"
error_message = None
if result.returncode != 0 and result.stderr:
lines = result.stderr.strip().split('\n')
error_message = lines[-1][:200] if lines else None
completed += 1
# Progress indicator
if ci:
icon = "PASS" if status == "passed" else "FAIL" if status == "failed" else "XFAIL"
else:
icon = "✅" if status == "passed" else "❌" if status == "failed" else "⚠️"
if not quiet:
if ci:
print(f" [{completed}/{total}] {icon} {item.display_name} ({duration:.2f}s)")
else:
typer.echo(f" [{completed}/{total}] {icon} {item.display_name} ({duration:.2f}s)")
return RunResult(
item_id=item.item_id,
suite="batch",
group=item.group,
source_path=item.source_path,
status=status,
exit_code=result.returncode,
duration_seconds=duration,
start_time=start_time.isoformat(),
end_time=end_time.isoformat(),
error_message=error_message,
stdout=result.stdout,
stderr=result.stderr,
code_hash=item.code_hash,
)
except subprocess.TimeoutExpired:
completed += 1
end_time = datetime.now(timezone.utc)
duration = (end_time - start_time).total_seconds()
if not quiet:
if ci:
print(f" [{completed}/{total}] TIMEOUT {item.display_name} (timeout)")
else:
typer.echo(f" [{completed}/{total}] ⏱️ {item.display_name} (timeout)")
return RunResult(
item_id=item.item_id,
suite="batch",
group=item.group,
source_path=item.source_path,
status="timeout",
duration_seconds=duration,
error_message=f"Exceeded {timeout}s timeout",
code_hash=item.code_hash,
)
except Exception as e:
completed += 1
if not quiet:
if ci:
print(f" [{completed}/{total}] FAIL {item.display_name} (error: {e})")
else:
typer.echo(f" [{completed}/{total}] ❌ {item.display_name} (error: {e})")
return RunResult(
item_id=item.item_id,
suite="batch",
group=item.group,
source_path=item.source_path,
status="failed",
error_message=str(e),
code_hash=item.code_hash,
)
# Run in parallel
with ThreadPoolExecutor(max_workers=max_workers) as executor:
futures = {executor.submit(run_item, item): item for item in items}
for future in as_completed(futures):
result = future.result()
results.append(result)
# Create report
report = RunReport(
results=results,
suite="batch",
source_path=search_path,
groups_run=[],
)
# Generate reports
output_dir.mkdir(parents=True, exist_ok=True)
reporter = SuiteReporter(output_dir)
reporter.save_logs(results)
reporter.generate_json(report)
reporter.generate_markdown(report)
reporter.generate_csv(report)
# Print summary
totals = report.totals
total_count = sum(totals.values())
if ci:
print("-" * 60)
print("")
print("SUMMARY")
print("=" * 60)
print(f" PASSED: {totals['passed']}")
print(f" FAILED: {totals['failed']}")
print(f" SKIPPED: {totals['skipped']}")
print(f" TIMEOUT: {totals['timeout']}")
print(" -----------------")
print(f" TOTAL: {total_count}")
print("=" * 60)
print(f"Reports: {output_dir}")
else:
typer.echo("-" * 60)
typer.echo("\nSUMMARY")
typer.echo("=" * 60)
typer.echo(f" ✅ Passed: {totals['passed']}")
typer.echo(f" ❌ Failed: {totals['failed']}")
typer.echo(f" ⏭️ Skipped: {totals['skipped']}")
typer.echo(f" ⏱️ Timeout: {totals['timeout']}")
typer.echo(" ─────────────────")
typer.echo(f" Total: {total_count}")
typer.echo("=" * 60)
typer.echo(f"\n📁 Reports saved to: {output_dir}")
# Exit code
if totals['failed'] > 0 or totals['timeout'] > 0:
raise typer.Exit(1)
raise typer.Exit(0)
@app.command("list")
def batch_list(
path: Optional[Path] = typer.Option(
None,
"--path", "-p",
help="Path to search",
),
recursive: bool = typer.Option(
False,
"--sub", "--recursive", "-r",
help="Include subdirectories",
),
depth: Optional[int] = typer.Option(
None,
"--depth", "-d",
help="Maximum recursion depth",
),
include_tests: bool = typer.Option(
False,
"--include-tests",
help="Include test files",
),
show_groups: bool = typer.Option(
False,
"--groups",
help="Show available groups only",
),
):
"""
List discovered PraisonAI scripts without running them.
Examples:
praisonai batch list
praisonai batch list --sub
praisonai batch list --groups
"""
from praisonai.suite_runner import BatchSource
search_path = Path(path).resolve() if path else Path.cwd().resolve()
if not search_path.exists():
typer.echo(f"❌ Path not found: {search_path}")
raise typer.Exit(2)
source = BatchSource(
root=search_path,
recursive=recursive,
depth=depth,
exclude_tests=not include_tests,
)
# Show groups only
if show_groups:
groups = source.get_groups()
if not groups:
typer.echo(f"No PraisonAI scripts found in {search_path}")
return
typer.echo(f"Available groups in {search_path}:\n")
for g in groups:
typer.echo(f" - {g}")
return
items = source.discover()
if not items:
typer.echo(f"No PraisonAI scripts found in {search_path}")
if not recursive:
typer.echo("Tip: Use --sub to include subdirectories")
return
typer.echo(f"Found {len(items)} PraisonAI scripts in {search_path}\n")
for idx, item in enumerate(items, 1):
rel_path = item.source_path.relative_to(search_path)
flags = []
if item.skip:
flags.append("skip")
if item.uses_agent:
flags.append("Agent")
if item.uses_agents:
flags.append("Agents")
if item.uses_workflow:
flags.append("Workflow")
flag_str = f" [{', '.join(flags)}]" if flags else ""
typer.echo(f" {idx:3}. [{item.group}] {rel_path}{flag_str}")
@app.command("stats")
def batch_stats(
path: Optional[Path] = typer.Option(
None,
"--path", "-p",
help="Path to search",
),
recursive: bool = typer.Option(
False,
"--sub", "--recursive", "-r",
help="Include subdirectories",
),
depth: Optional[int] = typer.Option(
None,
"--depth", "-d",
help="Maximum recursion depth",
),
include_tests: bool = typer.Option(
False,
"--include-tests",
help="Include test files",
),
):
"""
Show statistics for PraisonAI scripts.
Displays counts by group, runnable status, and agent-centric usage.
Examples:
praisonai batch stats
praisonai batch stats --sub
"""
from collections import Counter
from praisonai.suite_runner import BatchSource
search_path = Path(path).resolve() if path else Path.cwd().resolve()
if not search_path.exists():
typer.echo(f"❌ Path not found: {search_path}")
raise typer.Exit(2)
source = BatchSource(
root=search_path,
recursive=recursive,
depth=depth,
exclude_tests=not include_tests,
)
items = source.discover()
if not items:
typer.echo(f"No PraisonAI scripts found in {search_path}")
return
# Calculate stats
group_counts = Counter(item.group for item in items)
runnable_by_group = Counter(item.group for item in items if item.runnable)
# Agent-centric stats
agent_count = sum(1 for item in items if item.uses_agent)
agents_count = sum(1 for item in items if item.uses_agents)
workflow_count = sum(1 for item in items if item.uses_workflow)
agent_by_group = Counter(item.group for item in items if item.uses_agent)
agents_by_group = Counter(item.group for item in items if item.uses_agents)
workflow_by_group = Counter(item.group for item in items if item.uses_workflow)
typer.echo("\n📊 Batch Execution Statistics")
typer.echo(f"Path: {search_path}")
typer.echo("=" * 80)
typer.echo(f"\n{'Group':<20} {'Total':>8} {'Runnable':>10} {'Agent':>8} {'Agents':>8} {'Workflow':>10}")
typer.echo("-" * 80)
for g in sorted(group_counts.keys()):
typer.echo(
f"{g:<20} {group_counts[g]:>8} {runnable_by_group[g]:>10} "
f"{agent_by_group[g]:>8} {agents_by_group[g]:>8} {workflow_by_group[g]:>10}"
)
typer.echo("-" * 80)
typer.echo(
f"{'TOTAL':<20} {len(items):>8} {sum(1 for i in items if i.runnable):>10} "
f"{agent_count:>8} {agents_count:>8} {workflow_count:>10}"
)
typer.echo()
@app.command("report")
def batch_report(
report_dir: Optional[Path] = typer.Option(
None,
"--dir", "-d",
help="Specific report directory (default: latest)",
),
format: str = typer.Option(
"summary",
"--format", "-f",
help="Output format: summary, failures, full",
),
):
"""
View the latest batch execution report.
Examples:
praisonai batch report
praisonai batch report --format failures
praisonai batch report --dir ~/Downloads/reports/batch/20240115_120000
"""
import json
# Find report directory
if report_dir:
target_dir = Path(report_dir)
else:
target_dir = _get_latest_report_dir()
if not target_dir or not target_dir.exists():
typer.echo("❌ No batch reports found.")
typer.echo(" Run 'praisonai batch' first to generate a report.")
raise typer.Exit(1)
# Load JSON report
json_path = target_dir / "report.json"
if not json_path.exists():
typer.echo(f"❌ Report not found: {json_path}")
raise typer.Exit(1)
report_data = json.loads(json_path.read_text())
typer.echo(f"\n📊 Batch Report: {target_dir.name}")
typer.echo("=" * 60)
# Summary
totals = report_data.get("totals", {})
typer.echo(f" ✅ Passed: {totals.get('passed', 0)}")
typer.echo(f" ❌ Failed: {totals.get('failed', 0)}")
typer.echo(f" ⏭️ Skipped: {totals.get('skipped', 0)}")
typer.echo(f" ⏱️ Timeout: {totals.get('timeout', 0)}")
typer.echo("=" * 60)
if format in ("failures", "full"):
results = report_data.get("results", [])
failures = [r for r in results if r.get("status") == "failed"]
if failures:
typer.echo("\n❌ FAILURES:")
typer.echo("-" * 60)
for r in failures:
typer.echo(f"\n {r.get('source_path', 'unknown')}")
if r.get("error_message"):
typer.echo(f" Error: {r['error_message'][:200]}")
if format == "full":
results = report_data.get("results", [])
typer.echo("\n📋 ALL RESULTS:")
typer.echo("-" * 60)
for r in results:
status = r.get("status", "unknown")
icon = {"passed": "✅", "failed": "❌", "skipped": "⏭️", "timeout": "⏱️"}.get(status, "?")
duration = r.get("duration_seconds", 0)
typer.echo(f" {icon} {r.get('source_path', 'unknown')} ({duration:.2f}s)")
typer.echo(f"\n📁 Full report: {target_dir}")
+409 -15
View File
@@ -1,20 +1,414 @@
"""Backward-compatibility shim for :mod:`praisonai.cli.commands.context`.
"""
Context command group for PraisonAI CLI.
The implementation moved to :mod:`praisonai_code.cli.commands.context` as part
of the praisonai-code extraction (issue #2516 / parent #2512).
This shim aliases the moved module into ``sys.modules`` under the old dotted
path so that:
* ``from praisonai.cli.commands.context import X`` keeps working, and
* ``unittest.mock.patch("praisonai.cli.commands.context.X")`` patches the very
same module object that the implementation executes against.
Provides context management commands:
- stats: Show context statistics
- compact: Manually trigger context compaction
- export: Export context to file
- show: Show current context
- clear: Clear current context
"""
import sys as _sys
import typer
from praisonai_code.cli.commands import context as _impl
app = typer.Typer(help="Context management")
# Make the old dotted path resolve to the exact same module object so that
# attribute patching / monkeypatching stays transparent across both paths.
_sys.modules[__name__] = _impl
@app.command("show")
def context_show(
verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
):
"""Show current context."""
from praisonai_code.cli.main import PraisonAI
import sys
argv = ['context', 'show']
if verbose:
argv.append('--verbose')
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@app.command("clear")
def context_clear():
"""Clear current context."""
from praisonai_code.cli.main import PraisonAI
import sys
argv = ['context', 'clear']
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@app.command("add")
def context_add(
content: str = typer.Argument(..., help="Content to add to context"),
):
"""Add content to context."""
from praisonai_code.cli.main import PraisonAI
import sys
argv = ['context', 'add', content]
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@app.command("stats")
def context_stats(
agent: str = typer.Option("", "--agent", "-a", help="Agent name to show stats for"),
json_output: bool = typer.Option(False, "--json", "-j", help="Output as JSON"),
):
"""
Show context management statistics.
Displays token usage, utilization, cache stats, and optimization history.
"""
try:
from praisonaiagents.context import get_metrics
import json
# Get global metrics
metrics = get_metrics()
stats = metrics.to_dict()
if json_output:
print(json.dumps(stats, indent=2))
else:
from rich.console import Console
from rich.table import Table
console = Console()
# Timing table
timing_table = Table(title="Context Operation Timing")
timing_table.add_column("Operation", style="cyan")
timing_table.add_column("Calls", justify="right")
timing_table.add_column("Avg (ms)", justify="right")
timing_table.add_column("Max (ms)", justify="right")
for op, data in stats["timing"].items():
timing_table.add_row(
op,
str(data["calls"]),
f"{data['avg_ms']:.2f}",
f"{data['max_ms']:.2f}",
)
console.print(timing_table)
# Token stats
console.print("\n[bold]Token Statistics:[/bold]")
console.print(f" Processed: {stats['tokens']['processed']:,}")
console.print(f" Saved: {stats['tokens']['saved']:,}")
console.print(f" Compactions: {stats['tokens']['compactions']}")
# Cache stats
console.print("\n[bold]Cache Statistics:[/bold]")
console.print(f" Hits: {stats['cache']['hits']:,}")
console.print(f" Misses: {stats['cache']['misses']:,}")
console.print(f" Hit Rate: {stats['cache']['hit_rate']:.1%}")
except ImportError as e:
typer.echo(f"Error: Context module not available: {e}", err=True)
raise typer.Exit(1)
@app.command("compact")
def context_compact(
agent: str = typer.Option("", "--agent", "-a", help="Agent name to compact"),
threshold: float = typer.Option(0.8, "--threshold", "-t", help="Compaction threshold (0.0-1.0)"),
dry_run: bool = typer.Option(False, "--dry-run", "-n", help="Show what would be compacted without doing it"),
):
"""
Manually trigger context compaction.
Compacts context when utilization exceeds threshold.
"""
try:
from praisonaiagents.context import get_global_store
store = get_global_store()
stats = store.get_stats()
if dry_run:
typer.echo("[Dry Run] Current store stats:")
typer.echo(f" Agents: {stats['agent_count']}")
typer.echo(f" Total messages: {stats['total_messages']}")
for agent_id, agent_stats in stats.get("agents", {}).items():
typer.echo(f" {agent_id}: {agent_stats['message_count']} messages, {agent_stats['effective_count']} effective")
else:
# Cleanup orphaned parents for all agents
for agent_id in stats.get("agents", {}).keys():
if agent and agent_id != agent:
continue
store.cleanup_agent(agent_id)
typer.echo(f"Cleaned up agent: {agent_id}")
typer.echo("Compaction complete.")
except ImportError as e:
typer.echo(f"Error: Context module not available: {e}", err=True)
raise typer.Exit(1)
@app.command("export")
def context_export(
output: str = typer.Argument("context_export.json", help="Output file path"),
agent: str = typer.Option("", "--agent", "-a", help="Agent name to export"),
format: str = typer.Option("json", "--format", "-f", help="Export format (json, txt)"),
):
"""
Export context to a file.
Exports the current context store state for debugging or backup.
"""
try:
from praisonaiagents.context import get_global_store
from pathlib import Path
store = get_global_store()
if format == "json":
data = store.snapshot()
output_path = Path(output)
output_path.write_bytes(data)
typer.echo(f"Exported context to: {output_path}")
else:
# Text format
stats = store.get_stats()
output_path = Path(output)
lines = ["# Context Export", ""]
lines.append(f"Agents: {stats['agent_count']}")
lines.append(f"Total Messages: {stats['total_messages']}")
lines.append("")
for agent_id, agent_stats in stats.get("agents", {}).items():
if agent and agent_id != agent:
continue
lines.append(f"## Agent: {agent_id}")
lines.append(f"Messages: {agent_stats['message_count']}")
lines.append(f"Effective: {agent_stats['effective_count']}")
lines.append("")
output_path.write_text("\n".join(lines))
typer.echo(f"Exported context to: {output_path}")
except ImportError as e:
typer.echo(f"Error: Context module not available: {e}", err=True)
raise typer.Exit(1)
# =============================================================================
# Artifact Commands (Dynamic Context Discovery)
# =============================================================================
artifacts_app = typer.Typer(help="Artifact management for dynamic context discovery")
app.add_typer(artifacts_app, name="artifacts")
@artifacts_app.command("list")
def artifacts_list(
run_id: str = typer.Option(None, "--run-id", "-r", help="Filter by run ID"),
agent_id: str = typer.Option(None, "--agent-id", "-a", help="Filter by agent ID"),
tool_name: str = typer.Option(None, "--tool", "-t", help="Filter by tool name"),
json_output: bool = typer.Option(False, "--json", "-j", help="Output as JSON"),
):
"""List artifacts."""
try:
from praisonai.context import FileSystemArtifactStore
import json as json_module
store = FileSystemArtifactStore()
artifacts = store.list_artifacts(
run_id=run_id,
agent_id=agent_id,
tool_name=tool_name,
)
if json_output:
data = [a.to_dict() for a in artifacts]
print(json_module.dumps(data, indent=2))
else:
if not artifacts:
typer.echo("No artifacts found.")
return
from rich.console import Console
from rich.table import Table
console = Console()
table = Table(title=f"Artifacts ({len(artifacts)} found)")
table.add_column("ID", style="cyan")
table.add_column("Tool", style="green")
table.add_column("Size", justify="right")
table.add_column("Summary")
table.add_column("Path", style="dim")
for ref in artifacts:
size_str = ref._format_size(ref.size_bytes)
table.add_row(
ref.artifact_id,
ref.tool_name or "-",
size_str,
ref.summary[:40] + "..." if len(ref.summary) > 40 else ref.summary,
ref.path,
)
console.print(table)
except ImportError as e:
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(1)
@artifacts_app.command("show")
def artifacts_show(
artifact_path: str = typer.Argument(..., help="Path to artifact"),
lines: int = typer.Option(None, "--lines", "-n", help="Number of lines to show"),
):
"""Show artifact content."""
try:
from praisonai.context import FileSystemArtifactStore
from praisonaiagents.context.artifacts import ArtifactRef
store = FileSystemArtifactStore()
ref = ArtifactRef(path=artifact_path, summary="", size_bytes=0)
if lines:
content = store.head(ref, lines=lines)
else:
content = store.load(ref)
if not isinstance(content, str):
import json
content = json.dumps(content, indent=2)
typer.echo(content)
except FileNotFoundError:
typer.echo(f"Artifact not found: {artifact_path}", err=True)
raise typer.Exit(1)
except Exception as e:
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(1)
@artifacts_app.command("tail")
def artifacts_tail(
artifact_path: str = typer.Argument(..., help="Path to artifact"),
lines: int = typer.Option(50, "--lines", "-n", help="Number of lines"),
):
"""Get last N lines of artifact."""
try:
from praisonai.context import FileSystemArtifactStore
from praisonaiagents.context.artifacts import ArtifactRef
store = FileSystemArtifactStore()
ref = ArtifactRef(path=artifact_path, summary="", size_bytes=0)
content = store.tail(ref, lines=lines)
typer.echo(content)
except FileNotFoundError:
typer.echo(f"Artifact not found: {artifact_path}", err=True)
raise typer.Exit(1)
except Exception as e:
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(1)
@artifacts_app.command("grep")
def artifacts_grep(
artifact_path: str = typer.Argument(..., help="Path to artifact"),
pattern: str = typer.Argument(..., help="Search pattern (regex)"),
context_lines: int = typer.Option(2, "--context", "-C", help="Context lines"),
max_matches: int = typer.Option(50, "--max", "-m", help="Max matches"),
):
"""Search for pattern in artifact."""
try:
from praisonai.context import FileSystemArtifactStore
from praisonaiagents.context.artifacts import ArtifactRef
store = FileSystemArtifactStore()
ref = ArtifactRef(path=artifact_path, summary="", size_bytes=0)
matches = store.grep(ref, pattern=pattern, context_lines=context_lines, max_matches=max_matches)
if not matches:
typer.echo(f"No matches found for: {pattern}")
return
from rich.console import Console
console = Console()
console.print(f"[bold]Found {len(matches)} matches:[/bold]")
for match in matches:
console.print(f"\n[cyan]--- Line {match.line_number} ---[/cyan]")
for ctx in match.context_before:
console.print(f" {ctx}", style="dim")
console.print(f"[green]> {match.line_content}[/green]")
for ctx in match.context_after:
console.print(f" {ctx}", style="dim")
except FileNotFoundError:
typer.echo(f"Artifact not found: {artifact_path}", err=True)
raise typer.Exit(1)
except ValueError as e:
typer.echo(f"Invalid pattern: {e}", err=True)
raise typer.Exit(1)
except Exception as e:
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(1)
@artifacts_app.command("export")
def artifacts_export(
artifact_path: str = typer.Argument(..., help="Path to artifact"),
output: str = typer.Option(None, "--output", "-o", help="Output file path"),
):
"""Export artifact to file."""
try:
from pathlib import Path
import shutil
src_path = Path(artifact_path)
if not src_path.exists():
typer.echo(f"Artifact not found: {artifact_path}", err=True)
raise typer.Exit(1)
if output:
dst_path = Path(output)
else:
dst_path = Path.cwd() / src_path.name
shutil.copy2(src_path, dst_path)
typer.echo(f"Exported to: {dst_path}")
except Exception as e:
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(1)
File diff suppressed because it is too large. Load diff
+947 -14
View File
@@ -1,20 +1,953 @@
"""Backward-compatibility shim for :mod:`praisonai.cli.commands.examples`.
"""
PraisonAI Examples CLI Command.
The implementation moved to :mod:`praisonai_code.cli.commands.examples` as part
of the praisonai-code extraction (issue #2516 / parent #2512).
Run and manage example files with reporting and diagnostics.
This shim aliases the moved module into ``sys.modules`` under the old dotted
path so that:
* ``from praisonai.cli.commands.examples import X`` keeps working, and
* ``unittest.mock.patch("praisonai.cli.commands.examples.X")`` patches the very
same module object that the implementation executes against.
Usage:
praisonai examples run # Run all examples
praisonai examples run --path ./examples # Custom path
praisonai examples list # List discovered examples
"""
import sys as _sys
from datetime import datetime
from pathlib import Path
from typing import Optional, List
import re
from praisonai_code.cli.commands import examples as _impl
import typer
# Make the old dotted path resolve to the exact same module object so that
# attribute patching / monkeypatching stays transparent across both paths.
_sys.modules[__name__] = _impl
app = typer.Typer(help="Run and manage example files")
def _get_default_examples_path() -> Path:
"""Get default examples path (repo examples/ or cwd)."""
candidates = [
Path.home() / "praisonai-package" / "examples",
Path("/Users/praison/praisonai-package/examples"),
Path.cwd() / "examples",
Path(__file__).parent.parent.parent.parent.parent / "examples", # repo root
]
for candidate in candidates:
if candidate.exists() and candidate.is_dir():
return candidate
return Path.cwd()
def _get_default_report_dir() -> Path:
"""Get default report directory with timestamp."""
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
return Path.home() / "Downloads" / "reports" / "examples" / timestamp
@app.command()
def run(
path: Optional[Path] = typer.Option(
None,
"--path", "-p",
help="Path to examples directory",
),
include: Optional[List[str]] = typer.Option(
None,
"--include", "-i",
help="Include patterns (glob), can be specified multiple times",
),
exclude: Optional[List[str]] = typer.Option(
None,
"--exclude", "-e",
help="Exclude patterns (glob), can be specified multiple times",
),
group: Optional[List[str]] = typer.Option(
None,
"--group", "-g",
help="Run only specific groups (top-level dirs), can be repeated",
),
timeout: int = typer.Option(
60,
"--timeout", "-t",
help="Per-example timeout in seconds",
),
max_items: Optional[int] = typer.Option(
None,
"--max-items",
help="Maximum examples to run",
),
fail_fast: bool = typer.Option(
False,
"--fail-fast", "-x",
help="Stop on first failure",
),
no_stream: bool = typer.Option(
False,
"--no-stream",
help="Don't stream output to terminal",
),
report_dir: Optional[Path] = typer.Option(
None,
"--report-dir", "-r",
help="Directory for reports (default: ./reports/examples/<timestamp>)",
),
no_json: bool = typer.Option(
False,
"--no-json",
help="Skip JSON report generation",
),
no_md: bool = typer.Option(
False,
"--no-md",
help="Skip Markdown report generation",
),
no_csv: bool = typer.Option(
False,
"--no-csv",
help="Skip CSV report generation",
),
require_env: Optional[List[str]] = typer.Option(
None,
"--require-env",
help="Required env vars (skip all if missing)",
),
python: Optional[str] = typer.Option(
None,
"--python",
help="Python executable to use (default: current interpreter)",
),
quiet: bool = typer.Option(
False,
"--quiet", "-q",
help="Minimal output",
),
):
"""
Run all examples in the specified directory.
Examples:
praisonai examples run
praisonai examples run --path ./examples --timeout 120
praisonai examples run --group python --group mcp --max-items 5
praisonai examples run --include "context/*" --exclude "*_wow.py"
praisonai examples run --fail-fast --no-stream
"""
# Lazy import to avoid loading at CLI startup
from praisonai.suite_runner import ExamplesSource, SuiteExecutor, RunResult
# Resolve paths
examples_path = path or _get_default_examples_path()
output_dir = report_dir or _get_default_report_dir()
if not examples_path.exists():
typer.echo(f"❌ Examples path not found: {examples_path}")
raise typer.Exit(2)
# Create source
source = ExamplesSource(
root=examples_path,
include_patterns=list(include) if include else None,
exclude_patterns=list(exclude) if exclude else None,
groups=list(group) if group else None,
)
# Discover items
items = source.discover()
# Create executor
executor = SuiteExecutor(
suite="examples",
source_path=examples_path,
timeout=timeout,
fail_fast=fail_fast,
stream_output=not no_stream,
max_items=max_items,
require_env=list(require_env) if require_env else None,
report_dir=output_dir,
generate_json=not no_json,
generate_md=not no_md,
generate_csv=not no_csv,
python_executable=python,
pythonpath_additions=source.get_pythonpath(),
groups=list(group) if group else None,
)
# Status icons
icons = {
"passed": "✅",
"failed": "❌",
"skipped": "⏭️",
"timeout": "⏱️",
"xfail": "⚠️",
"not_run": "📝",
}
def on_item_start(item, idx: int, total: int):
if not quiet:
typer.echo(f"\n[{idx}/{total}] Running: {item.display_name}")
def on_item_end(result: RunResult, idx: int, total: int):
icon = icons.get(result.status, "?")
duration = f"{result.duration_seconds:.2f}s" if result.duration_seconds else ""
if quiet:
typer.echo(f"{icon} {result.display_name}")
else:
msg = f" {icon} {result.status.upper()}"
if duration:
msg += f" ({duration})"
if result.skip_reason:
msg += f" - {result.skip_reason}"
if result.error_message and not no_stream:
msg += f"\n Error: {result.error_message[:100]}"
typer.echo(msg)
def on_output(line: str, stream: str):
if not quiet and not no_stream:
prefix = " │ " if stream == "stdout" else " │ [err] "
typer.echo(f"{prefix}{line.rstrip()}")
# Print header
if not quiet:
typer.echo("=" * 60)
typer.echo("PraisonAI Examples Runner")
typer.echo("=" * 60)
typer.echo(f"Path: {examples_path}")
typer.echo(f"Timeout: {timeout}s")
if group:
typer.echo(f"Groups: {', '.join(group)}")
typer.echo(f"Reports: {output_dir}")
typer.echo(f"Items: {len(items)}")
# Run examples
report = executor.run(
items=items,
on_item_start=on_item_start,
on_item_end=on_item_end,
on_output=on_output if not no_stream else None,
)
# Update report with CLI args
report.cli_args = [f"--path={examples_path}", f"--timeout={timeout}"]
if fail_fast:
report.cli_args.append("--fail-fast")
if group:
for g in group:
report.cli_args.append(f"--group={g}")
# Print summary
totals = report.totals
total_count = sum(totals.values())
if not quiet:
typer.echo("\n" + "=" * 60)
typer.echo("SUMMARY")
typer.echo("=" * 60)
typer.echo(f" ✅ Passed: {totals['passed']}")
typer.echo(f" ❌ Failed: {totals['failed']}")
typer.echo(f" ⏭️ Skipped: {totals['skipped']}")
typer.echo(f" ⏱️ Timeout: {totals['timeout']}")
typer.echo(f" ⚠️ XFail: {totals['xfail']}")
typer.echo(" ─────────────────")
typer.echo(f" Total: {total_count}")
typer.echo("=" * 60)
if output_dir.exists():
typer.echo(f"\n📁 Reports saved to: {output_dir}")
# Exit code
if totals['failed'] > 0 or totals['timeout'] > 0:
raise typer.Exit(1)
raise typer.Exit(0)
@app.command("list")
def list_examples(
path: Optional[Path] = typer.Option(
None,
"--path", "-p",
help="Path to examples directory",
),
include: Optional[List[str]] = typer.Option(
None,
"--include", "-i",
help="Include patterns (glob)",
),
exclude: Optional[List[str]] = typer.Option(
None,
"--exclude", "-e",
help="Exclude patterns (glob)",
),
group: Optional[List[str]] = typer.Option(
None,
"--group", "-g",
help="Filter by group (top-level dir)",
),
show_metadata: bool = typer.Option(
False,
"--metadata", "-m",
help="Show parsed metadata for each example",
),
show_groups: bool = typer.Option(
False,
"--groups",
help="Show available groups only",
),
):
"""
List discovered examples without running them.
Examples:
praisonai examples list
praisonai examples list --groups
praisonai examples list --group python --metadata
"""
from praisonai.suite_runner import ExamplesSource
examples_path = path or _get_default_examples_path()
if not examples_path.exists():
typer.echo(f"❌ Examples path not found: {examples_path}")
raise typer.Exit(2)
source = ExamplesSource(
root=examples_path,
include_patterns=list(include) if include else None,
exclude_patterns=list(exclude) if exclude else None,
groups=list(group) if group else None,
)
# Show groups only
if show_groups:
groups = source.get_groups()
typer.echo(f"Available groups in {examples_path}:\n")
for g in groups:
typer.echo(f" - {g}")
return
items = source.discover()
typer.echo(f"Found {len(items)} examples in {examples_path}\n")
for idx, item in enumerate(items, 1):
rel_path = item.source_path.relative_to(examples_path)
if show_metadata:
flags = []
if item.skip:
flags.append("skip")
if item.timeout:
flags.append(f"timeout={item.timeout}")
if item.require_env:
flags.append(f"env={','.join(item.require_env)}")
if item.xfail:
flags.append(f"xfail={item.xfail}")
if item.is_interactive:
flags.append("interactive")
flag_str = f" [{', '.join(flags)}]" if flags else ""
typer.echo(f" {idx:3}. [{item.group}] {rel_path}{flag_str}")
else:
typer.echo(f" {idx:3}. [{item.group}] {rel_path}")
def _tokenize_query(query: str) -> List[str]:
"""Tokenize query into lowercase search terms."""
return [t for t in re.split(r"[^a-zA-Z0-9_\-]+", query.lower()) if t]
@app.command("find")
def find_examples(
query: str = typer.Argument(..., help="Search query (e.g. rag, mcp, gemini)"),
path: Optional[Path] = typer.Option(
None,
"--path", "-p",
help="Path to examples directory",
),
group: Optional[List[str]] = typer.Option(
None,
"--group", "-g",
help="Filter by group (top-level dir), can be repeated",
),
limit: int = typer.Option(
10,
"--limit", "-n",
help="Maximum number of matches to show",
min=1,
),
):
"""
Find examples by keyword with lightweight relevance scoring.
Examples:
praisonai examples find rag
praisonai examples find mcp --group python
praisonai examples find gemini --limit 20
"""
from praisonai.suite_runner import ExamplesSource
examples_path = path or _get_default_examples_path()
if not examples_path.exists():
typer.echo(f"❌ Examples path not found: {examples_path}")
raise typer.Exit(2)
if not examples_path.is_dir():
typer.echo(f"❌ Examples path is not a directory: {examples_path}")
raise typer.Exit(2)
terms = _tokenize_query(query)
if not terms:
typer.echo("❌ Please provide a non-empty search query")
raise typer.Exit(2)
# Resolve path to ensure it's absolute for relative_to operations
examples_path = examples_path.resolve()
source = ExamplesSource(
root=examples_path,
groups=list(group) if group else None,
)
items = source.discover()
matches = []
for item in items:
rel_path = item.source_path.relative_to(examples_path).as_posix()
rel_lower = rel_path.lower()
filename_lower = item.source_path.name.lower()
group_lower = item.group.lower()
# Build searchable text with metadata signals
searchable = [
rel_lower,
filename_lower,
group_lower,
(item.runnable_decision or "").lower(),
" ".join((item.require_env or [])).lower(),
]
if item.uses_agent:
searchable.append("agent")
if item.uses_agents:
searchable.append("agents")
if item.uses_workflow:
searchable.append("workflow")
haystack = " ".join(searchable)
score = 0
matched_terms = 0
for term in terms:
term_score = 0
if term in filename_lower:
term_score = max(term_score, 8)
if f"/{term}" in rel_lower or rel_lower.startswith(term):
term_score = max(term_score, 6)
if term in group_lower:
term_score = max(term_score, 5)
if term in haystack:
term_score = max(term_score, 3)
if term_score > 0:
matched_terms += 1
score += term_score
# Prefer results matching all terms; allow partial for flexibility
if score > 0:
if matched_terms == len(terms):
score += 2
matches.append((score, rel_path, item))
matches.sort(key=lambda x: (-x[0], x[1]))
if not matches:
typer.echo(f"No examples found for query: {query}")
raise typer.Exit(0)
typer.echo(f"Found {len(matches)} matches for '{query}' in {examples_path}\n")
for idx, (score, rel_path, item) in enumerate(matches[:limit], 1):
tags = []
if item.uses_agent:
tags.append("agent")
if item.uses_agents:
tags.append("agents")
if item.uses_workflow:
tags.append("workflow")
if item.require_env:
tags.append("env")
tag_str = f" [{' '.join(tags)}]" if tags else ""
typer.echo(f" {idx:2}. [{item.group}] {rel_path} (score={score}){tag_str}")
@app.command()
def info(
example: Path = typer.Argument(
...,
help="Path to example file",
),
):
"""
Show detailed metadata for a specific example.
Examples:
praisonai examples info ./examples/context/01_basic.py
"""
from praisonai.suite_runner import ExamplesSource
if not example.exists():
typer.echo(f"❌ Example not found: {example}")
raise typer.Exit(2)
# Create a source just to parse the file
source = ExamplesSource(root=example.parent)
item = source._create_item(example)
typer.echo(f"Example: {example.name}")
typer.echo(f"Path: {example}")
typer.echo(f"Group: {item.group}")
typer.echo("")
typer.echo("Metadata:")
typer.echo(f" Runnable: {item.runnable}")
typer.echo(f" Decision: {item.runnable_decision}")
typer.echo(f" Skip: {item.skip}")
if item.skip_reason:
typer.echo(f" Skip Reason: {item.skip_reason}")
typer.echo(f" Timeout: {item.timeout or 'default'}")
typer.echo(f" Required Env: {', '.join(item.require_env) if item.require_env else 'none'}")
typer.echo(f" XFail: {item.xfail or 'no'}")
typer.echo(f" Interactive: {item.is_interactive}")
typer.echo(f" Code Hash: {item.code_hash}")
@app.command("stats")
def examples_stats(
path: Optional[Path] = typer.Option(
None,
"--path", "-p",
help="Path to examples directory",
),
group: Optional[List[str]] = typer.Option(
None,
"--group", "-g",
help="Filter by group (top-level dir)",
),
):
"""
Show statistics for examples.
Displays counts by group, runnable status, and agent-centric usage.
Examples:
praisonai examples stats
praisonai examples stats --group python --group mcp
"""
from collections import Counter
from praisonai.suite_runner import ExamplesSource
examples_path = path or _get_default_examples_path()
if not examples_path.exists():
typer.echo(f"❌ Examples path not found: {examples_path}")
raise typer.Exit(2)
source = ExamplesSource(
root=examples_path,
groups=list(group) if group else None,
)
items = source.discover()
# Calculate stats
group_counts = Counter(item.group for item in items)
runnable_by_group = Counter(item.group for item in items if item.runnable)
# Agent-centric stats
agent_count = sum(1 for item in items if item.uses_agent)
agents_count = sum(1 for item in items if item.uses_agents)
workflow_count = sum(1 for item in items if item.uses_workflow)
agent_by_group = Counter(item.group for item in items if item.uses_agent)
agents_by_group = Counter(item.group for item in items if item.uses_agents)
workflow_by_group = Counter(item.group for item in items if item.uses_workflow)
typer.echo("\n📊 Examples Statistics")
typer.echo(f"Path: {examples_path}")
typer.echo("=" * 80)
typer.echo(f"\n{'Group':<20} {'Total':>8} {'Runnable':>10} {'Agent':>8} {'Agents':>8} {'Workflow':>10}")
typer.echo("-" * 80)
for g in sorted(group_counts.keys()):
typer.echo(
f"{g:<20} {group_counts[g]:>8} {runnable_by_group[g]:>10} "
f"{agent_by_group[g]:>8} {agents_by_group[g]:>8} {workflow_by_group[g]:>10}"
)
typer.echo("-" * 80)
typer.echo(
f"{'TOTAL':<20} {len(items):>8} {sum(1 for i in items if i.runnable):>10} "
f"{agent_count:>8} {agents_count:>8} {workflow_count:>10}"
)
typer.echo()
@app.command("run-all")
def examples_run_all(
path: Optional[Path] = typer.Option(
None,
"--path", "-p",
help="Path to examples directory",
),
timeout: int = typer.Option(
60,
"--timeout", "-t",
help="Per-example timeout in seconds",
),
report_dir: Optional[Path] = typer.Option(
None,
"--report-dir", "-r",
help="Directory for reports (default: ~/Downloads/reports/examples/<timestamp>)",
),
parallel: bool = typer.Option(
True,
"--parallel/--sequential",
help="Run groups in parallel (default: parallel)",
),
max_workers: int = typer.Option(
4,
"--workers", "-w",
help="Max parallel workers (default: 4)",
),
quiet: bool = typer.Option(
False,
"--quiet", "-q",
help="Minimal output",
),
ci: bool = typer.Option(
False,
"--ci",
help="CI-friendly output (no colors, proper exit codes)",
),
):
"""
Run all examples group-by-group.
Executes all groups and generates a comprehensive report.
Uses parallel execution by default for faster results.
Examples:
praisonai examples run-all
praisonai examples run-all --sequential
praisonai examples run-all --workers 8 --timeout 120
"""
import json
from concurrent.futures import ThreadPoolExecutor, as_completed
from praisonai.suite_runner import ExamplesSource, SuiteExecutor, RunResult
examples_path = path or _get_default_examples_path()
output_dir = report_dir or _get_default_report_dir()
if not examples_path.exists():
typer.echo(f"❌ Examples path not found: {examples_path}")
raise typer.Exit(2)
# Get all groups
source = ExamplesSource(root=examples_path)
all_groups = sorted(source.get_groups())
if ci:
print(f"Found {len(all_groups)} groups to process")
print(f"Mode: {'parallel' if parallel else 'sequential'}")
print(f"Workers: {max_workers}")
print(f"Timeout: {timeout}s")
print("=" * 60)
else:
typer.echo(f"Found {len(all_groups)} groups to process")
typer.echo(f"Mode: {'parallel' if parallel else 'sequential'}")
typer.echo("=" * 60)
# Track overall results
overall_results = {
'passed': 0, 'failed': 0, 'skipped': 0,
'timeout': 0, 'not_run': 0, 'total': 0, 'xfail': 0,
}
group_summaries = []
def run_group(group_name: str) -> dict:
"""Run a single group and return summary."""
group_source = ExamplesSource(root=examples_path, groups=[group_name])
items = group_source.discover()
if not items:
return {
'group': group_name,
'total': 0, 'passed': 0, 'failed': 0,
'skipped': 0, 'timeout': 0, 'not_run': 0, 'xfail': 0,
}
group_report_dir = output_dir / group_name
group_report_dir.mkdir(parents=True, exist_ok=True)
executor = SuiteExecutor(
suite='examples',
source_path=examples_path,
report_dir=group_report_dir,
timeout=timeout,
stream_output=False,
generate_json=True,
generate_md=True,
generate_csv=True,
groups=[group_name],
)
report = executor.run(items=items)
totals = report.totals
return {
'group': group_name,
**totals,
}
if parallel and len(all_groups) > 1:
# Parallel execution using ThreadPoolExecutor (ProcessPoolExecutor cannot pickle nested functions)
with ThreadPoolExecutor(max_workers=min(max_workers, len(all_groups))) as pool:
futures = {pool.submit(run_group, g): g for g in all_groups}
for future in as_completed(futures):
group_name = futures[future]
try:
summary = future.result()
group_summaries.append(summary)
# Update overall
for key in overall_results:
if key in summary:
overall_results[key] += summary[key]
if not quiet:
if ci:
print(
f"PASS {group_name}: "
f"PASS:{summary['passed']} FAIL:{summary['failed']} "
f"SKIP:{summary['skipped']} TIMEOUT:{summary['timeout']} XFAIL:{summary.get('xfail', 0)}"
)
else:
typer.echo(
f"✅ {group_name}: "
f"✅{summary['passed']} ❌{summary['failed']} "
f"⏭️{summary['skipped']} ⏱️{summary['timeout']} ⚠️{summary.get('xfail', 0)}"
)
except Exception as e:
if ci:
print(f"FAIL {group_name}: Error - {e}")
else:
typer.echo(f"❌ {group_name}: Error - {e}")
else:
# Sequential execution with real-time output
for group_name in all_groups:
if not quiet:
typer.echo(f"\n{'='*60}")
typer.echo(f"GROUP: {group_name}")
typer.echo(f"{'='*60}")
group_source = ExamplesSource(root=examples_path, groups=[group_name])
items = group_source.discover()
if not items:
if not quiet:
typer.echo(" No items found, skipping")
group_summaries.append({
'group': group_name,
'total': 0, 'passed': 0, 'failed': 0,
'skipped': 0, 'timeout': 0, 'not_run': 0, 'xfail': 0,
})
continue
group_report_dir = output_dir / group_name
group_report_dir.mkdir(parents=True, exist_ok=True)
executor = SuiteExecutor(
suite='examples',
source_path=examples_path,
report_dir=group_report_dir,
timeout=timeout,
stream_output=False,
generate_json=True,
generate_md=True,
generate_csv=True,
groups=[group_name],
)
icons = {'passed': '✅', 'failed': '❌', 'skipped': '⏭️', 'timeout': '⏱️', 'xfail': '⚠️'}
def on_start(item, idx, total):
if not quiet:
typer.echo(f" [{idx}/{total}] {item.display_name} ", nl=False)
def on_end(result: RunResult, idx, total):
if not quiet:
icon = icons.get(result.status, '❓')
typer.echo(f"{icon}")
report = executor.run(items=items, on_item_start=on_start, on_item_end=on_end)
totals = report.totals
group_summaries.append({'group': group_name, **totals})
for key in overall_results:
if key in totals:
overall_results[key] += totals[key]
if not quiet:
typer.echo(
f"\n Summary: ✅{totals['passed']} ❌{totals['failed']} "
f"⏭️{totals['skipped']} ⏱️{totals['timeout']} ⚠️{totals.get('xfail', 0)}"
)
# Final report
if ci:
print("")
print("=" * 80)
print("FINAL REPORT - ALL GROUPS")
print("=" * 80)
print(f"{'Group':<20} {'Total':>8} {'Passed':>8} {'Failed':>8} {'Skip':>8} {'Timeout':>8} {'XFail':>8}")
print("-" * 80)
for gs in sorted(group_summaries, key=lambda x: x['group']):
print(
f"{gs['group']:<20} {gs['total']:>8} {gs['passed']:>8} {gs['failed']:>8} "
f"{gs['skipped']:>8} {gs['timeout']:>8} {gs.get('xfail', 0):>8}"
)
print("-" * 80)
print(
f"{'TOTAL':<20} {overall_results['total']:>8} {overall_results['passed']:>8} "
f"{overall_results['failed']:>8} {overall_results['skipped']:>8} "
f"{overall_results['timeout']:>8} {overall_results.get('xfail', 0):>8}"
)
else:
typer.echo("\n" + "=" * 80)
typer.echo("FINAL REPORT - ALL GROUPS")
typer.echo("=" * 80)
typer.echo(f"\n{'Group':<20} {'Total':>8} {'Passed':>8} {'Failed':>8} {'Skip':>8} {'Timeout':>8} {'XFail':>8}")
typer.echo("-" * 80)
for gs in sorted(group_summaries, key=lambda x: x['group']):
typer.echo(
f"{gs['group']:<20} {gs['total']:>8} {gs['passed']:>8} {gs['failed']:>8} "
f"{gs['skipped']:>8} {gs['timeout']:>8} {gs.get('xfail', 0):>8}"
)
typer.echo("-" * 80)
typer.echo(
f"{'TOTAL':<20} {overall_results['total']:>8} {overall_results['passed']:>8} "
f"{overall_results['failed']:>8} {overall_results['skipped']:>8} "
f"{overall_results['timeout']:>8} {overall_results.get('xfail', 0):>8}"
)
# Save final summary
output_dir.mkdir(parents=True, exist_ok=True)
summary_path = output_dir / 'final_summary.json'
with open(summary_path, 'w') as f:
json.dump({
'timestamp': datetime.now().isoformat(),
'overall': overall_results,
'groups': group_summaries,
}, f, indent=2)
if ci:
print(f"Final summary: {summary_path}")
print(f"Group reports: {output_dir}")
else:
typer.echo(f"\n📁 Final summary saved to: {summary_path}")
typer.echo(f"📁 Individual group reports in: {output_dir}")
# Exit code
if overall_results['failed'] > 0 or overall_results['timeout'] > 0:
raise typer.Exit(1)
raise typer.Exit(0)
@app.command("report")
def examples_report(
report_dir: Optional[Path] = typer.Argument(
None,
help="Report directory path (default: auto-detect latest)",
),
base_dir: Optional[Path] = typer.Option(
None,
"--base-dir", "-b",
help="Base reports directory (default: ~/Downloads/reports/examples)",
),
limit: int = typer.Option(
50,
"--limit", "-n",
help="Limit rows in tables (0 = no limit)",
),
errors: bool = typer.Option(
True,
"--errors/--no-errors",
help="Show error grouping section",
),
wide: bool = typer.Option(
False,
"--wide/--no-wide",
help="Wide output: full error messages and item lists",
),
show_paths: bool = typer.Option(
True,
"--show-paths/--no-show-paths",
help="Show affected item paths in error groups",
),
match: Optional[List[str]] = typer.Option(
None,
"--match", "-m",
help="Filter by status (repeatable: --match failed --match timeout)",
),
group: Optional[List[str]] = typer.Option(
None,
"--group", "-g",
help="Filter by group name (repeatable)",
),
contains: Optional[str] = typer.Option(
None,
"--contains", "-c",
help="Filter items whose error contains text (case-insensitive)",
),
open_dir: bool = typer.Option(
False,
"--open",
help="Show report artifacts listing",
),
output_format: str = typer.Option(
"table",
"--format", "-f",
help="Output format: table or json",
),
):
"""
View execution report with failures and error grouping.
Examples:
praisonai examples report # View latest report
praisonai examples report ./my-report # View specific report
praisonai examples report --wide --limit 0 # Full details
praisonai examples report --match failed # Only failures
praisonai examples report --format json # JSON output
"""
# Lazy import to avoid loading at CLI startup
from praisonai.suite_runner.report_viewer import view_report
output, exit_code = view_report(
report_dir=report_dir,
suite="examples",
base_dir=base_dir,
limit=limit,
show_errors=errors,
wide=wide,
show_paths=show_paths,
match_statuses=match,
match_groups=group,
contains=contains,
show_artifacts=open_dir,
output_format=output_format,
)
typer.echo(output)
raise typer.Exit(exit_code)
if __name__ == "__main__":
app()
+431 -15
View File
@@ -1,20 +1,436 @@
"""Backward-compatibility shim for :mod:`praisonai.cli.commands.flow`.
"""PraisonAI Flow — Visual workflow builder powered by Langflow.
The implementation moved to :mod:`praisonai_code.cli.commands.flow` as part
of the praisonai-code extraction (issue #2516 / parent #2512).
This shim aliases the moved module into ``sys.modules`` under the old dotted
path so that:
* ``from praisonai.cli.commands.flow import X`` keeps working, and
* ``unittest.mock.patch("praisonai.cli.commands.flow.X")`` patches the very
same module object that the implementation executes against.
Provides `praisonai flow` CLI command to launch Langflow with
PraisonAI custom components (Agent, Agents, Task) pre-loaded.
"""
import sys as _sys
import typer
from praisonai_code.cli.commands import flow as _impl
import click
# Make the old dotted path resolve to the exact same module object so that
# attribute patching / monkeypatching stays transparent across both paths.
_sys.modules[__name__] = _impl
app = typer.Typer(name="flow", help="Visual workflow builder (Langflow)")
@app.callback(invoke_without_command=True)
def flow_start(
ctx: typer.Context,
port: int = typer.Option(7860, "--port", "-p", help="Port to listen on"),
host: str = typer.Option("127.0.0.1", "--host", "-H", help="Host to bind to"),
env_file: str = typer.Option(None, "--env-file", help="Path to .env file"),
no_open: bool = typer.Option(False, "--no-open", help="Don't open browser on start"),
log_level: str = typer.Option(
"error",
"--log-level",
"-l",
help="Logging level (debug, info, warning, error, critical)",
),
backend_only: bool = typer.Option(
False, "--backend-only", help="Run backend API only (no frontend UI)"
),
components_path: str = typer.Option(
None,
"--components-path",
help="Additional custom components directory",
),
):
"""Start PraisonAI Flow — visual workflow builder.
Launches Langflow with PraisonAI Agent, AgentTeam, and Task components
pre-loaded in the sidebar. Build complex AI workflows visually.
Install: pip install praisonai[flow]
Examples:
praisonai flow
praisonai flow --port 8080
praisonai flow --host 0.0.0.0 --backend-only
"""
if ctx.invoked_subcommand is not None:
return
import os
import subprocess
import sys
from pathlib import Path
from rich.console import Console
console = Console()
# Check langflow is installed
try:
import langflow # noqa: F401
except ImportError:
console.print(
"[red]Langflow is not installed.[/red]\n"
"[yellow]Install with: pip install praisonai[flow][/yellow]"
)
raise typer.Abort()
# Resolve PraisonAI components directory (absolute path)
praison_components = str(
(Path(__file__).parent.parent.parent / "flow" / "components").resolve()
)
# Set LANGFLOW_COMPONENTS_PATH env var (comma-separated for pydantic list parsing)
env = os.environ.copy()
existing = env.get("LANGFLOW_COMPONENTS_PATH", "")
if components_path:
all_paths = f"{praison_components},{components_path}"
else:
all_paths = praison_components
env["LANGFLOW_COMPONENTS_PATH"] = (
f"{all_paths},{existing}" if existing else all_paths
)
# Suppress macOS CoreFoundation fork safety warnings (harmless noise from multiprocessing)
if sys.platform == "darwin":
env.setdefault("OBJC_DISABLE_INITIALIZE_FORK_SAFETY", "YES")
# Suppress noisy Openlayer/loguru debug messages unless user wants them
if log_level.lower() not in ("debug",):
env.setdefault("LOGURU_LEVEL", log_level.upper())
# Build langflow run command
cmd = [
sys.executable,
"-m",
"langflow",
"run",
"--port",
str(port),
"--host",
host,
"--log-level",
log_level,
]
if backend_only:
cmd.append("--backend-only")
if env_file:
cmd.extend(["--env-file", env_file])
if no_open:
cmd.extend(["--open-browser", "false"])
console.print()
console.print("[bold green]🚀 Starting PraisonAI Flow[/bold green]")
console.print(f"[dim]Langflow + PraisonAI components on {host}:{port}[/dim]")
console.print(
f"[dim]Components: {praison_components}[/dim]"
)
console.print()
try:
subprocess.run(cmd, env=env)
except KeyboardInterrupt:
console.print("\n[yellow]PraisonAI Flow stopped.[/yellow]")
except Exception as e:
console.print(f"[red]Error starting Langflow: {e}[/red]")
raise typer.Abort()
@app.command("import")
def flow_import(
yaml_path: str = typer.Argument(..., help="Path to YAML workflow file"),
langflow_url: str = typer.Option(
"http://localhost:7860",
"--url",
help="Langflow server URL"
),
dry_run: bool = typer.Option(
False,
"--dry-run",
help="Preview JSON without uploading"
),
open_browser: bool = typer.Option(
False,
"--open",
help="Open imported flow in browser"
),
output: str = typer.Option(
None,
"--output",
"-o",
help="Save JSON to file instead of uploading"
),
):
"""Import YAML workflow into Langflow.
Converts PraisonAI YAML workflow to Langflow JSON format and uploads
to a running Langflow instance for visual editing.
Examples:
praisonai flow import workflow.yaml
praisonai flow import workflow.yaml --dry-run
praisonai flow import workflow.yaml --output flow.json
praisonai flow import workflow.yaml --url http://localhost:8080
"""
from pathlib import Path
from rich.console import Console
from rich.json import JSON
console = Console()
# Validate input file
yaml_file = Path(yaml_path)
if not yaml_file.exists():
console.print(f"[red]Error: File not found: {yaml_path}[/red]")
raise typer.Abort()
try:
from praisonai.flow.converter import yaml_to_langflow_json
console.print(f"[cyan]Converting {yaml_path} to Langflow format...[/cyan]")
# Convert YAML to Langflow JSON
langflow_json = yaml_to_langflow_json(str(yaml_file))
# Dry run: just show the JSON
if dry_run:
console.print("\n[bold green]✅ Conversion Preview[/bold green]")
console.print(JSON.from_data(langflow_json, indent=2))
return
# Save to file mode
if output:
output_path = Path(output)
output_path.parent.mkdir(parents=True, exist_ok=True)
import json
with open(output_path, 'w') as f:
json.dump(langflow_json, f, indent=2)
console.print(f"[green]✅ Flow saved to {output_path}[/green]")
return
# Upload to Langflow
from praisonai.flow.client import create_client
console.print(f"[cyan]Connecting to Langflow at {langflow_url}...[/cyan]")
client = create_client(langflow_url)
# Check server health
health = client.health_check()
if health["status"] != "healthy":
console.print(f"[red]Error: Langflow server not accessible at {langflow_url}[/red]")
console.print("[yellow]Make sure Langflow is running: praisonai flow[/yellow]")
raise typer.Abort()
# Upload flow
console.print("[cyan]Uploading flow to Langflow...[/cyan]")
response = client.upload_flow(langflow_json)
flow_id = response.get("id", response.get("flow_id", ""))
flow_name = langflow_json.get("name", "Imported Flow")
console.print(f"[green]✅ Flow '{flow_name}' imported successfully![/green]")
console.print(f"[dim]Flow ID: {flow_id}[/dim]")
# Generate flow URL
if flow_id:
flow_url = f"{langflow_url}/flow/{flow_id}"
console.print(f"[blue]View: {flow_url}[/blue]")
# Open browser if requested
if open_browser:
import webbrowser
webbrowser.open(flow_url)
console.print("[dim]Opening in browser...[/dim]")
except ImportError as e:
console.print(f"[red]Error: {e}[/red]")
console.print("[yellow]Install with: pip install 'praisonai[flow]'[/yellow]")
raise typer.Abort()
except Exception as e:
console.print(f"[red]Error importing flow: {e}[/red]")
raise typer.Abort()
@app.command("export")
def flow_export(
flow_id: str = typer.Argument(..., help="Flow ID to export"),
output: str = typer.Option(
None,
"--output",
"-o",
help="Output YAML file path (default: flow_id.yaml)"
),
langflow_url: str = typer.Option(
"http://localhost:7860",
"--url",
help="Langflow server URL"
),
format: str = typer.Option(
"yaml",
"--format",
help="Output format (yaml, json)",
click_type=click.Choice(["yaml", "json"])
),
):
"""Export Langflow flow to YAML format.
Downloads a flow from Langflow and converts it back to PraisonAI
YAML format for use with the CLI.
Examples:
praisonai flow export abc-123-def
praisonai flow export abc-123-def --output my_workflow.yaml
praisonai flow export abc-123-def --format json
"""
from pathlib import Path
from rich.console import Console
console = Console()
try:
from praisonai.flow.client import create_client
console.print(f"[cyan]Connecting to Langflow at {langflow_url}...[/cyan]")
client = create_client(langflow_url)
# Check server health
health = client.health_check()
if health["status"] != "healthy":
console.print(f"[red]Error: Langflow server not accessible at {langflow_url}[/red]")
raise typer.Abort()
# Download flow
console.print(f"[cyan]Downloading flow {flow_id}...[/cyan]")
flow_data = client.get_flow(flow_id)
# Determine output file
if not output:
flow_name = flow_data.get("name", flow_id)
safe_name = "".join(c for c in flow_name if c.isalnum() or c in (' ', '-', '_')).rstrip()
output = f"{safe_name}.{format}"
output_path = Path(output)
output_path.parent.mkdir(parents=True, exist_ok=True)
if format == "json":
# Save as JSON
import json
with open(output_path, 'w') as f:
json.dump(flow_data, f, indent=2)
else:
# Convert to YAML
from praisonai.flow.converter import langflow_json_to_yaml
console.print("[cyan]Converting to YAML format...[/cyan]")
yaml_content = langflow_json_to_yaml(flow_data)
with open(output_path, 'w') as f:
f.write(yaml_content)
console.print(f"[green]✅ Flow exported to {output_path}[/green]")
except ImportError as e:
console.print(f"[red]Error: {e}[/red]")
console.print("[yellow]Install with: pip install 'praisonai[flow]'[/yellow]")
raise typer.Abort()
except Exception as e:
console.print(f"[red]Error exporting flow: {e}[/red]")
raise typer.Abort()
@app.command("list")
def flow_list(
langflow_url: str = typer.Option(
"http://localhost:7860",
"--url",
help="Langflow server URL"
),
search: str = typer.Option(
None,
"--search",
"-s",
help="Search flows by name or description"
),
):
"""List flows in Langflow server.
Shows all flows with their IDs, names, and descriptions for
easy identification and export.
Examples:
praisonai flow list
praisonai flow list --search research
praisonai flow list --url http://localhost:8080
"""
from rich.console import Console
from rich.table import Table
console = Console()
try:
from praisonai.flow.client import create_client
console.print(f"[cyan]Connecting to Langflow at {langflow_url}...[/cyan]")
client = create_client(langflow_url)
# Check server health
health = client.health_check()
if health["status"] != "healthy":
console.print(f"[red]Error: Langflow server not accessible at {langflow_url}[/red]")
raise typer.Abort()
# Get flows
if search:
console.print(f"[cyan]Searching for flows matching '{search}'...[/cyan]")
flows = client.search_flows(search)
else:
console.print("[cyan]Loading flows...[/cyan]")
flows = client.list_flows()
if not flows:
if search:
console.print(f"[yellow]No flows found matching '{search}'[/yellow]")
else:
console.print("[yellow]No flows found[/yellow]")
return
# Create table
table = Table(title=f"Langflow Flows ({len(flows)} found)")
table.add_column("ID", style="cyan", min_width=20)
table.add_column("Name", style="green")
table.add_column("Description", style="dim")
table.add_column("Created", style="blue")
for flow in flows:
flow_id = flow.get("id", "")[:20] + "..." if len(flow.get("id", "")) > 20 else flow.get("id", "")
name = flow.get("name", "Unnamed")
description = flow.get("description", "")[:50] + "..." if len(flow.get("description", "")) > 50 else flow.get("description", "")
created = flow.get("created_at", "")[:10] if flow.get("created_at") else ""
table.add_row(flow_id, name, description, created)
console.print(table)
except ImportError as e:
console.print(f"[red]Error: {e}[/red]")
console.print("[yellow]Install with: pip install 'praisonai[flow]'[/yellow]")
raise typer.Abort()
except Exception as e:
console.print(f"[red]Error listing flows: {e}[/red]")
raise typer.Abort()
@app.command("version")
def flow_version():
"""Show Langflow version information."""
from rich.console import Console
console = Console()
try:
from langflow.utils.version import get_version_info
info = get_version_info()
console.print(f"[bold]Langflow[/bold]: {info.get('version', 'unknown')}")
console.print(f"[bold]Package[/bold]: {info.get('package', 'unknown')}")
except ImportError:
console.print(
"[red]Langflow is not installed.[/red]\n"
"[yellow]Install with: pip install praisonai[flow][/yellow]"
)
except Exception as e:
console.print(f"[red]Error: {e}[/red]")
+434 -14
View File
@@ -1,20 +1,440 @@
"""Backward-compatibility shim for :mod:`praisonai.cli.commands.knowledge`.
"""
Knowledge command group for PraisonAI CLI.
The implementation moved to :mod:`praisonai_code.cli.commands.knowledge` as part
of the praisonai-code extraction (issue #2516 / parent #2512).
Provides knowledge base management commands:
- index: Add/index documents into a knowledge base (canonical indexing command)
- search: Search/retrieve from knowledge base (no generation)
- list: List available knowledge bases
This shim aliases the moved module into ``sys.modules`` under the old dotted
path so that:
* ``from praisonai.cli.commands.knowledge import X`` keeps working, and
* ``unittest.mock.patch("praisonai.cli.commands.knowledge.X")`` patches the very
same module object that the implementation executes against.
Knowledge is the canonical substrate for indexing and retrieval.
For answering questions with citations, use `praisonai rag query`.
"""
import sys as _sys
import typer
from typing import Optional, List
from pathlib import Path
from contextlib import contextmanager
import time
import json as json_module
from praisonai_code.cli.commands import knowledge as _impl
app = typer.Typer(help="Knowledge base management (indexing and retrieval)")
# Make the old dotted path resolve to the exact same module object so that
# attribute patching / monkeypatching stays transparent across both paths.
_sys.modules[__name__] = _impl
@contextmanager
def knowledge_profiler(enabled: bool, profile_out: Optional[Path], profile_top: int = 20):
"""
Context manager for knowledge command profiling.
Uses the existing praisonai.profiler infrastructure when available,
falls back to basic timing if not.
"""
if not enabled:
yield None
return
import sys
start_time = time.perf_counter()
start_modules = set(sys.modules.keys())
# Try to use the full profiler
profiler = None
try:
from praisonai.profiler import Profiler
Profiler.enable()
Profiler.clear()
profiler = Profiler
except ImportError:
pass
# Try tracemalloc for memory
try:
import tracemalloc
tracemalloc.start()
except Exception:
tracemalloc = None
profile_data = {
"command": "knowledge",
"start_time": time.time(),
"metrics": {},
"imports": [],
"top_functions": [],
}
try:
yield profile_data
finally:
end_time = time.perf_counter()
elapsed_ms = (end_time - start_time) * 1000
# Collect metrics
profile_data["metrics"]["wall_time_ms"] = elapsed_ms
profile_data["metrics"]["wall_time_s"] = elapsed_ms / 1000
# Memory usage
if tracemalloc:
try:
current, peak = tracemalloc.get_traced_memory()
tracemalloc.stop()
profile_data["metrics"]["peak_memory_mb"] = peak / (1024 * 1024)
profile_data["metrics"]["current_memory_mb"] = current / (1024 * 1024)
except Exception:
pass
# New imports during execution
end_modules = set(sys.modules.keys())
new_modules = end_modules - start_modules
profile_data["imports"] = sorted(list(new_modules))[:profile_top]
profile_data["metrics"]["modules_imported"] = len(new_modules)
# Get profiler data if available
if profiler:
try:
profiler.disable()
stats = profiler.get_statistics()
if stats:
profile_data["top_functions"] = stats.get("top_functions", [])[:profile_top]
profile_data["metrics"].update(stats.get("summary", {}))
except Exception:
pass
# Save to file if requested
if profile_out:
try:
profile_out.parent.mkdir(parents=True, exist_ok=True)
with open(profile_out, "w") as f:
json_module.dump(profile_data, f, indent=2, default=str)
except Exception as e:
print(f"Warning: Failed to save profile: {e}")
# Print summary
from rich.console import Console
console = Console(stderr=True)
console.print(f"\n[dim]Profile: {elapsed_ms:.2f}ms wall time[/dim]")
if "peak_memory_mb" in profile_data["metrics"]:
console.print(f"[dim]Profile: {profile_data['metrics']['peak_memory_mb']:.2f}MB peak memory[/dim]")
console.print(f"[dim]Profile: {len(new_modules)} modules imported[/dim]")
if profile_out:
console.print(f"[dim]Profile saved to: {profile_out}[/dim]")
@app.command("index")
def knowledge_index(
sources: List[str] = typer.Argument(..., help="Source files, directories, or URLs to index"),
collection: str = typer.Option("default", "--collection", "-c", help="Collection/knowledge base name"),
user_id: Optional[str] = typer.Option(None, "--user-id", "-u", help="User ID for scoping (required for mem0 backend)"),
agent_id: Optional[str] = typer.Option(None, "--agent-id", "-a", help="Agent ID for scoping"),
run_id: Optional[str] = typer.Option(None, "--run-id", "-r", help="Run ID for scoping"),
backend: str = typer.Option("mem0", "--backend", "-b", help="Knowledge backend: mem0, chroma, internal"),
config: Optional[Path] = typer.Option(None, "--config", "-f", help="Config file path"),
verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
profile: bool = typer.Option(False, "--profile", help="Enable performance profiling"),
profile_out: Optional[Path] = typer.Option(None, "--profile-out", help="Save profile to JSON file"),
profile_top: int = typer.Option(20, "--profile-top", help="Top N items in profile"),
):
"""
Index documents into a knowledge base.
This is the canonical command for adding documents to a knowledge base.
Use `praisonai rag query` to answer questions using the indexed knowledge.
Examples:
praisonai knowledge index ./docs --user-id myuser
praisonai knowledge index paper.pdf --collection research --agent-id research_agent
praisonai knowledge index ./data --backend chroma --profile
"""
from rich.console import Console
from rich.progress import Progress, SpinnerColumn, TextColumn
console = Console()
with knowledge_profiler(profile, profile_out, profile_top) as profile_data:
try:
# Lazy import to avoid startup cost
from praisonaiagents.knowledge import Knowledge
# Validate scope for mem0 backend
if backend == "mem0" and not any([user_id, agent_id, run_id]):
console.print("[yellow]Warning:[/yellow] mem0 backend requires at least one scope identifier.")
console.print("Use --user-id, --agent-id, or --run-id to scope your knowledge.")
console.print("Defaulting to user_id='default_user'")
user_id = "default_user"
# Build config based on backend
if backend == "chroma":
knowledge_config = {
"vector_store": {
"provider": "chroma",
"config": {
"collection_name": collection,
"path": f"./.praison/knowledge/{collection}",
}
}
}
else:
# mem0 or internal backend
knowledge_config = {
"vector_store": {
"provider": "qdrant",
"config": {
"collection_name": collection,
}
}
}
# Load config file if provided
if config and config.exists():
import yaml
with open(config) as f:
file_config = yaml.safe_load(f)
if "knowledge" in file_config:
knowledge_config.update(file_config["knowledge"])
# Initialize Knowledge
knowledge = Knowledge(config=knowledge_config, verbose=verbose)
# Index sources with scope identifiers
with Progress(
SpinnerColumn(),
TextColumn("[progress.description]{task.description}"),
console=console,
transient=True,
) as progress:
for source in sources:
task = progress.add_task(f"Indexing {source}...", total=None)
try:
result = knowledge.add(
source,
user_id=user_id,
agent_id=agent_id,
run_id=run_id,
)
count = len(result.get("results", [])) if isinstance(result, dict) else 0
console.print(f"[green]✓[/green] Indexed {source}: {count} chunks")
except Exception as e:
console.print(f"[red]✗[/red] Failed to index {source}: {e}")
progress.remove_task(task)
console.print(f"\n[bold green]Indexing complete![/bold green] Collection: {collection}")
if profile_data:
profile_data["command"] = "knowledge index"
profile_data["collection"] = collection
profile_data["sources"] = sources
except ImportError as e:
console.print(f"[red]Error:[/red] Missing dependency: {e}")
console.print("Install with: pip install 'praisonaiagents[knowledge]'")
raise typer.Exit(1)
except Exception as e:
console.print(f"[red]Error:[/red] {e}")
raise typer.Exit(1)
@app.command("search")
def knowledge_search(
query: str = typer.Argument(..., help="Search query"),
collection: str = typer.Option("default", "--collection", "-c", help="Collection to search"),
user_id: Optional[str] = typer.Option(None, "--user-id", "-u", help="User ID for scoping (required for mem0 backend)"),
agent_id: Optional[str] = typer.Option(None, "--agent-id", "-a", help="Agent ID for scoping"),
run_id: Optional[str] = typer.Option(None, "--run-id", "-r", help="Run ID for scoping"),
backend: str = typer.Option("mem0", "--backend", "-b", help="Knowledge backend: mem0, chroma, internal"),
top_k: int = typer.Option(5, "--top-k", "-k", help="Number of results to retrieve"),
hybrid: bool = typer.Option(False, "--hybrid", help="Use hybrid retrieval (dense + BM25)"),
config: Optional[Path] = typer.Option(None, "--config", "-f", help="Config file path"),
verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
profile: bool = typer.Option(False, "--profile", help="Enable performance profiling"),
profile_out: Optional[Path] = typer.Option(None, "--profile-out", help="Save profile to JSON file"),
profile_top: int = typer.Option(20, "--profile-top", help="Top N items in profile"),
):
"""
Search/retrieve from a knowledge base (no LLM generation).
Returns raw search results without generating an answer.
For answers with citations, use `praisonai rag query`.
Examples:
praisonai knowledge search "capital of France" --user-id myuser
praisonai knowledge search "main findings" --collection research --top-k 10
praisonai knowledge search "Python tutorial" --hybrid --backend chroma
"""
from rich.console import Console
from rich.table import Table
console = Console()
with knowledge_profiler(profile, profile_out, profile_top) as profile_data:
try:
from praisonaiagents.knowledge import Knowledge
# Validate scope for mem0 backend
if backend == "mem0" and not any([user_id, agent_id, run_id]):
console.print("[yellow]Warning:[/yellow] mem0 backend requires at least one scope identifier.")
console.print("Use --user-id, --agent-id, or --run-id to scope your search.")
console.print("Defaulting to user_id='default_user'")
user_id = "default_user"
# Build config based on backend
if backend == "chroma":
knowledge_config = {
"vector_store": {
"provider": "chroma",
"config": {
"collection_name": collection,
"path": f"./.praison/knowledge/{collection}",
}
}
}
else:
# mem0 or internal backend
knowledge_config = {
"vector_store": {
"provider": "qdrant",
"config": {
"collection_name": collection,
}
}
}
# Add hybrid retrieval config if enabled
if hybrid:
knowledge_config["retrieval"] = {
"strategy": "hybrid",
}
# Load config file if provided
if config and config.exists():
import yaml
with open(config) as f:
file_config = yaml.safe_load(f)
if "knowledge" in file_config:
knowledge_config.update(file_config["knowledge"])
# Initialize and search
knowledge = Knowledge(config=knowledge_config, verbose=verbose)
# Use hybrid search if enabled, pass scope identifiers
if hybrid:
if verbose:
console.print("[dim]Using hybrid retrieval (dense + BM25)...[/dim]")
results = knowledge.search(
query, limit=top_k, hybrid=True,
user_id=user_id, agent_id=agent_id, run_id=run_id,
)
else:
results = knowledge.search(
query, limit=top_k,
user_id=user_id, agent_id=agent_id, run_id=run_id,
)
if not results:
console.print("[yellow]No results found.[/yellow]")
return
# Display results
table = Table(title=f"Search Results for: {query}")
table.add_column("#", style="dim", width=3)
table.add_column("Score", width=8)
table.add_column("Source", width=20)
table.add_column("Content", width=60)
result_list = results.get('results', results) if isinstance(results, dict) else results
for i, result in enumerate(result_list[:top_k], 1):
if isinstance(result, dict):
score = f"{result.get('score', 0):.3f}" if result.get('score') else "-"
# CRITICAL: Handle metadata=None from mem0
metadata = result.get('metadata') or {}
source = metadata.get('filename', metadata.get('source', '-'))
content = result.get('memory', result.get('text', ''))[:100] + "..."
else:
score = "-"
source = "-"
content = str(result)[:100] + "..."
table.add_row(str(i), score, source, content)
console.print(table)
if profile_data:
profile_data["command"] = "knowledge search"
profile_data["collection"] = collection
profile_data["query"] = query
profile_data["num_results"] = len(result_list)
except ImportError as e:
console.print(f"[red]Error:[/red] Missing dependency: {e}")
console.print("Install with: pip install 'praisonaiagents[knowledge]'")
raise typer.Exit(1)
except Exception as e:
console.print(f"[red]Error:[/red] {e}")
if verbose:
import traceback
console.print(traceback.format_exc())
raise typer.Exit(1)
@app.command("add")
def knowledge_add(
sources: List[str] = typer.Argument(..., help="Source files, directories, or URLs to index"),
collection: str = typer.Option("default", "--collection", "-c", help="Collection/knowledge base name"),
config: Optional[Path] = typer.Option(None, "--config", "-f", help="Config file path"),
verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
profile: bool = typer.Option(False, "--profile", help="Enable performance profiling"),
profile_out: Optional[Path] = typer.Option(None, "--profile-out", help="Save profile to JSON file"),
profile_top: int = typer.Option(20, "--profile-top", help="Top N items in profile"),
):
"""
Add documents to a knowledge base (alias for 'index').
This is a backward-compatible alias for `praisonai knowledge index`.
Examples:
praisonai knowledge add ./docs
praisonai knowledge add paper.pdf --collection research
"""
# Delegate to index command
knowledge_index(
sources=sources,
collection=collection,
config=config,
verbose=verbose,
profile=profile,
profile_out=profile_out,
profile_top=profile_top,
)
@app.command("list")
def knowledge_list(
verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
):
"""List available knowledge bases/collections."""
from rich.console import Console
from rich.table import Table
import os
console = Console()
# Check for knowledge directories
knowledge_dirs = ["./.praison/knowledge", "./.praison/rag", ".praison"]
collections = set()
for base_dir in knowledge_dirs:
if os.path.exists(base_dir):
for item in os.listdir(base_dir):
item_path = os.path.join(base_dir, item)
if os.path.isdir(item_path):
collections.add((item, base_dir))
if not collections:
console.print("[yellow]No knowledge bases found.[/yellow]")
console.print("Create one with: praisonai knowledge index ./docs --collection myknowledge")
return
table = Table(title="Knowledge Bases")
table.add_column("Collection", style="cyan")
table.add_column("Path", style="dim")
for name, path in sorted(collections):
table.add_row(name, os.path.join(path, name))
console.print(table)
@@ -1,20 +1,120 @@
"""Backward-compatibility shim for :mod:`praisonai.cli.commands.langextract`.
"""
PraisonAI Langextract Commands.
The implementation moved to :mod:`praisonai_code.cli.commands.langextract` as part
of the praisonai-code extraction (issue #2516 / parent #2512).
This shim aliases the moved module into ``sys.modules`` under the old dotted
path so that:
* ``from praisonai.cli.commands.langextract import X`` keeps working, and
* ``unittest.mock.patch("praisonai.cli.commands.langextract.X")`` patches the very
same module object that the implementation executes against.
CLI commands for rendering PraisonAI traces with langextract:
- `praisonai langextract view` - render existing JSONL to HTML
- `praisonai langextract render` - run workflow with langextract observability
"""
import sys as _sys
import typer
import webbrowser
from pathlib import Path
from typing import Optional
from praisonai_code.cli.commands import langextract as _impl
app = typer.Typer(name="langextract", help="Render PraisonAI traces with langextract.")
# Make the old dotted path resolve to the exact same module object so that
# attribute patching / monkeypatching stays transparent across both paths.
_sys.modules[__name__] = _impl
@app.command(name="view")
def view(
jsonl_path: Path = typer.Argument(..., help="Path to annotated-documents JSONL"),
output_html: Path = typer.Option("trace.html", "--output", "-o", help="Output HTML file path"),
no_open: bool = typer.Option(False, "--no-open", help="Don't open HTML in browser"),
):
"""Render an existing annotated-documents JSONL to an interactive HTML."""
try:
import langextract as lx # type: ignore
except ImportError:
typer.echo("Error: langextract is not installed. Install with: pip install 'praisonai[langextract]'", err=True)
raise typer.Exit(1)
if not jsonl_path.exists():
typer.echo(f"Error: JSONL file not found: {jsonl_path}", err=True)
raise typer.Exit(1)
try:
html = lx.visualize(str(jsonl_path))
html_text = html.data if hasattr(html, "data") else html
output_html.write_text(html_text, encoding="utf-8")
typer.echo(f"✅ Wrote {output_html}")
if not no_open:
webbrowser.open(f"file://{output_html.resolve()}")
except Exception as e:
typer.echo(f"Error: Failed to render HTML: {e}", err=True)
raise typer.Exit(1)
@app.command(name="render")
def render(
yaml_path: Path = typer.Argument(..., help="PraisonAI YAML workflow"),
output_html: Path = typer.Option("workflow.html", "--output", "-o", help="Output HTML file path"),
no_open: bool = typer.Option(False, "--no-open", help="Don't open HTML in browser"),
api_url: Optional[str] = typer.Option(None, "--api-url", help="API URL (if using remote API)"),
):
"""Run a workflow end-to-end with LangextractSink attached, then open the HTML."""
try:
import langextract # noqa: F401 — probe optional dep early for clear error
from praisonai.observability import LangextractSink, LangextractSinkConfig
from praisonaiagents.trace.protocol import TraceEmitter, set_default_emitter
from praisonai import PraisonAI
except ImportError as e:
typer.echo(
f"Error: Missing dependencies: {e}. "
"Install langextract with: pip install 'praisonai[langextract]'",
err=True,
)
raise typer.Exit(1) from e
if not yaml_path.exists():
typer.echo(f"Error: YAML file not found: {yaml_path}", err=True)
raise typer.Exit(1)
# Set up langextract observability
config = LangextractSinkConfig(
output_path=str(output_html),
auto_open=not no_open,
)
sink = LangextractSink(config=config)
# Set up trace emitter for the duration of the run
emitter = TraceEmitter(sink=sink, enabled=True)
set_default_emitter(emitter)
# Also bridge the context emitter so real agent runtime events
# (agent_start/end, tool_call_*, llm_response) are captured.
from praisonai.observability.langextract import LangextractSink
def warn_handler(msg: str):
# Warn user about bridge failure since this command specifically generates traces
typer.echo(f"Warning: {msg}", err=True)
LangextractSink.bridge_context_events(
sink=sink,
session_id="praisonai-langextract-render",
warn_callback=warn_handler
)
try:
# Run the workflow
praison = PraisonAI(agent_file=str(yaml_path))
if api_url:
praison.api_url = api_url.rstrip("/")
result = praison.main()
typer.echo(result)
except Exception as e:
typer.echo(f"Error: Workflow failed: {e}", err=True)
raise typer.Exit(1) from e
finally:
# Ensure sink is closed even if workflow fails
sink.close()
if output_html.exists():
typer.echo(f"✅ Trace rendered: {output_html}")
else:
typer.echo(
f"Error: Trace was not rendered to {output_html} (see logs for details)",
err=True,
)
raise typer.Exit(1)
+979 -15
View File
@@ -1,20 +1,984 @@
"""Backward-compatibility shim for :mod:`praisonai.cli.commands.langfuse`.
"""PraisonAI Langfuse — Observability platform for LLM apps.
The implementation moved to :mod:`praisonai_code.cli.commands.langfuse` as part
of the praisonai-code extraction (issue #2516 / parent #2512).
This shim aliases the moved module into ``sys.modules`` under the old dotted
path so that:
* ``from praisonai.cli.commands.langfuse import X`` keeps working, and
* ``unittest.mock.patch("praisonai.cli.commands.langfuse.X")`` patches the very
same module object that the implementation executes against.
Provides `praisonai langfuse` CLI commands to manage local (Docker) and remote
Langfuse instances for observability and evaluation of agent workflows.
"""
import sys as _sys
import os
import json
import subprocess
import sys
import time
from datetime import datetime
from pathlib import Path
from typing import Optional
from praisonai_code.cli.commands import langfuse as _impl
import typer
from rich.console import Console
from rich.table import Table
from rich.panel import Panel
from rich import box
# Make the old dotted path resolve to the exact same module object so that
# attribute patching / monkeypatching stays transparent across both paths.
_sys.modules[__name__] = _impl
app = typer.Typer(name="langfuse", help="🔍 Langfuse observability platform")
def _format_publishers(publishers):
"""Format Publishers field for display in status table."""
if isinstance(publishers, list) and publishers:
# Publishers is array of port mapping objects
ports = []
for pub in publishers:
if isinstance(pub, dict):
url = pub.get("URL", "")
target_port = pub.get("TargetPort", "")
published_port = pub.get("PublishedPort", "")
protocol = pub.get("Protocol", "tcp")
if published_port and target_port:
ports.append(f"{url}:{published_port}->{target_port}/{protocol}")
return ", ".join(ports) if ports else ""
elif isinstance(publishers, str):
return publishers # Fallback for string format
else:
return ""
@app.callback(invoke_without_command=True)
def langfuse_start(
ctx: typer.Context,
port: int = typer.Option(3000, "--port", "-p", help="Port to listen on"),
host: str = typer.Option("127.0.0.1", "--host", "-H", help="Host to bind to"),
detach: bool = typer.Option(True, "--detach/--no-detach", help="Run in background"),
data_dir: str = typer.Option(
None, "--data-dir", help="Data directory (default: ~/.praisonai/langfuse)"
),
email: str = typer.Option(
None, "--email", "-e", help="Initial admin email (default: [email protected])"
),
password: str = typer.Option(
None, "--password", "-pw", help="Initial admin password (default: admin12345)"
),
):
"""Start local Langfuse server (Docker).
Clones Langfuse repo and starts the full Docker Compose stack
with web UI, worker, PostgreSQL, ClickHouse, Redis, and MinIO.
Examples:
praisonai langfuse
praisonai langfuse --port 8080
praisonai langfuse --no-detach
praisonai langfuse --email admin@example.com --password mypassword123
"""
if ctx.invoked_subcommand is not None:
return
console = Console()
# Check Docker availability
try:
subprocess.run(["docker", "--version"], capture_output=True, check=True)
subprocess.run(["docker", "compose", "version"], capture_output=True, check=True)
except (subprocess.CalledProcessError, FileNotFoundError):
console.print("[red]Docker or Docker Compose is not installed or not running.[/red]")
console.print("[yellow]Please install Docker and ensure the daemon is running.[/yellow]")
raise typer.Abort()
# Set up data directory
if data_dir is None:
data_dir = os.path.expanduser("~/.praisonai/langfuse")
data_path = Path(data_dir)
repo_path = data_path / "langfuse"
console.print(f"[blue]📁 Data directory: {data_path}[/blue]")
# Clone Langfuse repo if not present
if not repo_path.exists():
console.print("[yellow]📥 Cloning Langfuse repository...[/yellow]")
data_path.mkdir(parents=True, exist_ok=True)
try:
subprocess.run([
"git", "clone",
"https://github.com/langfuse/langfuse.git",
str(repo_path)
], check=True, cwd=data_path)
console.print("[green]✅ Repository cloned successfully[/green]")
except subprocess.CalledProcessError:
console.print("[red]❌ Failed to clone Langfuse repository[/red]")
raise typer.Abort()
else:
console.print("[green]✅ Langfuse repository already exists[/green]")
# Check if containers are already running
try:
result = subprocess.run([
"docker", "compose", "ps", "--format", "table"
], cwd=repo_path, capture_output=True, text=True)
if "langfuse-web" in result.stdout and "Up" in result.stdout:
console.print("[yellow]⚠️ Langfuse is already running[/yellow]")
console.print(f"[blue]🌐 Access UI: http://{host}:{port}[/blue]")
return
except subprocess.CalledProcessError:
pass
# Update docker-compose.yml port if different from default
if port != 3000:
compose_file = repo_path / "docker-compose.yml"
if compose_file.exists():
content = compose_file.read_text()
content = content.replace("3000:3000", f"{port}:3000")
compose_file.write_text(content)
# Set up initial credentials via .env file
env_file = repo_path / ".env"
env_vars = {}
# Read existing .env if present
if env_file.exists():
for line in env_file.read_text().strip().split('\n'):
if '=' in line and not line.startswith('#'):
key, value = line.split('=', 1)
env_vars[key.strip()] = value.strip()
# Set default or custom credentials
default_email = email or "[email protected]"
default_password = password or "admin12345"
default_name = "Admin User"
# Update with Langfuse init user environment variables
env_vars["LANGFUSE_INIT_USER_EMAIL"] = default_email
env_vars["LANGFUSE_INIT_USER_PASSWORD"] = default_password
env_vars["LANGFUSE_INIT_USER_NAME"] = default_name
env_vars["LANGFUSE_INIT_ORG_NAME"] = "Default Org"
env_vars["LANGFUSE_INIT_PROJECT_NAME"] = "Default Project"
# Write .env file
env_content = "# Langfuse Initial User Configuration\n"
env_content += f"LANGFUSE_INIT_USER_EMAIL={default_email}\n"
env_content += f"LANGFUSE_INIT_USER_PASSWORD={default_password}\n"
env_content += f"LANGFUSE_INIT_USER_NAME={default_name}\n"
env_content += f"LANGFUSE_INIT_ORG_NAME=Default Org\n"
env_content += f"LANGFUSE_INIT_PROJECT_NAME=Default Project\n"
env_content += "LANGFUSE_INIT_PROJECT_ID=default-project\n"
env_file.write_text(env_content)
console.print(f"[dim]📄 Initial user: {default_email}[/dim]")
# Start Docker Compose
console.print(f"[blue]🚀 Starting Langfuse on {host}:{port}[/blue]")
console.print("[dim]This may take a few minutes on first run (downloading images)...[/dim]")
cmd = ["docker", "compose", "up"]
if detach:
cmd.append("-d")
try:
if detach:
subprocess.run(cmd, cwd=repo_path, check=True)
console.print("[green]✅ Langfuse started successfully[/green]")
# Wait for health check
console.print("[yellow]⏳ Waiting for services to be ready...[/yellow]")
try:
import requests as requests_lib
except ImportError:
requests_lib = None
max_wait = 180 # 3 minutes
for i in range(max_wait):
try:
if requests_lib is None:
break
response = requests_lib.get(f"http://{host}:{port}", timeout=5)
if response.status_code < 500:
break
except Exception:
pass
time.sleep(1)
if i % 10 == 0:
console.print(f"[dim]Still waiting... ({i}s/{max_wait}s)[/dim]")
else:
console.print("[yellow]⚠️ Service may still be starting up[/yellow]")
console.print()
console.print("[bold green]🎉 Langfuse is ready![/bold green]")
console.print(f"[blue]🌐 Web UI: http://{host}:{port}[/blue]")
console.print(f"[dim]Login: {default_email} / {default_password[:3]}***[/dim]")
console.print()
console.print("[bold]Next steps:[/bold]")
console.print("1. Open the web UI and login")
console.print("2. Get API keys from Settings > API Keys")
console.print("3. Configure PraisonAI:")
console.print(f" [green]praisonai langfuse config --public-key pk-... --secret-key sk-...[/green]")
console.print("4. Test: [green]praisonai langfuse test[/green]")
else:
subprocess.run(cmd, cwd=repo_path)
except subprocess.CalledProcessError as e:
error_output = str(e)
if hasattr(e, 'stderr') and e.stderr:
error_output += e.stderr
if hasattr(e, 'stdout') and e.stdout:
error_output += e.stdout
# Check for port conflicts
from praisonai.cli.commands.port import _is_port_conflict_error, _extract_port_from_error, _show_port_conflict_help
if _is_port_conflict_error(error_output):
port = _extract_port_from_error(error_output)
if port:
_show_port_conflict_help(port, console)
else:
console.print(f"[red]❌ Failed to start Langfuse: Port conflict detected[/red]")
console.print("[yellow]Run [cyan]praisonai port list[/cyan] to see ports in use[/yellow]")
else:
console.print(f"[red]❌ Failed to start Langfuse: {e}[/red]")
console.print("[yellow]Check Docker logs with: docker compose logs[/yellow]")
raise typer.Abort()
except KeyboardInterrupt:
console.print("\n[yellow]⏹️ Stopping Langfuse...[/yellow]")
@app.command("stop")
def langfuse_stop(
data_dir: str = typer.Option(
None, "--data-dir", help="Data directory (default: ~/.praisonai/langfuse)"
),
):
"""Stop local Langfuse server."""
console = Console()
# Set up data directory
if data_dir is None:
data_dir = os.path.expanduser("~/.praisonai/langfuse")
repo_path = Path(data_dir) / "langfuse"
if not repo_path.exists():
console.print("[yellow]⚠️ Langfuse repository not found[/yellow]")
return
console.print("[yellow]⏹️ Stopping Langfuse...[/yellow]")
try:
subprocess.run(["docker", "compose", "down"], cwd=repo_path, check=True)
console.print("[green]✅ Langfuse stopped successfully[/green]")
except subprocess.CalledProcessError as e:
console.print(f"[red]❌ Failed to stop Langfuse: {e}[/red]")
raise typer.Abort()
@app.command("init")
def langfuse_init(
email: str = typer.Option(
"[email protected]", "--email", "-e", help="Admin email address"
),
password: str = typer.Option(
None, "--password", "-p", help="Admin password (will prompt if not provided)"
),
port: int = typer.Option(3000, "--port", "-p", help="Port to listen on"),
data_dir: str = typer.Option(
None, "--data-dir", help="Data directory (default: ~/.praisonai/langfuse)"
),
):
"""Initialize Langfuse with custom admin credentials.
First-time setup with configurable admin user. Sets up the Docker
environment and creates initial user account.
Examples:
praisonai langfuse init
praisonai langfuse init --email admin@example.com --password mypass123
praisonai langfuse init --email user@company.com --port 8080
"""
console = Console()
# Prompt for password if not provided
if password is None:
password = typer.prompt("Enter admin password", hide_input=True)
password_confirm = typer.prompt("Confirm password", hide_input=True)
if password != password_confirm:
console.print("[red]❌ Passwords do not match[/red]")
raise typer.Abort()
# Validate password length (Langfuse requires 8+ characters)
if len(password) < 8:
console.print("[red]❌ Password must be at least 8 characters long[/red]")
raise typer.Abort()
console.print("[bold]🚀 Initializing Langfuse...[/bold]")
console.print(f"[dim]Email: {email}[/dim]")
console.print(f"[dim]Port: {port}[/dim]")
console.print()
# Reuse the start logic with explicit credentials
ctx = typer.Context(langfuse_start)
langfuse_start(
ctx=ctx,
port=port,
host="127.0.0.1",
detach=True,
data_dir=data_dir,
email=email,
password=password
)
@app.command("status")
def langfuse_status(
data_dir: str = typer.Option(
None, "--data-dir", help="Data directory (default: ~/.praisonai/langfuse)"
),
):
"""Check Langfuse server status."""
console = Console()
# Set up data directory
if data_dir is None:
data_dir = os.path.expanduser("~/.praisonai/langfuse")
repo_path = Path(data_dir) / "langfuse"
if not repo_path.exists():
console.print("[yellow]⚠️ Langfuse repository not found[/yellow]")
console.print("Run 'praisonai langfuse' to set up Langfuse")
return
try:
import requests as requests_lib
except ImportError:
requests_lib = None
# Check Docker containers
try:
result = subprocess.run([
"docker", "compose", "ps", "--format", "json"
], cwd=repo_path, capture_output=True, text=True, check=True)
if not result.stdout.strip():
console.print("[yellow]📊 No Langfuse containers running[/yellow]")
return
# Parse JSON output
containers = []
for line in result.stdout.strip().split('\n'):
if line:
containers.append(json.loads(line))
# Create status table
table = Table(title="Langfuse Services")
table.add_column("Service", style="cyan")
table.add_column("Status", style="green")
table.add_column("Ports", style="blue")
table.add_column("Health", style="yellow")
for container in containers:
status_color = "green" if container.get("State") == "running" else "red"
health = container.get("Health", "unknown")
table.add_row(
container.get("Service", "unknown"),
f"[{status_color}]{container.get('State', 'unknown')}[/{status_color}]",
_format_publishers(container.get("Publishers", [])),
health
)
console.print(table)
# Test API availability
web_running = any(
c.get("Service") == "langfuse-web" and c.get("State") == "running"
for c in containers
)
if web_running:
# Extract port from web service
web_container = next(
c for c in containers
if c.get("Service") == "langfuse-web"
)
# Extract published port from Publishers array
publishers = web_container.get("Publishers", [])
port = "3000"
if isinstance(publishers, list) and publishers:
# Publishers is array of {"URL": "0.0.0.0", "TargetPort": 80, "PublishedPort": 8080, "Protocol": "tcp"}
for pub in publishers:
if isinstance(pub, dict) and pub.get("TargetPort") == 3000:
port = str(pub.get("PublishedPort", 3000))
break
elif isinstance(publishers, str) and ":" in publishers:
# Fallback for older format or edge cases
import re
match = re.search(r":(\d+)->", publishers)
port = match.group(1) if match else "3000"
try:
if requests_lib is not None:
response = requests_lib.get(f"http://127.0.0.1:{port}", timeout=5)
if response.status_code < 500:
console.print(f"[green]🌐 Web UI available: http://127.0.0.1:{port}[/green]")
else:
console.print(f"[yellow]🌐 Web UI starting: http://127.0.0.1:{port}[/yellow]")
else:
console.print(f"[blue]🌐 Web UI: http://127.0.0.1:{port}[/blue]")
except Exception:
console.print(f"[red]🌐 Web UI not responding: http://127.0.0.1:{port}[/red]")
except subprocess.CalledProcessError:
console.print("[red]❌ Failed to check container status[/red]")
except Exception as e:
console.print(f"[red]❌ Error: {e}[/red]")
@app.command("config")
def langfuse_config(
public_key: str = typer.Option(None, "--public-key", "-pk", help="Langfuse public key"),
secret_key: str = typer.Option(None, "--secret-key", "-sk", help="Langfuse secret key"),
host: str = typer.Option(None, "--host", help="Langfuse host URL"),
show: bool = typer.Option(False, "--show", help="Show current configuration"),
):
"""Configure Langfuse connection credentials."""
console = Console()
config_dir = Path.home() / ".praisonai"
config_file = config_dir / "langfuse.env"
if show:
# Show current configuration
if config_file.exists():
console.print(f"[blue]📄 Configuration file: {config_file}[/blue]")
content = config_file.read_text()
table = Table(title="Langfuse Configuration")
table.add_column("Setting", style="cyan")
table.add_column("Value", style="green")
for line in content.strip().split('\n'):
if '=' in line and not line.startswith('#'):
key, value = line.split('=', 1)
# Mask secret key for security
if "SECRET" in key:
value = value[:8] + "..." if len(value) > 8 else "***"
table.add_row(key, value)
console.print(table)
# Also show env vars
env_vars = []
for var in ["LANGFUSE_PUBLIC_KEY", "LANGFUSE_SECRET_KEY", "LANGFUSE_HOST", "LANGFUSE_BASE_URL"]:
value = os.environ.get(var)
if value:
if "SECRET" in var:
value = value[:8] + "..." if len(value) > 8 else "***"
env_vars.append((var, value))
if env_vars:
console.print("\n[blue]🌍 Environment Variables:[/blue]")
for var, value in env_vars:
console.print(f" {var}={value}")
else:
console.print("[yellow]⚠️ No configuration file found[/yellow]")
return
# Set configuration
if not any([public_key, secret_key, host]):
console.print("[yellow]ℹ️ No configuration options provided[/yellow]")
console.print()
console.print("[bold]Quick Setup Guide:[/bold]")
console.print()
console.print("1. [bold cyan]Get your API keys from Langfuse Cloud:[/bold cyan]")
console.print(" [blue]https://cloud.langfuse.com[/blue]")
console.print(" → Sign up → Create project → Project Settings → API Keys")
console.print()
console.print("2. [bold cyan]Configure PraisonAI:[/bold cyan]")
console.print(" [dim]praisonai langfuse config[/dim]")
console.print(" [dim]--public-key pk-...[/dim]")
console.print(" [dim]--secret-key sk-...[/dim]")
console.print()
console.print("3. [bold cyan]Verify your setup:[/bold cyan]")
console.print(" [green]praisonai langfuse test[/green]")
console.print()
console.print("[dim]Use --show to view current configuration[/dim]")
raise typer.Abort()
# Read existing config
config_data = {}
if config_file.exists():
content = config_file.read_text()
for line in content.strip().split('\n'):
if '=' in line and not line.startswith('#'):
key, value = line.split('=', 1)
config_data[key] = value
# Update with new values
if public_key:
config_data["LANGFUSE_PUBLIC_KEY"] = public_key
if secret_key:
config_data["LANGFUSE_SECRET_KEY"] = secret_key
if host:
config_data["LANGFUSE_HOST"] = host
# Write config
config_dir.mkdir(exist_ok=True)
content_lines = [
"# Langfuse Configuration",
"# Generated by PraisonAI CLI",
""
]
for key, value in config_data.items():
content_lines.append(f"{key}={value}")
config_file.write_text('\n'.join(content_lines) + '\n')
console.print(f"[green]✅ Configuration saved to {config_file}[/green]")
console.print("\n[blue]💡 To use this configuration, either:[/blue]")
console.print(f" 1. Source it: source {config_file}")
console.print(" 2. Export variables manually")
console.print(" 3. Set them in your shell profile")
@app.command("connect")
def langfuse_connect(
public_key: str = typer.Option(..., "--public-key", "-pk", help="Langfuse public key"),
secret_key: str = typer.Option(..., "--secret-key", "-sk", help="Langfuse secret key"),
host: str = typer.Option("https://cloud.langfuse.com", "--host", help="Langfuse host URL"),
):
"""Configure connection to remote Langfuse instance."""
return langfuse_config(public_key=public_key, secret_key=secret_key, host=host)
@app.command("test")
def langfuse_test():
"""Emit a test trace to verify Langfuse connectivity."""
console = Console()
try:
from praisonai.observability.langfuse import LangfuseSink, LangfuseSinkConfig
from praisonaiagents.trace.protocol import ActionEvent, ActionEventType, TraceEmitter
import time
except ImportError as e:
console.print(f"[red]❌ Import error: {e}[/red]")
console.print("[yellow]Install with: pip install praisonai[langfuse][/yellow]")
raise typer.Abort()
# Load configuration from env vars and ~/.praisonai/langfuse.env if present
config_file = Path.home() / ".praisonai" / "langfuse.env"
if config_file.exists():
# Load env file into current process environment
with open(config_file, 'r') as f:
for line in f:
line = line.strip()
if line and not line.startswith('#') and '=' in line:
key, value = line.split('=', 1)
os.environ[key.strip()] = value.strip()
console.print(f"[dim]📄 Loaded config from {config_file}[/dim]")
config = LangfuseSinkConfig()
if not config.enabled:
console.print("[yellow]⚠️ Langfuse credentials not configured[/yellow]")
console.print()
console.print("[bold]How to get your Langfuse API keys:[/bold]")
console.print("1. Go to [blue]https://cloud.langfuse.com[/blue] and sign up/login")
console.print("2. Create a new project (or open existing)")
console.print("3. Go to Project Settings → API Keys")
console.print("4. Click 'Create new API keys'")
console.print()
console.print("[bold]Then configure PraisonAI:[/bold]")
console.print(" [green]praisonai langfuse config --public-key=pk-... --secret-key=sk-...[/green]")
console.print()
console.print("Or set environment variables:")
console.print(" [green]export LANGFUSE_PUBLIC_KEY=pk-...[/green]")
console.print(" [green]export LANGFUSE_SECRET_KEY=sk-...[/green]")
raise typer.Abort()
console.print("[blue]🧪 Testing Langfuse connectivity...[/blue]")
console.print(f"[dim]Host: {config.host}[/dim]")
console.print(f"[dim]Public Key: {config.public_key[:8]}...[/dim]")
try:
# Create sink and emitter
sink = LangfuseSink(config)
emitter = TraceEmitter(sink=sink, enabled=True)
# Emit test events
agent_name = "test-agent"
timestamp = time.time()
console.print("[yellow]📤 Emitting test events...[/yellow]")
# Agent start
emitter.agent_start(agent_name, metadata={"test": True, "cli": "praisonai langfuse test"})
# Tool call
emitter.tool_start("test_tool", {"query": "Hello, Langfuse!"}, agent_name=agent_name)
time.sleep(0.1) # Simulate work
emitter.tool_end(
"test_tool",
duration_ms=100,
status="ok",
result_summary="Test completed successfully",
agent_name=agent_name
)
# Output
emitter.output("Test trace emitted successfully!", agent_name=agent_name)
# Agent end
emitter.agent_end(agent_name, status="ok")
# Flush and close
emitter.flush()
emitter.close()
console.print("[green]✅ Test trace sent successfully![/green]")
console.print(f"[blue]🌐 Check your Langfuse dashboard: {config.host}[/blue]")
console.print("[dim]It may take a few seconds for the trace to appear[/dim]")
except Exception as e:
console.print(f"[red]❌ Test failed: {e}[/red]")
raise typer.Abort()
@app.command("version")
def langfuse_version():
"""Show Langfuse version information."""
console = Console()
try:
import langfuse
console.print(f"[bold]Langfuse SDK[/bold]: {langfuse.__version__}")
except ImportError:
console.print("[yellow]Langfuse SDK not installed[/yellow]")
console.print("[blue]Install with: pip install praisonai[langfuse][/blue]")
except AttributeError:
console.print("[yellow]Langfuse SDK version unknown[/yellow]")
# Check local server version if running
data_dir = os.path.expanduser("~/.praisonai/langfuse")
repo_path = Path(data_dir) / "langfuse"
if repo_path.exists():
try:
result = subprocess.run([
"git", "describe", "--tags", "--abbrev=0"
], cwd=repo_path, capture_output=True, text=True)
if result.returncode == 0:
version = result.stdout.strip()
console.print(f"[bold]Local Langfuse[/bold]: {version}")
except Exception:
pass
@app.command("traces")
def langfuse_traces(
limit: int = typer.Option(20, "--limit", "-n", help="Maximum number of traces to show"),
session_id: Optional[str] = typer.Option(None, "--session", "-s", help="Filter by session ID"),
agent: Optional[str] = typer.Option(None, "--agent", "-a", help="Filter by agent name"),
):
"""
List traces from Langfuse.
Fetches and displays traces from the Langfuse API without opening the web UI.
Shows trace ID, name, timestamp, session, and metadata.
Examples:
praisonai langfuse traces
praisonai langfuse traces --limit 10
praisonai langfuse traces --session abc-123
praisonai langfuse traces --agent MyAgent
"""
console = Console()
# Import the client
try:
from praisonai.cli.langfuse_client import LangfuseClient, LangfuseAPIError
except ImportError:
console.print("[red]❌ Langfuse client not available[/red]")
raise typer.Abort()
# Load client from config
try:
client = LangfuseClient.from_config_file()
except FileNotFoundError as e:
console.print(f"[red]❌ {e}[/red]")
console.print("[yellow]Run 'praisonai langfuse config --public-key ... --secret-key ...' first[/yellow]")
raise typer.Abort()
except ValueError as e:
console.print(f"[red]❌ {e}[/red]")
raise typer.Abort()
# Fetch traces
try:
console.print(f"[blue]📊 Fetching traces from {client.host}...[/blue]")
traces = client.get_traces(limit=limit, session_id=session_id, name=agent)
if not traces:
console.print("[yellow]No traces found[/yellow]")
if session_id:
console.print(f"[dim]Filter: session_id={session_id}[/dim]")
if agent:
console.print(f"[dim]Filter: agent={agent}[/dim]")
return
# Create table
table = Table(title=f"Recent Traces ({len(traces)})")
table.add_column("Trace ID", style="cyan", no_wrap=True)
table.add_column("Name", style="green")
table.add_column("Timestamp", style="blue")
table.add_column("Session", style="yellow")
table.add_column("Observations", style="magenta")
for trace in traces:
trace_id = trace.get("id", "unknown")[:12] + "..."
name = trace.get("name", "unnamed")
# Parse timestamp
ts = trace.get("timestamp", "")
if ts:
try:
dt = datetime.fromisoformat(ts.replace("Z", "+00:00"))
timestamp = dt.strftime("%Y-%m-%d %H:%M")
except Exception:
timestamp = str(ts)[:16]
else:
timestamp = "unknown"
session = trace.get("sessionId", "")[:8] + "..." if trace.get("sessionId") else "-"
obs_count = len(trace.get("observations", []))
table.add_row(trace_id, name, timestamp, session, str(obs_count))
console.print(table)
# Show hint for viewing details
if traces:
console.print(f"[dim]View details: praisonai langfuse show {traces[0].get('id')}[/dim]")
except LangfuseAPIError as e:
console.print(f"[red]❌ API Error: {e}[/red]")
if "401" in str(e):
console.print("[yellow]Check your LANGFUSE_PUBLIC_KEY and LANGFUSE_SECRET_KEY[/yellow]")
elif "connection" in str(e).lower():
console.print("[yellow]Ensure Langfuse is running: praisonai langfuse status[/yellow]")
raise typer.Abort()
except Exception as e:
console.print(f"[red]❌ Error: {e}[/red]")
raise typer.Abort()
@app.command("sessions")
def langfuse_sessions(
limit: int = typer.Option(20, "--limit", "-n", help="Maximum number of sessions to show"),
):
"""
List sessions from Langfuse.
Fetches and displays sessions (conversation threads) from the Langfuse API.
Shows session ID, creation time, and number of traces in each session.
Examples:
praisonai langfuse sessions
praisonai langfuse sessions --limit 10
"""
console = Console()
# Import the client
try:
from praisonai.cli.langfuse_client import LangfuseClient, LangfuseAPIError
except ImportError:
console.print("[red]❌ Langfuse client not available[/red]")
raise typer.Abort()
# Load client from config
try:
client = LangfuseClient.from_config_file()
except FileNotFoundError as e:
console.print(f"[red]❌ {e}[/red]")
console.print("[yellow]Run 'praisonai langfuse config --public-key ... --secret-key ...' first[/yellow]")
raise typer.Abort()
except ValueError as e:
console.print(f"[red]❌ {e}[/red]")
raise typer.Abort()
# Fetch sessions
try:
console.print(f"[blue]📊 Fetching sessions from {client.host}...[/blue]")
sessions = client.get_sessions(limit=limit)
if not sessions:
console.print("[yellow]No sessions found[/yellow]")
return
# Create table
table = Table(title=f"Sessions ({len(sessions)})")
table.add_column("Session ID", style="cyan", no_wrap=True)
table.add_column("Created", style="blue")
table.add_column("Traces", style="green", justify="right")
for session in sessions:
session_id = session.get("id", "unknown")[:16] + "..."
# Parse timestamp
ts = session.get("createdAt", "")
if ts:
try:
dt = datetime.fromisoformat(ts.replace("Z", "+00:00"))
created = dt.strftime("%Y-%m-%d %H:%M")
except Exception:
created = str(ts)[:16]
else:
created = "unknown"
trace_count = session.get("traceCount", len(session.get("traces", [])))
table.add_row(session_id, created, str(trace_count))
console.print(table)
# Show hint
if sessions:
console.print(f"[dim]View traces in session: praisonai langfuse traces --session {sessions[0].get('id')}[/dim]")
except LangfuseAPIError as e:
console.print(f"[red]❌ API Error: {e}[/red]")
if "401" in str(e):
console.print("[yellow]Check your LANGFUSE_PUBLIC_KEY and LANGFUSE_SECRET_KEY[/yellow]")
elif "connection" in str(e).lower():
console.print("[yellow]Ensure Langfuse is running: praisonai langfuse status[/yellow]")
raise typer.Abort()
except Exception as e:
console.print(f"[red]❌ Error: {e}[/red]")
raise typer.Abort()
@app.command("show")
def langfuse_show(
trace_id: str = typer.Argument(..., help="Trace ID to display"),
):
"""
Show detailed information about a specific trace.
Displays full trace details including metadata, input/output, and all
observations (spans, events, generations) without opening the web UI.
Examples:
praisonai langfuse show trace-abc-123
praisonai langfuse show --help
"""
console = Console()
# Import the client
try:
from praisonai.cli.langfuse_client import LangfuseClient, LangfuseAPIError
except ImportError:
console.print("[red]❌ Langfuse client not available[/red]")
raise typer.Abort()
# Load client from config
try:
client = LangfuseClient.from_config_file()
except FileNotFoundError as e:
console.print(f"[red]❌ {e}[/red]")
console.print("[yellow]Run 'praisonai langfuse config --public-key ... --secret-key ...' first[/yellow]")
raise typer.Abort()
except ValueError as e:
console.print(f"[red]❌ {e}[/red]")
raise typer.Abort()
# Fetch trace details
try:
console.print(f"[blue]🔍 Fetching trace {trace_id}...[/blue]")
trace = client.get_trace(trace_id)
# Display trace info
console.print()
console.print(Panel(f"[bold cyan]Trace: {trace.get('name', 'unnamed')}[/bold cyan]",
subtitle=f"ID: {trace_id}"))
# Metadata
meta_table = Table(show_header=False, box=None)
meta_table.add_column("Key", style="yellow")
meta_table.add_column("Value", style="white")
# Parse timestamp
ts = trace.get("timestamp", "")
if ts:
try:
dt = datetime.fromisoformat(ts.replace("Z", "+00:00"))
timestamp = dt.strftime("%Y-%m-%d %H:%M:%S")
except Exception:
timestamp = str(ts)
else:
timestamp = "unknown"
meta_table.add_row("Timestamp:", timestamp)
meta_table.add_row("Session:", trace.get("sessionId", "-"))
meta_table.add_row("User:", trace.get("userId", "-"))
if trace.get("metadata"):
meta_str = json.dumps(trace.get("metadata"), indent=2)
meta_table.add_row("Metadata:", meta_str)
console.print(meta_table)
# Observations
observations = trace.get("observations", [])
if observations:
console.print()
console.print(f"[bold]Observations ({len(observations)}):[/bold]")
obs_table = Table()
obs_table.add_column("Type", style="cyan")
obs_table.add_column("Name", style="green")
obs_table.add_column("Start Time", style="blue")
obs_table.add_column("Status", style="yellow")
for obs in observations:
obs_type = obs.get("type", "unknown")
name = obs.get("name", "unnamed")
# Parse start time
start_ts = obs.get("startTime", "")
if start_ts:
try:
dt = datetime.fromisoformat(start_ts.replace("Z", "+00:00"))
start_time = dt.strftime("%H:%M:%S")
except Exception:
start_time = str(start_ts)[11:19]
else:
start_time = "-"
status = obs.get("level", "DEFAULT")
if obs.get("statusMessage"):
status = f"{status} ({obs.get('statusMessage')})"
obs_table.add_row(obs_type, name, start_time, status)
console.print(obs_table)
# Input/Output (if available)
if trace.get("input"):
console.print()
console.print("[bold]Input:[/bold]")
input_str = json.dumps(trace.get("input"), indent=2)
console.print(f"[dim]{input_str[:500]}[/dim]")
if trace.get("output"):
console.print()
console.print("[bold]Output:[/bold]")
output_str = json.dumps(trace.get("output"), indent=2)
console.print(f"[dim]{output_str[:500]}[/dim]")
console.print()
console.print(f"[dim]View in UI: {client.host}/trace/{trace_id}[/dim]")
except LangfuseAPIError as e:
if "404" in str(e) or "not found" in str(e).lower():
console.print(f"[red]❌ Trace not found: {trace_id}[/red]")
else:
console.print(f"[red]❌ API Error: {e}[/red]")
raise typer.Abort()
except Exception as e:
console.print(f"[red]❌ Error: {e}[/red]")
raise typer.Abort()
+653 -14
View File
@@ -1,20 +1,659 @@
"""Backward-compatibility shim for :mod:`praisonai.cli.commands.managed`.
"""
Managed Agents command group for PraisonAI CLI.
The implementation moved to :mod:`praisonai_code.cli.commands.managed` as part
of the praisonai-code extraction (issue #2516 / parent #2512).
Provides commands for Anthropic Managed Agents (cloud-hosted agent backend).
This shim aliases the moved module into ``sys.modules`` under the old dotted
path so that:
* ``from praisonai.cli.commands.managed import X`` keeps working, and
* ``unittest.mock.patch("praisonai.cli.commands.managed.X")`` patches the very
same module object that the implementation executes against.
Sub-command groups:
praisonai managed "prompt" — run a single prompt
praisonai managed multi — interactive multi-turn chat
praisonai managed sessions ... — list / get / resume sessions
praisonai managed agents ... — list / get / update agents
praisonai managed envs ... — list / get environments
praisonai managed ids ... — save / restore / show ID snapshots
"""
import sys as _sys
import json
import pathlib
from typing import Optional
from praisonai_code.cli.commands import managed as _impl
import typer
# Make the old dotted path resolve to the exact same module object so that
# attribute patching / monkeypatching stays transparent across both paths.
_sys.modules[__name__] = _impl
app = typer.Typer(help="Managed Agents (Anthropic cloud or local provider)")
# ── sub-app registrations (defined below, registered after) ──
sessions_app = typer.Typer(help="Manage sessions")
agents_app = typer.Typer(help="Manage agents")
envs_app = typer.Typer(help="Manage environments")
ids_app = typer.Typer(help="Save / restore assigned IDs")
@app.callback()
def managed_callback(ctx: typer.Context):
"""Managed Agents — Anthropic cloud-hosted backend.
Run a prompt:
praisonai managed run "Say hello"
praisonai managed run --stream "Explain decorators"
Manage resources:
praisonai managed sessions list agent_01...
praisonai managed agents list
praisonai managed envs list
praisonai managed ids show
"""
def _build_managed(
provider: Optional[str] = None,
model: Optional[str] = None,
system: Optional[str] = None,
name: Optional[str] = None,
packages: Optional[str] = None,
networking: bool = True,
database_url: Optional[str] = None,
):
"""Build a ManagedAgent instance from CLI arguments."""
try:
from praisonai import ManagedAgent, ManagedConfig
except ImportError:
typer.echo("Error: praisonai with managed agents support is required.")
typer.echo("Install with: pip install 'praisonai[managed]'")
raise typer.Exit(1)
# Build DB adapter if --db URL was provided
db_adapter = None
if database_url:
from praisonai import DB
db_adapter = DB(database_url=database_url)
# Determine effective provider
resolved_provider = provider
if resolved_provider is None:
import os
if os.getenv("ANTHROPIC_API_KEY") or os.getenv("CLAUDE_API_KEY"):
resolved_provider = "anthropic"
else:
resolved_provider = "local"
if resolved_provider == "anthropic":
cfg_kwargs = {}
if model:
cfg_kwargs["model"] = model
if system:
cfg_kwargs["system"] = system
if name:
cfg_kwargs["name"] = name
if packages:
cfg_kwargs["packages"] = {"pip": [p.strip() for p in packages.split(",")]}
if not networking:
cfg_kwargs["networking"] = {"type": "limited"}
config = ManagedConfig(**cfg_kwargs) if cfg_kwargs else None
return ManagedAgent(provider="anthropic", config=config, db=db_adapter)
else:
from praisonai import LocalManagedConfig
cfg_kwargs = {}
if model:
cfg_kwargs["model"] = model
if system:
cfg_kwargs["system"] = system
if name:
cfg_kwargs["name"] = name
if packages:
cfg_kwargs["packages"] = {"pip": [p.strip() for p in packages.split(",")]}
if not networking:
cfg_kwargs["networking"] = {"type": "limited"}
config = LocalManagedConfig(**cfg_kwargs) if cfg_kwargs else None
return ManagedAgent(provider=resolved_provider, config=config, db=db_adapter)
@app.command("run")
def managed_main(
prompt: str = typer.Argument(..., help="Prompt to send to the managed agent"),
model: Optional[str] = typer.Option(None, "--model", "-m", help="Model to use"),
system: Optional[str] = typer.Option(None, "--system", "-s", help="System prompt"),
name: Optional[str] = typer.Option(None, "--name", "-n", help="Agent name"),
stream: bool = typer.Option(False, "--stream", help="Stream response token by token"),
packages: Optional[str] = typer.Option(None, "--packages", "-p", help="Comma-separated pip packages to install"),
networking: bool = typer.Option(True, "--networking/--no-networking", help="Enable unrestricted networking"),
provider: Optional[str] = typer.Option(None, "--provider", help="Provider: anthropic, local, openai, ollama, gemini (auto-detects if not set)"),
database_url: Optional[str] = typer.Option(None, "--db", help="Database URL for persistence (e.g. sqlite:///data.db, postgresql://...)"),
verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
):
"""Run a single prompt on a managed agent.
Auto-detects provider: Anthropic if ANTHROPIC_API_KEY is set, local otherwise.
Use --provider to override.
Examples:
praisonai managed run "Say hello"
praisonai managed run "Write fibonacci" --model claude-sonnet-4-6
praisonai managed run "Analyze data" --provider local --model gpt-4o
praisonai managed run --stream --provider ollama --model llama3 "Explain decorators"
"""
managed = _build_managed(provider=provider, model=model, system=system, name=name, packages=packages, networking=networking, database_url=database_url)
from praisonai import Agent
agent = Agent(name=name or "managed-agent", backend=managed)
if verbose:
typer.echo(f"Provider: {managed.provider}")
typer.echo(f"Model: {managed._cfg.get('model', 'unknown')}")
typer.echo(f"System: {managed._cfg.get('system', 'default')}")
if packages:
typer.echo(f"Packages: {packages}")
if database_url:
typer.echo(f"DB: {database_url}")
typer.echo("")
result = agent.start(prompt, stream=stream)
if result and not stream:
print(result)
if verbose:
typer.echo(f"\nAgent ID: {managed.agent_id}")
typer.echo(f"Session ID: {getattr(managed, 'session_id', None)}")
typer.echo(f"Input tokens: {managed.total_input_tokens}")
typer.echo(f"Output tokens: {managed.total_output_tokens}")
@app.command("multi")
def managed_multi(
model: Optional[str] = typer.Option(None, "--model", "-m", help="Model to use"),
system: Optional[str] = typer.Option(None, "--system", "-s", help="System prompt"),
name: Optional[str] = typer.Option(None, "--name", "-n", help="Agent name"),
provider: Optional[str] = typer.Option(None, "--provider", help="Provider: anthropic, local, openai, ollama, gemini"),
database_url: Optional[str] = typer.Option(None, "--db", help="Database URL for persistence (e.g. sqlite:///data.db, postgresql://...)"),
verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
):
"""
Start multi-turn conversation with a managed agent.
Examples:
praisonai managed multi
praisonai managed multi --model claude-sonnet-4-6
praisonai managed multi --provider local --model gpt-4o
"""
managed = _build_managed(provider=provider, model=model, system=system, name=name, database_url=database_url)
from praisonai import Agent
agent = Agent(name=name or "managed-agent", backend=managed)
typer.echo(f"Managed Agent multi-turn session — provider: {managed.provider} (type 'exit' to stop)")
typer.echo(f"Model: {managed._cfg.get('model', 'default')}")
if database_url:
typer.echo(f"DB: {database_url}")
typer.echo("")
turn = 0
while True:
try:
user_input = input("You: ").strip()
except (EOFError, KeyboardInterrupt):
typer.echo("\nSession ended.")
break
if user_input.lower() in ("exit", "quit", "q"):
typer.echo("Session ended.")
break
if not user_input:
continue
turn += 1
result = agent.start(user_input, stream=True)
if result:
print(f"\nAgent: {result}\n")
if verbose:
typer.echo(f"\nTurns: {turn}")
typer.echo(f"Input tokens: {managed.total_input_tokens}")
typer.echo(f"Output tokens: {managed.total_output_tokens}")
# ─────────────────────────────────────────────────────────────────────────────
# sessions sub-commands
# ─────────────────────────────────────────────────────────────────────────────
def _get_client():
"""Return an Anthropic client using env API key."""
try:
import anthropic
except ImportError:
import sys
typer.echo(
"Error: the anthropic package is not installed for the Python running this CLI.\n"
f" Interpreter: {sys.executable}\n"
"Fix either:\n"
" • pip install 'anthropic>=0.94.0' into that environment, or\n"
" • use the same interpreter that already has anthropic, e.g.\n"
" python -m praisonai managed envs list"
)
raise typer.Exit(1)
import os
api_key = os.getenv("ANTHROPIC_API_KEY") or os.getenv("CLAUDE_API_KEY")
if not api_key:
typer.echo("Error: ANTHROPIC_API_KEY not set.")
raise typer.Exit(1)
return anthropic.Anthropic(api_key=api_key)
@sessions_app.command("list")
def sessions_list(
agent_id: str = typer.Argument(..., help="Agent ID (agent_01...)"),
limit: int = typer.Option(10, "--limit", "-l", help="Max sessions to return"),
):
"""List sessions for a given agent ID.
Example:
praisonai managed sessions list agent_01AbCdEf
"""
client = _get_client()
sessions = client.beta.sessions.list(agent_id=agent_id, limit=limit)
rows = getattr(sessions, "data", sessions)
if not rows:
typer.echo("No sessions found.")
return
typer.echo(f"{'ID':<40} {'STATUS':<10} TITLE")
typer.echo("-" * 70)
for s in rows:
typer.echo(f"{s.id:<40} {getattr(s,'status',''):<10} {getattr(s,'title','')}")
@sessions_app.command("get")
def sessions_get(
session_id: str = typer.Argument(..., help="Session ID (sesn_01...)"),
):
"""Get details and usage for a session.
Example:
praisonai managed sessions get sesn_01AbCdEf
"""
client = _get_client()
sess = client.beta.sessions.retrieve(session_id)
typer.echo(f"ID : {sess.id}")
typer.echo(f"Status : {getattr(sess, 'status', 'unknown')}")
typer.echo(f"Title : {getattr(sess, 'title', '')}")
usage = getattr(sess, "usage", None)
if usage:
typer.echo(f"Input tokens : {getattr(usage, 'input_tokens', 0)}")
typer.echo(f"Output tokens: {getattr(usage, 'output_tokens', 0)}")
@sessions_app.command("resume")
def sessions_resume(
session_id: str = typer.Argument(..., help="Session ID to resume (sesn_01...)"),
prompt: str = typer.Argument(..., help="Prompt to send to the resumed session"),
stream: bool = typer.Option(True, "--stream/--no-stream", help="Stream response"),
model: Optional[str] = typer.Option(None, "--model", "-m", help="Model (only used if agent needs re-creating)"),
system: Optional[str] = typer.Option(None, "--system", "-s", help="System prompt override"),
):
"""Resume an existing session by ID and send a prompt.
Example:
praisonai managed sessions resume sesn_01AbCdEf "Continue the task"
praisonai managed sessions resume sesn_01AbCdEf "What did we do?" --stream
"""
try:
from praisonai import Agent, ManagedAgent, ManagedConfig
except ImportError:
typer.echo("Error: pip install praisonai")
raise typer.Exit(1)
cfg_kwargs = {}
if model:
cfg_kwargs["model"] = model
if system:
cfg_kwargs["system"] = system
managed = ManagedAgent(config=ManagedConfig(**cfg_kwargs) if cfg_kwargs else None)
managed.resume_session(session_id)
agent = Agent(name="managed-agent", backend=managed)
result = agent.start(prompt, stream=stream)
if result and not stream:
print(result)
@sessions_app.command("delete")
def sessions_delete(
session_id: str = typer.Argument(..., help="Session ID to delete (sesn_01...)"),
yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt"),
):
"""Delete a session.
Example:
praisonai managed sessions delete sesn_01AbCdEf
praisonai managed sessions delete sesn_01AbCdEf --yes
"""
if not yes:
confirm = typer.confirm(f"Are you sure you want to delete session {session_id}?")
if not confirm:
typer.echo("Deletion cancelled.")
raise typer.Exit(0)
client = _get_client()
try:
client.beta.sessions.delete(session_id)
typer.echo(f"Session {session_id} deleted successfully.")
except Exception as e:
typer.echo(f"Error deleting session: {e}")
raise typer.Exit(1)
# ─────────────────────────────────────────────────────────────────────────────
# agents sub-commands
# ─────────────────────────────────────────────────────────────────────────────
@agents_app.command("list")
def agents_list(
limit: int = typer.Option(10, "--limit", "-l", help="Max agents to return"),
):
"""List managed agents on your Anthropic account.
Example:
praisonai managed agents list
praisonai managed agents list --limit 20
"""
client = _get_client()
agents = client.beta.agents.list(limit=limit)
rows = getattr(agents, "data", agents)
if not rows:
typer.echo("No agents found.")
return
typer.echo(f"{'ID':<40} {'VER':<5} {'MODEL':<25} NAME")
typer.echo("-" * 85)
for a in rows:
model_obj = getattr(a, 'model', '')
model_str = getattr(model_obj, 'id', str(model_obj))
typer.echo(
f"{a.id:<40} {str(getattr(a,'version','')):<5} "
f"{model_str:<25} {getattr(a,'name','')}"
)
@agents_app.command("get")
def agents_get(
agent_id: str = typer.Argument(..., help="Agent ID (agent_01...)"),
):
"""Get details for a specific agent.
Example:
praisonai managed agents get agent_01AbCdEf
"""
client = _get_client()
a = client.beta.agents.retrieve(agent_id)
typer.echo(f"ID : {a.id}")
typer.echo(f"Name : {getattr(a, 'name', '')}")
model_obj = getattr(a, 'model', '')
typer.echo(f"Model : {getattr(model_obj, 'id', str(model_obj))}")
typer.echo(f"Version: {getattr(a, 'version', '')}")
typer.echo(f"System : {getattr(a, 'system', '')[:120]}")
@agents_app.command("update")
def agents_update(
agent_id: str = typer.Argument(..., help="Agent ID (agent_01...)"),
name: Optional[str] = typer.Option(None, "--name", "-n", help="New agent name"),
system: Optional[str] = typer.Option(None, "--system", "-s", help="New system prompt"),
model: Optional[str] = typer.Option(None, "--model", "-m", help="New model"),
version: Optional[int] = typer.Option(None, "--version", help="Agent version (required by API)"),
):
"""Update an existing agent's name, system prompt, or model.
Example:
praisonai managed agents update agent_01AbCdEf --name "New Name" --version 1
praisonai managed agents update agent_01AbCdEf --system "You are a data analyst." --version 2
"""
client = _get_client()
kwargs = {}
if name:
kwargs["name"] = name
if system:
kwargs["system"] = system
if model:
kwargs["model"] = model
if version is not None:
kwargs["version"] = version
if not kwargs:
typer.echo("Nothing to update. Pass --name, --system, or --model.")
raise typer.Exit(0)
updated = client.beta.agents.update(agent_id, **kwargs)
typer.echo(f"Updated agent: {updated.id} (v{getattr(updated,'version','')})")
@agents_app.command("delete")
def agents_delete(
agent_id: str = typer.Argument(..., help="Agent ID to delete (agent_01...)"),
yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt"),
):
"""Delete an agent.
Example:
praisonai managed agents delete agent_01AbCdEf
praisonai managed agents delete agent_01AbCdEf --yes
"""
if not yes:
confirm = typer.confirm(f"Are you sure you want to delete agent {agent_id}?")
if not confirm:
typer.echo("Deletion cancelled.")
raise typer.Exit(0)
client = _get_client()
try:
client.beta.agents.delete(agent_id)
typer.echo(f"Agent {agent_id} deleted successfully.")
except Exception as e:
typer.echo(f"Error deleting agent: {e}")
raise typer.Exit(1)
# ─────────────────────────────────────────────────────────────────────────────
# envs sub-commands
# ─────────────────────────────────────────────────────────────────────────────
@envs_app.command("list")
def envs_list(
limit: int = typer.Option(10, "--limit", "-l", help="Max environments to return"),
):
"""List sandbox environments on your Anthropic account.
Example:
praisonai managed envs list
"""
client = _get_client()
envs = client.beta.environments.list(limit=limit)
rows = getattr(envs, "data", envs)
if not rows:
typer.echo("No environments found.")
return
typer.echo(f"{'ID':<40} {'STATUS':<12} NAME")
typer.echo("-" * 70)
for e in rows:
typer.echo(f"{e.id:<40} {getattr(e,'status',''):<12} {getattr(e,'name','')}")
@envs_app.command("get")
def envs_get(
env_id: str = typer.Argument(..., help="Environment ID (env_01...)"),
):
"""Get details for a specific environment.
Example:
praisonai managed envs get env_01AbCdEf
"""
client = _get_client()
e = client.beta.environments.retrieve(env_id)
typer.echo(f"ID : {e.id}")
typer.echo(f"Name : {getattr(e, 'name', '')}")
typer.echo(f"Status: {getattr(e, 'status', '')}")
cfg = getattr(e, "config", None)
if cfg:
typer.echo(f"Config: {cfg}")
@envs_app.command("update")
def envs_update(
env_id: str = typer.Argument(..., help="Environment ID (env_01...)"),
packages: Optional[str] = typer.Option(None, "--packages", "-p", help="Comma-separated pip packages"),
networking: Optional[str] = typer.Option(None, "--networking", help="Networking type: 'full' or 'limited'"),
):
"""Update an environment's configuration.
Example:
praisonai managed envs update env_01AbCdEf --packages "numpy,pandas"
praisonai managed envs update env_01AbCdEf --networking limited
"""
client = _get_client()
kwargs = {}
if packages:
pkg_list = [p.strip() for p in packages.split(",") if p.strip()]
if not pkg_list:
typer.echo("Error: --packages must include at least one package name")
raise typer.Exit(1)
kwargs["packages"] = {"pip": pkg_list}
if networking:
if networking not in ["full", "limited"]:
typer.echo("Error: --networking must be 'full' or 'limited'")
raise typer.Exit(1)
kwargs["networking"] = {"type": networking}
if not kwargs:
typer.echo("Nothing to update. Pass --packages or --networking.")
raise typer.Exit(0)
try:
updated = client.beta.environments.update(env_id, **kwargs)
typer.echo(f"Updated environment: {updated.id}")
except Exception as e:
typer.echo(f"Error updating environment: {e}")
raise typer.Exit(1)
@envs_app.command("delete")
def envs_delete(
env_id: str = typer.Argument(..., help="Environment ID to delete (env_01...)"),
yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt"),
):
"""Delete an environment.
Example:
praisonai managed envs delete env_01AbCdEf
praisonai managed envs delete env_01AbCdEf --yes
"""
if not yes:
confirm = typer.confirm(f"Are you sure you want to delete environment {env_id}?")
if not confirm:
typer.echo("Deletion cancelled.")
raise typer.Exit(0)
client = _get_client()
try:
client.beta.environments.delete(env_id)
typer.echo(f"Environment {env_id} deleted successfully.")
except Exception as e:
typer.echo(f"Error deleting environment: {e}")
raise typer.Exit(1)
# ─────────────────────────────────────────────────────────────────────────────
# ids sub-commands (save / restore / show — no Anthropic IDs are user-defined)
# ─────────────────────────────────────────────────────────────────────────────
_DEFAULT_IDS_FILE = pathlib.Path("managed_ids.json")
@ids_app.command("show")
def ids_show(
file: pathlib.Path = typer.Option(_DEFAULT_IDS_FILE, "--file", "-f", help="IDs file path"),
):
"""Show saved Anthropic-assigned IDs from a JSON file.
Example:
praisonai managed ids show
praisonai managed ids show --file /tmp/my_ids.json
"""
if not file.exists():
typer.echo(f"No IDs file found at {file}. Run a prompt first or use 'ids save'.")
raise typer.Exit(1)
ids = json.loads(file.read_text())
typer.echo(f"File: {file}")
for k, v in ids.items():
typer.echo(f" {k:<20}: {v}")
@ids_app.command("save")
def ids_save(
agent_id: str = typer.Argument(..., help="Agent ID (agent_01...)"),
environment_id: str = typer.Argument(..., help="Environment ID (env_01...)"),
session_id: str = typer.Argument(..., help="Session ID (sesn_01...)"),
agent_version: Optional[int] = typer.Option(None, "--version", help="Agent version"),
file: pathlib.Path = typer.Option(_DEFAULT_IDS_FILE, "--file", "-f", help="Output file path"),
):
"""Save Anthropic-assigned IDs to a JSON file for later restore.
Anthropic assigns all IDs — you cannot define your own.
Use this to persist them so you can skip re-creating resources.
Example:
praisonai managed ids save agent_01... env_01... sesn_01... --version 1
"""
ids = {
"agent_id": agent_id,
"agent_version": agent_version,
"environment_id": environment_id,
"session_id": session_id,
}
file.write_text(json.dumps(ids, indent=2))
typer.echo(f"Saved IDs to {file}:")
for k, v in ids.items():
typer.echo(f" {k}: {v}")
@ids_app.command("restore")
def ids_restore(
prompt: str = typer.Argument(..., help="Prompt to send after restoring IDs"),
file: pathlib.Path = typer.Option(_DEFAULT_IDS_FILE, "--file", "-f", help="IDs file to restore from"),
stream: bool = typer.Option(True, "--stream/--no-stream", help="Stream response"),
):
"""Restore saved IDs and run a prompt — skips creating agent/env/session.
Example:
praisonai managed ids restore "Continue the previous task"
praisonai managed ids restore "What did we do?" --file /tmp/my_ids.json
"""
if not file.exists():
typer.echo(f"No IDs file at {file}. Run 'praisonai managed ids save ...' first.")
raise typer.Exit(1)
try:
from praisonai import Agent, ManagedAgent
except ImportError:
typer.echo("Error: pip install praisonai")
raise typer.Exit(1)
ids = json.loads(file.read_text())
typer.echo(f"Restoring IDs from {file}:")
for k, v in ids.items():
typer.echo(f" {k}: {v}")
typer.echo("")
managed = ManagedAgent()
managed.restore_ids(ids)
agent = Agent(name="managed-agent", backend=managed)
result = agent.start(prompt, stream=stream)
if result and not stream:
print(result)
# ─────────────────────────────────────────────────────────────────────────────
# Register sub-apps onto main app
# ─────────────────────────────────────────────────────────────────────────────
app.add_typer(sessions_app, name="sessions")
app.add_typer(agents_app, name="agents")
app.add_typer(envs_app, name="envs")
app.add_typer(ids_app, name="ids")
+758 -15
View File
@@ -1,20 +1,763 @@
"""Backward-compatibility shim for :mod:`praisonai.cli.commands.mcp`.
"""
MCP command group for PraisonAI CLI.
The implementation moved to :mod:`praisonai_code.cli.commands.mcp` as part
of the praisonai-code extraction (issue #2516 / parent #2512).
This shim aliases the moved module into ``sys.modules`` under the old dotted
path so that:
* ``from praisonai.cli.commands.mcp import X`` keeps working, and
* ``unittest.mock.patch("praisonai.cli.commands.mcp.X")`` patches the very
same module object that the implementation executes against.
Provides MCP (Model Context Protocol) server management.
"""
import sys as _sys
from typing import Optional
from praisonai_code.cli.commands import mcp as _impl
import typer
# Make the old dotted path resolve to the exact same module object so that
# attribute patching / monkeypatching stays transparent across both paths.
_sys.modules[__name__] = _impl
from ..output.console import get_output_controller
from ..configuration.loader import get_config_loader
app = typer.Typer(help="MCP server management")
@app.command("list")
def mcp_list(
json_output: bool = typer.Option(False, "--json", help="Output JSON"),
):
"""List configured MCP servers."""
from ..configuration.schema import MCPLocalConfig, MCPRemoteConfig
output = get_output_controller()
loader = get_config_loader()
config = loader.load()
servers = config.mcp.servers
if output.is_json_mode or json_output:
server_data = {}
for name, s in servers.items():
if isinstance(s, MCPRemoteConfig):
server_data[name] = {
"type": "remote",
"url": s.url,
"enabled": s.enabled,
}
else:
server_data[name] = {
"type": "local",
"command": s.command,
"args": s.args,
"enabled": s.enabled,
}
output.print_json({"servers": server_data})
return
if not servers:
output.print_info("No MCP servers configured")
output.print("\nAdd a server with: praisonai mcp add <name> <command>")
return
headers = ["Name", "Type", "Target", "Enabled"]
rows = []
for name, server in servers.items():
if isinstance(server, MCPRemoteConfig):
target = server.url
server_type = "remote"
else:
target = f"{server.command} {' '.join(server.args)}"
server_type = "local"
if len(target) > 45:
target = target[:42] + "..."
rows.append([name, server_type, target, "✓" if server.enabled else "✗"])
output.print_table(headers, rows, title="MCP Servers")
@app.command("add")
def mcp_add(
name: str = typer.Argument(..., help="Server name"),
command: str = typer.Argument(..., help="Command to run"),
args: Optional[str] = typer.Option(None, "--args", "-a", help="Command arguments (space-separated)"),
env: Optional[str] = typer.Option(None, "--env", "-e", help="Environment variables (KEY=VALUE,...)"),
):
"""Add an MCP server."""
output = get_output_controller()
loader = get_config_loader()
# Parse args
arg_list = args.split() if args else []
# Parse env
env_dict = {}
if env:
for pair in env.split(","):
if "=" in pair:
key, value = pair.split("=", 1)
env_dict[key.strip()] = value.strip()
# Build server config
server_config = {
"command": command,
"args": arg_list,
"env": env_dict,
"enabled": True,
}
# Save to config
loader.set(f"mcp.servers.{name}", server_config)
if output.is_json_mode:
output.print_json({"added": name, "config": server_config})
else:
output.print_success(f"Added MCP server: {name}")
@app.command("remove")
def mcp_remove(
name: str = typer.Argument(..., help="Server name to remove"),
confirm: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation"),
):
"""Remove an MCP server."""
output = get_output_controller()
loader = get_config_loader()
config = loader.load()
if name not in config.mcp.servers:
output.print_error(f"Server not found: {name}")
raise typer.Exit(1)
if not confirm:
confirmed = typer.confirm(f"Remove MCP server '{name}'?")
if not confirmed:
output.print_info("Cancelled")
raise typer.Exit(0)
# Remove from config by setting to None (will be cleaned up)
# For now, we need to reload and modify the raw config
from ..configuration.paths import get_user_config_path
from ..configuration.loader import _load_toml, _save_toml
config_path = get_user_config_path()
if config_path.exists():
raw_config = _load_toml(config_path)
if "mcp" in raw_config and "servers" in raw_config["mcp"]:
if name in raw_config["mcp"]["servers"]:
del raw_config["mcp"]["servers"][name]
_save_toml(config_path, raw_config)
if output.is_json_mode:
output.print_json({"removed": name})
else:
output.print_success(f"Removed MCP server: {name}")
@app.command("test")
def mcp_test(
name: str = typer.Argument(..., help="Server name to test"),
timeout: float = typer.Option(10.0, "--timeout", "-t", help="Timeout in seconds"),
):
"""Test an MCP server connection."""
from ..configuration.schema import MCPLocalConfig, MCPRemoteConfig
output = get_output_controller()
loader = get_config_loader()
config = loader.load()
if name not in config.mcp.servers:
output.print_error(f"Server not found: {name}")
raise typer.Exit(1)
server = config.mcp.servers[name]
if isinstance(server, MCPRemoteConfig):
# Test remote server
output.print_info(f"Testing remote MCP server: {name}")
output.print(f" URL: {server.url}")
try:
import httpx
# Simple HTTP probe to check if server is reachable
with httpx.Client(timeout=timeout) as client:
response = client.get(server.url)
if response.status_code == 200:
if output.is_json_mode:
output.print_json({"name": name, "status": "ok", "message": "Remote server reachable"})
else:
output.print_success(f"Remote server '{name}' is reachable")
else:
if output.is_json_mode:
output.print_json({"name": name, "status": "error", "message": f"HTTP {response.status_code}"})
else:
output.print_error(f"Remote server returned HTTP {response.status_code}")
raise typer.Exit(1)
except ImportError:
if output.is_json_mode:
output.print_json({"name": name, "status": "error", "message": "httpx not available for remote testing"})
else:
output.print_error("httpx package not available. Install with: pip install httpx")
raise typer.Exit(1)
except typer.Exit:
# Let typer.Exit propagate, don't catch it
raise
except Exception as e:
if output.is_json_mode:
output.print_json({"name": name, "status": "error", "message": str(e)})
else:
output.print_error(f"Failed to connect to remote server: {e}")
raise typer.Exit(1)
else:
# Test local server
output.print_info(f"Testing local MCP server: {name}")
output.print(f" Command: {server.command} {' '.join(server.args)}")
# Try to start the server and check if it responds
import subprocess
import time
try:
cmd = [server.command] + server.args
# Fix environment inheritance - merge with current env instead of replacing
env = None
if server.env:
import os
env = {**os.environ, **server.env}
# Start process
proc = subprocess.Popen(
cmd,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
env=env,
)
# Wait briefly and check if it's running
time.sleep(0.5)
if proc.poll() is None:
# Process is running
proc.terminate()
proc.wait(timeout=2)
if output.is_json_mode:
output.print_json({"name": name, "status": "ok", "message": "Server started successfully"})
else:
output.print_success(f"Server '{name}' started successfully")
else:
# Process exited
stderr = proc.stderr.read().decode() if proc.stderr else ""
if output.is_json_mode:
output.print_json({"name": name, "status": "error", "message": stderr or "Server exited immediately"})
else:
output.print_error(f"Server exited immediately: {stderr}")
raise typer.Exit(1)
except FileNotFoundError:
if output.is_json_mode:
output.print_json({"name": name, "status": "error", "message": f"Command not found: {server.command}"})
else:
output.print_error(f"Command not found: {server.command}")
raise typer.Exit(1)
except Exception as e:
if output.is_json_mode:
output.print_json({"name": name, "status": "error", "message": str(e)})
else:
output.print_error(f"Test failed: {e}")
raise typer.Exit(1)
@app.command("sync")
def mcp_sync(
server: Optional[str] = typer.Argument(None, help="Server name (None for all)"),
timeout: float = typer.Option(30.0, "--timeout", "-t", help="Timeout in seconds"),
):
"""Sync tool schemas from MCP servers to local index."""
output = get_output_controller()
loader = get_config_loader()
config = loader.load()
servers_to_sync = []
if server:
if server not in config.mcp.servers:
output.print_error(f"Server not found: {server}")
raise typer.Exit(1)
servers_to_sync = [server]
else:
servers_to_sync = list(config.mcp.servers.keys())
if not servers_to_sync:
output.print_info("No MCP servers configured")
return
total_tools = 0
synced_servers = []
failed_servers = []
for srv in servers_to_sync:
output.print_info(f"Syncing {srv}...")
srv_config = config.mcp.servers[srv]
try:
# Import schema types for checking
from ..configuration.schema import MCPLocalConfig, MCPRemoteConfig
# Actually connect to MCP server and fetch tools
from praisonaiagents.mcp import MCP
# Handle different server types
if isinstance(srv_config, MCPRemoteConfig):
# Remote server - connect via URL
if not srv_config.url:
output.print_error(f" Remote server {srv} has no URL configured")
failed_servers.append(srv)
continue
# Create MCP instance for remote server
mcp = MCP(srv_config.url, timeout=int(timeout))
else:
# Local server - use command
cmd_parts = [srv_config.command] + srv_config.args
cmd_string = " ".join(cmd_parts)
# Create MCP instance with env vars
mcp = MCP(cmd_string, timeout=int(timeout), env=srv_config.env or {})
# Get tools
tools = mcp.get_tools()
openai_tools = mcp.to_openai_tool() or []
tool_count = len(tools)
total_tools += tool_count
synced_servers.append(srv)
output.print(f" Found {tool_count} tools:")
for tool_def in openai_tools:
if isinstance(tool_def, dict) and "function" in tool_def:
name = tool_def["function"].get("name", "unknown")
desc = tool_def["function"].get("description", "")[:40]
output.print(f" - {name}: {desc}...")
# Clean up
mcp.shutdown()
except ImportError:
output.print_error(" MCP package not installed. Install with: pip install praisonaiagents[mcp]")
failed_servers.append(srv)
except Exception as e:
output.print_error(f" Failed to sync: {e}")
failed_servers.append(srv)
# Summary
if output.is_json_mode:
output.print_json({
"synced_servers": synced_servers,
"failed_servers": failed_servers,
"total_tools": total_tools
})
else:
if synced_servers:
output.print_success(f"Synced {len(synced_servers)} server(s), {total_tools} tools total")
if failed_servers:
output.print_error(f"Failed to sync {len(failed_servers)} server(s): {', '.join(failed_servers)}")
@app.command("tools")
def mcp_tools(
server: Optional[str] = typer.Argument(None, help="Server name (None for all)"),
json_output: bool = typer.Option(False, "--json", help="Output JSON"),
):
"""List indexed MCP tools."""
output = get_output_controller()
try:
from praisonai.mcp_server.tool_index import MCPToolIndex
index = MCPToolIndex()
if server:
tools = index.list_tools(server)
if not tools:
output.print_info(f"No tools indexed for server: {server}")
output.print("\nRun 'praisonai mcp sync' to index tools")
return
else:
tools = index.get_all_tools()
if not tools:
output.print_info("No tools indexed")
output.print("\nRun 'praisonai mcp sync' to index tools")
return
if output.is_json_mode or json_output:
output.print_json({"tools": [t.to_dict() for t in tools]})
else:
headers = ["Server", "Tool", "Description"]
rows = [[t.server, t.name, t.hint[:50] + "..." if len(t.hint) > 50 else t.hint] for t in tools]
output.print_table(headers, rows, title=f"MCP Tools ({len(tools)} total)")
except ImportError as e:
output.print_error(f"Error: {e}")
raise typer.Exit(1)
@app.command("describe")
def mcp_describe(
server: str = typer.Argument(..., help="Server name"),
tool: str = typer.Argument(..., help="Tool name"),
):
"""Show full schema for an MCP tool."""
output = get_output_controller()
try:
from praisonai.mcp_server.tool_index import MCPToolIndex
import json
index = MCPToolIndex()
schema = index.describe(server, tool)
if not schema:
output.print_error(f"Tool not found: {server}/{tool}")
raise typer.Exit(1)
if output.is_json_mode:
output.print_json(schema)
else:
output.print(json.dumps(schema, indent=2))
except ImportError as e:
output.print_error(f"Error: {e}")
raise typer.Exit(1)
@app.command("status")
def mcp_status(
server: Optional[str] = typer.Argument(None, help="Server name (None for all)"),
json_output: bool = typer.Option(False, "--json", help="Output JSON"),
):
"""Show status of MCP servers."""
output = get_output_controller()
try:
from praisonai.mcp_server.tool_index import MCPToolIndex
from datetime import datetime
index = MCPToolIndex()
servers = [server] if server else index.list_servers()
if not servers:
output.print_info("No servers indexed")
return
statuses = []
for srv in servers:
status = index.get_status(srv)
if status:
statuses.append(status)
if output.is_json_mode or json_output:
output.print_json({"statuses": [s.to_dict() for s in statuses]})
else:
headers = ["Server", "Status", "Tools", "Last Sync"]
rows = []
for s in statuses:
status_str = "✓ Available" if s.available else "✗ Unavailable"
if s.auth_required:
status_str += " (auth required)"
last_sync = datetime.fromtimestamp(s.last_sync).strftime("%Y-%m-%d %H:%M") if s.last_sync else "Never"
rows.append([s.server, status_str, str(s.tool_count), last_sync])
output.print_table(headers, rows, title="MCP Server Status")
except ImportError as e:
output.print_error(f"Error: {e}")
raise typer.Exit(1)
@app.command("run")
def mcp_run(
name: str = typer.Argument(..., help="Server name to run"),
port: int = typer.Option(8080, "--port", "-p", help="Port for SSE transport"),
host: str = typer.Option("127.0.0.1", "--host", "-h", help="Host to bind to"),
transport: str = typer.Option("stdio", "--transport", "-T", help="Transport type: stdio or sse"),
):
"""Run an MCP server from configuration.
DEPRECATED: Use `praisonai serve mcp --name <server>` instead.
This command starts an MCP server using the configuration stored
for the given server name. It can run in stdio mode (default) or
as an SSE server.
Examples:
praisonai mcp run my-server
praisonai mcp run my-server --transport sse --port 8080
"""
import sys
# Print deprecation warning
print("\n\033[93m⚠ DEPRECATION WARNING:\033[0m", file=sys.stderr)
print("\033[93m'praisonai mcp run' is deprecated and will be removed in a future version.\033[0m", file=sys.stderr)
print("\033[93mPlease use 'praisonai serve mcp --name <server>' instead.\033[0m\n", file=sys.stderr)
output = get_output_controller()
loader = get_config_loader()
config = loader.load()
if name not in config.mcp.servers:
output.print_error(f"Server not found: {name}")
raise typer.Exit(1)
server = config.mcp.servers[name]
if transport not in ("stdio", "sse"):
output.print_error(f"Invalid transport: {transport}. Use 'stdio' or 'sse'")
raise typer.Exit(1)
output.print_info(f"Starting MCP server: {name}")
output.print(f" Command: {server.command} {' '.join(server.args)}")
output.print(f" Transport: {transport}")
if transport == "sse":
output.print(f" Endpoint: http://{host}:{port}/sse")
import subprocess
import os
import sys
try:
cmd = [server.command] + server.args
# Build environment
env = os.environ.copy()
if server.env:
env.update(server.env)
if transport == "stdio":
# Run in stdio mode - pass through stdin/stdout
output.print_info("Running in stdio mode. Press Ctrl+C to stop.")
proc = subprocess.Popen(
cmd,
stdin=sys.stdin,
stdout=sys.stdout,
stderr=sys.stderr,
env=env,
)
proc.wait()
else:
# SSE mode - would need to wrap the server
# For now, just run the server and let it handle SSE
output.print_info(f"Running in SSE mode on http://{host}:{port}")
output.print_info("Press Ctrl+C to stop.")
# Try to use praisonaiagents MCP server wrapper
try:
from praisonaiagents.mcp import MCP
# Build command string
cmd_string = " ".join(cmd)
mcp = MCP(cmd_string, timeout=60, env=server.env or {})
# Get tools and expose via SSE
tools = mcp.get_tools()
output.print(f" Exposing {len(tools)} tools via SSE")
# Use ToolsMCPServer to expose tools
from praisonaiagents.mcp import ToolsMCPServer
mcp_server = ToolsMCPServer(name=name, tools=tools)
mcp_server.run_sse(host=host, port=port)
except ImportError:
output.print_error("MCP package not installed. Install with: pip install praisonaiagents[mcp]")
raise typer.Exit(1)
except KeyboardInterrupt:
output.print_info("\nServer stopped.")
except FileNotFoundError:
output.print_error(f"Command not found: {server.command}")
raise typer.Exit(1)
except KeyboardInterrupt:
output.print_info("\nServer stopped.")
except Exception as e:
output.print_error(f"Failed to start server: {e}")
raise typer.Exit(1)
@app.command("auth")
def mcp_auth(
name: str = typer.Argument(..., help="Server name to authenticate"),
timeout: float = typer.Option(300.0, "--timeout", "-t", help="Timeout for OAuth flow in seconds"),
):
"""[EXPERIMENTAL] Authenticate with an OAuth-enabled MCP server.
WARNING: OAuth implementation is currently experimental and stores
placeholder tokens only. Real token exchange is not yet implemented.
Use headers/API key authentication for production use.
This command initiates the OAuth 2.1 authorization flow for a remote
MCP server. It will open your browser for authentication and wait
for the callback.
Examples:
praisonai mcp auth github
praisonai mcp auth tavily --timeout 60
"""
output = get_output_controller()
loader = get_config_loader()
config = loader.load()
if name not in config.mcp.servers:
output.print_error(f"Server not found: {name}")
raise typer.Exit(1)
server = config.mcp.servers[name]
# Check if this is a remote server
from ..configuration.schema import MCPRemoteConfig
if not isinstance(server, MCPRemoteConfig):
output.print_error(f"Server '{name}' is a local server. OAuth is only for remote servers.")
raise typer.Exit(1)
if not server.url:
output.print_error(f"Server '{name}' has no URL configured.")
raise typer.Exit(1)
output.print_info(f"Authenticating with MCP server: {name}")
output.print(f" URL: {server.url}")
try:
from praisonaiagents.mcp import (
MCPAuthStorage,
OAuthCallbackHandler,
generate_state,
generate_code_verifier,
generate_code_challenge,
get_redirect_url,
)
import webbrowser
# Initialize auth storage and callback handler
auth_storage = MCPAuthStorage()
callback_handler = OAuthCallbackHandler()
# Generate PKCE parameters
state = generate_state()
code_verifier = generate_code_verifier()
code_challenge = generate_code_challenge(code_verifier)
# Store state and verifier
auth_storage.set_oauth_state(name, state)
auth_storage.set_code_verifier(name, code_verifier)
# Build authorization URL
# Note: In a real implementation, we'd discover the auth endpoint
# For now, we'll use a simple pattern
redirect_uri = get_redirect_url()
# Get OAuth config
client_id = server.oauth.client_id if server.oauth else None
scopes = " ".join(server.oauth.scopes) if server.oauth and server.oauth.scopes else ""
# Build auth URL (simplified - real impl would use OIDC discovery)
auth_url = f"{server.url}/oauth/authorize"
auth_url += "?response_type=code"
auth_url += f"&state={state}"
auth_url += f"&redirect_uri={redirect_uri}"
auth_url += f"&code_challenge={code_challenge}"
auth_url += "&code_challenge_method=S256"
if client_id:
auth_url += f"&client_id={client_id}"
if scopes:
auth_url += f"&scope={scopes}"
output.print_info("Opening browser for authentication...")
output.print(f" If browser doesn't open, visit: {auth_url}")
# Open browser
webbrowser.open(auth_url)
output.print_info(f"Waiting for callback (timeout: {timeout}s)...")
# Wait for callback
try:
code = callback_handler.wait_for_callback(state, timeout=timeout)
# Clear temporary state
auth_storage.clear_oauth_state(name)
auth_storage.clear_code_verifier(name)
# Store a placeholder token (real impl would exchange code for tokens)
auth_storage.set_tokens(name, {
"access_token": f"oauth_{code[:20]}...",
"refresh_token": None,
}, server_url=server.url)
if output.is_json_mode:
output.print_json({"name": name, "status": "authenticated"})
else:
output.print_success(f"Successfully authenticated with {name}")
except TimeoutError:
auth_storage.clear_oauth_state(name)
auth_storage.clear_code_verifier(name)
output.print_error(f"Authentication timed out after {timeout} seconds")
raise typer.Exit(1)
except ImportError as e:
output.print_error(f"MCP auth modules not available: {e}")
output.print("Install with: pip install praisonaiagents[mcp]")
raise typer.Exit(1)
except Exception as e:
output.print_error(f"Authentication failed: {e}")
raise typer.Exit(1)
@app.command("logout")
def mcp_logout(
name: str = typer.Argument(..., help="Server name to logout from"),
confirm: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation"),
):
"""Remove OAuth credentials for an MCP server.
This command clears stored OAuth tokens and client information
for a remote MCP server.
Examples:
praisonai mcp logout github
praisonai mcp logout tavily --yes
"""
output = get_output_controller()
try:
from praisonaiagents.mcp import MCPAuthStorage
auth_storage = MCPAuthStorage()
# Check if there are credentials to remove
entry = auth_storage.get(name)
if not entry or not entry.get("tokens"):
output.print_info(f"No credentials stored for: {name}")
return
if not confirm:
confirmed = typer.confirm(f"Remove OAuth credentials for '{name}'?")
if not confirmed:
output.print_info("Cancelled")
raise typer.Exit(0)
# Remove credentials
auth_storage.remove(name)
if output.is_json_mode:
output.print_json({"name": name, "status": "logged_out"})
else:
output.print_success(f"Removed OAuth credentials for: {name}")
except ImportError as e:
output.print_error(f"MCP auth modules not available: {e}")
raise typer.Exit(1)
except Exception as e:
output.print_error(f"Logout failed: {e}")
raise typer.Exit(1)
@app.callback(invoke_without_command=True)
def mcp_callback(ctx: typer.Context):
"""Show MCP help or list servers."""
if ctx.invoked_subcommand is None:
mcp_list(json_output=False)
+321 -15
View File
@@ -1,20 +1,326 @@
"""Backward-compatibility shim for :mod:`praisonai.cli.commands.n8n`.
"""
n8n CLI Commands
The implementation moved to :mod:`praisonai_code.cli.commands.n8n` as part
of the praisonai-code extraction (issue #2516 / parent #2512).
This shim aliases the moved module into ``sys.modules`` under the old dotted
path so that:
* ``from praisonai.cli.commands.n8n import X`` keeps working, and
* ``unittest.mock.patch("praisonai.cli.commands.n8n.X")`` patches the very
same module object that the implementation executes against.
CLI commands for n8n workflow integration.
"""
import sys as _sys
import typer
import logging
from pathlib import Path
from typing import Optional
from praisonai_code.cli.commands import n8n as _impl
logger = logging.getLogger(__name__)
# Make the old dotted path resolve to the exact same module object so that
# attribute patching / monkeypatching stays transparent across both paths.
_sys.modules[__name__] = _impl
app = typer.Typer(
name="n8n",
help="n8n visual workflow editor integration commands",
no_args_is_help=True,
rich_markup_mode="rich"
)
@app.command()
def export(
yaml_path: Path = typer.Argument(..., help="Path to YAML workflow file"),
output: Optional[Path] = typer.Option(None, "--output", "-o", help="Output JSON file path"),
format: str = typer.Option("n8n", "--format", help="Export format (currently only n8n supported)")
):
"""Export PraisonAI YAML workflow to n8n JSON format.
Example:
praisonai n8n export my-workflow.yaml --output workflow.json
"""
if format != 'n8n':
typer.echo(f"Error: Unsupported format '{format}'. Only 'n8n' is supported.", err=True)
raise typer.Exit(1)
try:
from praisonai.n8n import YAMLToN8nConverter
import yaml as yaml_lib
import json
# Load YAML workflow
with open(yaml_path, 'r') as f:
yaml_workflow = yaml_lib.safe_load(f)
# Convert to n8n format
converter = YAMLToN8nConverter()
n8n_json = converter.convert(yaml_workflow)
# Determine output path
if output is None:
output = yaml_path.with_suffix('.json')
# Write JSON file
with open(output, 'w') as f:
json.dump(n8n_json, f, indent=2)
typer.echo(f"✅ Exported workflow to: {output}")
typer.echo(f"💡 Import this file into n8n or use 'praisonai n8n preview {yaml_path}' to open directly")
except ImportError:
typer.echo("Error: n8n dependencies not installed. Run: pip install 'praisonai[n8n]'", err=True)
raise typer.Exit(1)
except Exception as e:
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(1)
@app.command(name="import")
def import_workflow(
json_path: Path = typer.Argument(..., help="Path to n8n JSON workflow file"),
output: Optional[Path] = typer.Option(None, "--output", "-o", help="Output YAML file path"),
format: str = typer.Option("n8n", "--format", help="Import format (currently only n8n supported)")
):
"""Import n8n JSON workflow to PraisonAI YAML format.
Example:
praisonai n8n import workflow.json --output my-workflow.yaml
"""
if format != 'n8n':
typer.echo(f"Error: Unsupported format '{format}'. Only 'n8n' is supported.", err=True)
raise typer.Exit(1)
try:
from praisonai.n8n import N8nToYAMLConverter
import yaml as yaml_lib
import json
# Load n8n JSON workflow
with open(json_path, 'r') as f:
n8n_workflow = json.load(f)
# Convert to YAML format
converter = N8nToYAMLConverter()
yaml_workflow = converter.convert(n8n_workflow)
# Determine output path
if output is None:
output = json_path.with_suffix('.yaml')
# Write YAML file
with open(output, 'w') as f:
yaml_lib.dump(yaml_workflow, f, default_flow_style=False, sort_keys=False)
typer.echo(f"✅ Imported workflow to: {output}")
typer.echo(f"💡 Run 'praisonai run {output}' to execute the workflow")
except ImportError:
typer.echo("Error: n8n dependencies not installed. Run: pip install 'praisonai[n8n]'", err=True)
raise typer.Exit(1)
except Exception as e:
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(1)
@app.command()
def preview(
yaml_path: Path = typer.Argument(..., help="Path to YAML workflow file"),
n8n_url: str = typer.Option("http://localhost:5678", "--n8n-url", help="n8n instance URL"),
api_key: Optional[str] = typer.Option(None, "--api-key", help="n8n API key (or set N8N_API_KEY env var)"),
no_open: bool = typer.Option(False, "--no-open", help="Do not automatically open browser")
):
"""Preview PraisonAI workflow in n8n visual editor.
This command converts your YAML workflow to n8n format and opens it
in the n8n visual editor for preview and editing.
Example:
praisonai n8n preview my-workflow.yaml
praisonai n8n preview my-workflow.yaml --n8n-url http://n8n.example.com
"""
try:
from praisonai.n8n import preview_workflow
# Preview workflow in n8n
editor_url = preview_workflow(
yaml_path=str(yaml_path),
n8n_url=n8n_url,
api_key=api_key,
auto_open=not no_open
)
typer.echo(f"✅ Workflow created in n8n")
typer.echo(f"🌐 Editor URL: {editor_url}")
if no_open:
typer.echo(f"💡 Open the URL above to view/edit your workflow visually")
else:
typer.echo(f"💡 Browser should open automatically. If not, visit the URL above")
except ImportError:
typer.echo("Error: n8n dependencies not installed. Run: pip install 'praisonai[n8n]'", err=True)
raise typer.Exit(1)
except FileNotFoundError as e:
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(1)
except ConnectionError as e:
typer.echo(f"Connection Error: {e}", err=True)
typer.echo("💡 Make sure n8n is running. Start with: npx n8n start")
raise typer.Exit(1)
except Exception as e:
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(1)
@app.command()
def pull(
workflow_id: str = typer.Argument(..., help="n8n workflow ID"),
output_path: Path = typer.Argument(..., help="Path where to save YAML file"),
n8n_url: str = typer.Option("http://localhost:5678", "--n8n-url", help="n8n instance URL"),
api_key: Optional[str] = typer.Option(None, "--api-key", help="n8n API key (or set N8N_API_KEY env var)")
):
"""Pull workflow from n8n and convert to YAML.
Example:
praisonai n8n pull abc123 my-workflow.yaml
"""
try:
from praisonai.n8n import export_from_n8n
# Export from n8n to YAML
export_from_n8n(
workflow_id=workflow_id,
output_path=str(output_path),
n8n_url=n8n_url,
api_key=api_key
)
typer.echo(f"✅ Pulled workflow {workflow_id} to: {output_path}")
typer.echo(f"💡 Run 'praisonai run {output_path}' to execute the workflow")
except ImportError:
typer.echo("Error: n8n dependencies not installed. Run: pip install 'praisonai[n8n]'", err=True)
raise typer.Exit(1)
except ConnectionError as e:
typer.echo(f"Connection Error: {e}", err=True)
typer.echo("💡 Make sure n8n is running and accessible")
raise typer.Exit(1)
except Exception as e:
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(1)
@app.command()
def push(
yaml_path: Path = typer.Argument(..., help="Path to YAML workflow file"),
workflow_id: str = typer.Argument(..., help="Existing n8n workflow ID to update"),
n8n_url: str = typer.Option("http://localhost:5678", "--n8n-url", help="n8n instance URL"),
api_key: Optional[str] = typer.Option(None, "--api-key", help="n8n API key (or set N8N_API_KEY env var)")
):
"""Push YAML workflow updates to existing n8n workflow.
Example:
praisonai n8n push my-workflow.yaml abc123
"""
try:
from praisonai.n8n import sync_workflow
# Sync workflow with n8n
editor_url = sync_workflow(
yaml_path=str(yaml_path),
workflow_id=workflow_id,
n8n_url=n8n_url,
api_key=api_key
)
typer.echo(f"✅ Synced workflow {workflow_id}")
typer.echo(f"🌐 Editor URL: {editor_url}")
except ImportError:
typer.echo("Error: n8n dependencies not installed. Run: pip install 'praisonai[n8n]'", err=True)
raise typer.Exit(1)
except FileNotFoundError as e:
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(1)
except ConnectionError as e:
typer.echo(f"Connection Error: {e}", err=True)
typer.echo("💡 Make sure n8n is running and accessible")
raise typer.Exit(1)
except Exception as e:
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(1)
@app.command(name="test")
def test_connection(
n8n_url: str = typer.Option("http://localhost:5678", "--n8n-url", help="n8n instance URL"),
api_key: Optional[str] = typer.Option(None, "--api-key", help="n8n API key (or set N8N_API_KEY env var)")
):
"""Test connection to n8n instance.
Example:
praisonai n8n test
praisonai n8n test --n8n-url http://n8n.example.com
"""
client = None
try:
from praisonai.n8n import N8nClient
client = N8nClient(base_url=n8n_url, api_key=api_key)
if client.test_connection():
typer.echo(f"✅ Connected to n8n at {n8n_url}")
# Try to list workflows to test API access
try:
workflows = client.list_workflows()
typer.echo(f"📊 Found {len(workflows)} workflows")
except Exception as e:
typer.echo(f"⚠️ Connection successful but API access failed: {e}")
typer.echo("💡 Check your API key or n8n permissions")
else:
typer.echo(f"❌ Cannot connect to n8n at {n8n_url}")
typer.echo("💡 Make sure n8n is running. Start with: npx n8n start")
raise typer.Exit(1)
except typer.Exit:
raise
except ImportError:
typer.echo("Error: n8n dependencies not installed. Run: pip install 'praisonai[n8n]'", err=True)
raise typer.Exit(1)
except Exception as e:
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(1)
finally:
if client is not None:
client.close()
@app.command(name="list")
def list_workflows(
n8n_url: str = typer.Option("http://localhost:5678", "--n8n-url", help="n8n instance URL"),
api_key: Optional[str] = typer.Option(None, "--api-key", help="n8n API key (or set N8N_API_KEY env var)")
):
"""List workflows in n8n instance.
Example:
praisonai n8n list
"""
client = None
try:
from praisonai.n8n import N8nClient
client = N8nClient(base_url=n8n_url, api_key=api_key)
workflows = client.list_workflows()
if not workflows:
typer.echo("No workflows found in n8n")
else:
typer.echo(f"Found {len(workflows)} workflows:")
typer.echo()
for workflow in workflows:
workflow_id = workflow.get('id', 'Unknown')
name = workflow.get('name', 'Untitled')
active = workflow.get('active', False)
status = "✅ Active" if active else "⏸️ Inactive"
typer.echo(f" {workflow_id}: {name} ({status})")
except ImportError:
typer.echo("Error: n8n dependencies not installed. Run: pip install 'praisonai[n8n]'", err=True)
raise typer.Exit(1)
except ConnectionError as e:
typer.echo(f"Connection Error: {e}", err=True)
typer.echo("💡 Make sure n8n is running and accessible")
raise typer.Exit(1)
except Exception as e:
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(1)
finally:
if client is not None:
client.close()
+461 -15
View File
@@ -1,20 +1,466 @@
"""Backward-compatibility shim for :mod:`praisonai.cli.commands.profile`.
"""
Profile command group for PraisonAI CLI.
The implementation moved to :mod:`praisonai_code.cli.commands.profile` as part
of the praisonai-code extraction (issue #2516 / parent #2512).
This shim aliases the moved module into ``sys.modules`` under the old dotted
path so that:
* ``from praisonai.cli.commands.profile import X`` keeps working, and
* ``unittest.mock.patch("praisonai.cli.commands.profile.X")`` patches the very
same module object that the implementation executes against.
Provides detailed cProfile-based profiling for query execution.
"""
import sys as _sys
import sys
from typing import Optional
from praisonai_code.cli.commands import profile as _impl
import typer
# Make the old dotted path resolve to the exact same module object so that
# attribute patching / monkeypatching stays transparent across both paths.
_sys.modules[__name__] = _impl
app = typer.Typer(help="Performance profiling and diagnostics")
@app.command("query")
def profile_query(
prompt: str = typer.Argument(..., help="Prompt to profile"),
model: Optional[str] = typer.Option(None, "--model", "-m", help="Model to use"),
stream: bool = typer.Option(False, "--stream/--no-stream", help="Use streaming mode"),
deep: bool = typer.Option(False, "--deep", help="Enable deep call tracing (higher overhead)"),
limit: int = typer.Option(30, "--limit", "-n", help="Top N functions to show"),
sort: str = typer.Option("cumulative", "--sort", "-s", help="Sort by: cumulative or tottime"),
show_callers: bool = typer.Option(False, "--show-callers", help="Show caller functions"),
show_callees: bool = typer.Option(False, "--show-callees", help="Show callee functions"),
save: Optional[str] = typer.Option(None, "--save", help="Save artifacts to path (creates .prof, .txt, .json)"),
output_format: str = typer.Option("text", "--format", "-f", help="Output format: text or json"),
):
"""
Profile a query execution with detailed timing breakdown.
Uses the unified profiling architecture for consistent results across
CLI direct invocation (--profile) and this command.
Shows per-function timing, call graphs, and latency metrics.
Examples:
praisonai profile query "What is 2+2?"
praisonai profile query "Hello" --limit 20
praisonai profile query "Test" --deep --show-callers --show-callees
praisonai profile query "Test" --save ./profile_results
"""
# Use unified profiler for consistent results
try:
from praisonai.cli.execution import ExecutionRequest, Profiler, ProfilerConfig as UnifiedProfilerConfig
except ImportError as e:
typer.echo(f"Error: Unified profiler not available: {e}", err=True)
raise typer.Exit(1)
# Warn about deep tracing overhead
if deep:
typer.echo("⚠️ Deep call tracing enabled - this adds significant overhead", err=True)
# Create unified execution request
request = ExecutionRequest(
prompt=prompt,
agent_name="ProfiledAgent",
model=model,
stream=stream,
)
# Create unified profiler config
config = UnifiedProfilerConfig(
layer=2 if deep else 1,
limit=limit,
sort_by=sort,
show_callers=show_callers,
show_callees=show_callees,
output_format=output_format,
save_path=save,
)
# Run profiler
typer.echo("🔬 Starting profiled execution...", err=True)
try:
profiler = Profiler(config)
result, report = profiler.profile_sync(request, invocation_method="profile_command")
except ImportError as e:
typer.echo(f"Error: {e}", err=True)
typer.echo("Install praisonaiagents: pip install praisonaiagents", err=True)
raise typer.Exit(1)
except Exception as e:
typer.echo(f"Error during profiling: {e}", err=True)
raise typer.Exit(1)
# Output results
if output_format == "json":
typer.echo(report.to_json())
else:
typer.echo(report.to_text())
# Save artifacts if requested
if save:
try:
import os
os.makedirs(save, exist_ok=True)
# Save JSON report
json_path = os.path.join(save, "profile_report.json")
with open(json_path, 'w') as f:
f.write(report.to_json())
# Save text report
txt_path = os.path.join(save, "profile_report.txt")
with open(txt_path, 'w') as f:
f.write(report.to_text())
typer.echo("\n✅ Artifacts saved:", err=True)
typer.echo(f" {json_path}", err=True)
typer.echo(f" {txt_path}", err=True)
except Exception as e:
typer.echo(f"Warning: Failed to save artifacts: {e}", err=True)
@app.command("imports")
def profile_imports():
"""
Profile module import times.
Shows which modules take longest to import, useful for optimizing startup time.
"""
import subprocess
import re
typer.echo("🔬 Profiling import times...", err=True)
try:
result = subprocess.run(
[sys.executable, "-X", "importtime", "-c", "import praisonaiagents"],
capture_output=True,
text=True,
timeout=60,
)
# Parse import times
import_times = []
for line in result.stderr.split('\n'):
if 'import time:' in line:
match = re.search(r'import time:\s+(\d+)\s+\|\s+(\d+)\s+\|\s+(.+)', line)
if match:
self_time = int(match.group(1))
cumulative = int(match.group(2))
module = match.group(3).strip()
import_times.append((module, self_time, cumulative))
# Sort by cumulative time
import_times.sort(key=lambda x: x[2], reverse=True)
# Display
typer.echo("\n" + "=" * 70)
typer.echo("Import Time Analysis")
typer.echo("=" * 70)
typer.echo(f"{'Module':<45} {'Self (μs)':>10} {'Cumul (μs)':>12}")
typer.echo("-" * 70)
for module, self_time, cumulative in import_times[:30]:
module_name = module
if len(module_name) > 43:
module_name = "..." + module_name[-40:]
typer.echo(f"{module_name:<45} {self_time:>10} {cumulative:>12}")
typer.echo("-" * 70)
if import_times:
total_time = max(t[2] for t in import_times)
typer.echo(f"Total import time: {total_time / 1000:.2f} ms")
except subprocess.TimeoutExpired:
typer.echo("Error: Import profiling timed out", err=True)
raise typer.Exit(1)
except Exception as e:
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(1)
@app.command("startup")
def profile_startup():
"""
Profile CLI startup time.
Measures time from CLI invocation to ready state.
"""
import time
import subprocess
typer.echo("🔬 Profiling startup time...", err=True)
# Measure cold start (new process)
start = time.perf_counter()
subprocess.run(
[sys.executable, "-c", "import praisonai; import praisonai.cli"],
capture_output=True,
text=True,
)
cold_start = (time.perf_counter() - start) * 1000
# Measure warm start (imports cached)
start = time.perf_counter()
subprocess.run(
[sys.executable, "-c", "import praisonai; import praisonai.cli"],
capture_output=True,
text=True,
)
warm_start = (time.perf_counter() - start) * 1000
typer.echo("\n" + "=" * 50)
typer.echo("Startup Time Analysis")
typer.echo("=" * 50)
typer.echo(f"Cold Start: {cold_start:>10.2f} ms")
typer.echo(f"Warm Start: {warm_start:>10.2f} ms")
typer.echo("=" * 50)
if cold_start > 1000:
typer.echo("\n⚠️ Cold start > 1s - consider lazy imports", err=True)
@app.command("suite")
def profile_suite(
output_dir: Optional[str] = typer.Option("/tmp/praisonai_profile_suite", "--output", "-o", help="Output directory for results"),
iterations: int = typer.Option(3, "--iterations", "-n", help="Iterations per scenario"),
quick: bool = typer.Option(False, "--quick", help="Quick mode (fewer iterations)"),
):
"""
Run a comprehensive profiling suite.
Runs multiple scenarios (streaming/non-streaming, simple/complex prompts)
and produces aggregated statistics and reports.
Examples:
praisonai profile suite
praisonai profile suite --output ./my_profile_results
praisonai profile suite --quick
"""
try:
from praisonai_code.cli.features.profiler import run_profile_suite, ScenarioConfig
except ImportError as e:
typer.echo(f"Error: Profiler module not available: {e}", err=True)
raise typer.Exit(1)
# Define scenarios
iters = 1 if quick else iterations
scenarios = [
ScenarioConfig(name="simple_non_stream", prompt="hi", stream=False, iterations=iters, warmup=0 if quick else 1),
ScenarioConfig(name="simple_stream", prompt="hi", stream=True, iterations=iters, warmup=0 if quick else 1),
]
if not quick:
scenarios.extend([
ScenarioConfig(name="medium_non_stream", prompt="Explain Python in 2 sentences", stream=False, iterations=max(1, iters-1)),
ScenarioConfig(name="medium_stream", prompt="Explain Python in 2 sentences", stream=True, iterations=max(1, iters-1)),
])
typer.echo("🔬 Running Profile Suite...", err=True)
typer.echo(f" Output: {output_dir}", err=True)
typer.echo(f" Scenarios: {len(scenarios)}", err=True)
typer.echo(f" Iterations: {iterations}", err=True)
try:
result = run_profile_suite(
output_dir=output_dir,
scenarios=scenarios,
verbose=True,
)
# Print summary
typer.echo("\n" + "=" * 60)
typer.echo("Profile Suite Summary")
typer.echo("=" * 60)
typer.echo(f"Startup Cold: {result.startup_cold_ms:.2f}ms")
typer.echo(f"Startup Warm: {result.startup_warm_ms:.2f}ms")
if result.import_analysis:
typer.echo(f"\nTop Import: {result.import_analysis[0]['module']}")
typer.echo(f" Time: {result.import_analysis[0]['cumulative_ms']:.2f}ms")
typer.echo("\nScenario Results:")
for scenario in result.scenarios:
stats = scenario.get_stats(scenario.total_times)
typer.echo(f" {scenario.name}: {stats['mean']:.2f}ms (±{stats['stdev']:.2f}ms)")
typer.echo(f"\n✅ Full results saved to: {output_dir}")
except Exception as e:
typer.echo(f"Error running suite: {e}", err=True)
raise typer.Exit(1)
@app.command("snapshot")
def profile_snapshot(
name: str = typer.Argument("current", help="Name for the snapshot"),
baseline: bool = typer.Option(False, "--baseline", "-b", help="Save as baseline for comparison"),
compare: bool = typer.Option(False, "--compare", "-c", help="Compare against baseline"),
output_format: str = typer.Option("text", "--format", "-f", help="Output format: text or json"),
):
"""
Create or compare performance snapshots.
Snapshots record timing data for baseline comparison.
Use --baseline to save a reference point, then --compare to check for regressions.
Examples:
praisonai profile snapshot --baseline
praisonai profile snapshot current --compare
praisonai profile snapshot v2.0 --format json
"""
try:
from praisonai_code.cli.features.profiler import (
run_profile_suite,
ScenarioConfig,
PerfSnapshotManager,
create_snapshot_from_suite,
format_comparison_report,
)
except ImportError as e:
typer.echo(f"Error: Profiler module not available: {e}", err=True)
raise typer.Exit(1)
manager = PerfSnapshotManager()
if compare:
# Load baseline and compare
baseline_snap = manager.load_baseline()
if not baseline_snap:
typer.echo("❌ No baseline found. Create one with: praisonai profile snapshot --baseline", err=True)
raise typer.Exit(1)
typer.echo("🔬 Running current profile for comparison...", err=True)
# Run quick suite
scenarios = [
ScenarioConfig(name="simple", prompt="hi", stream=False, iterations=2, warmup=1),
]
result = run_profile_suite(
output_dir="/tmp/praisonai_snapshot_compare",
scenarios=scenarios,
output="minimal",
)
current_snap = create_snapshot_from_suite(result, name)
comparison = manager.compare(current_snap)
if output_format == "json":
import json
typer.echo(json.dumps(comparison.to_dict(), indent=2))
else:
typer.echo(format_comparison_report(comparison))
if comparison.is_regression():
raise typer.Exit(1) # Exit with error on regression
else:
# Create new snapshot
typer.echo(f"🔬 Creating performance snapshot '{name}'...", err=True)
scenarios = [
ScenarioConfig(name="simple", prompt="hi", stream=False, iterations=3, warmup=1),
ScenarioConfig(name="stream", prompt="hi", stream=True, iterations=2, warmup=1),
]
result = run_profile_suite(
output_dir="/tmp/praisonai_snapshot",
scenarios=scenarios,
verbose=True,
)
snapshot = create_snapshot_from_suite(result, name)
if baseline:
path = manager.save_baseline(snapshot)
typer.echo(f"\n✅ Baseline saved to: {path}")
else:
path = manager.save(snapshot)
typer.echo(f"\n✅ Snapshot saved to: {path}")
if output_format == "json":
import json
typer.echo(json.dumps(snapshot.to_dict(), indent=2))
else:
typer.echo("\nSnapshot Summary:")
typer.echo(f" Startup Cold: {snapshot.startup_cold_ms:.2f}ms")
typer.echo(f" Import Time: {snapshot.import_time_ms:.2f}ms")
typer.echo(f" Query Time: {snapshot.query_time_ms:.2f}ms")
@app.command("optimize")
def profile_optimize(
prewarm: bool = typer.Option(False, "--prewarm", help="Enable provider pre-warming"),
lite: bool = typer.Option(False, "--lite", help="Enable lite mode (skip type validation)"),
show_config: bool = typer.Option(False, "--show", help="Show current optimization config"),
):
"""
Configure performance optimizations.
All optimizations are opt-in and safe by default.
Examples:
praisonai profile optimize --show
praisonai profile optimize --prewarm
praisonai profile optimize --lite
"""
try:
from praisonai_code.cli.features.profiler import (
PrewarmManager,
get_lite_mode_config,
get_provider_cache,
)
except ImportError as e:
typer.echo(f"Error: Profiler module not available: {e}", err=True)
raise typer.Exit(1)
if show_config:
typer.echo("=" * 50)
typer.echo("Performance Optimization Status")
typer.echo("=" * 50)
lite_config = get_lite_mode_config()
typer.echo(f"\nLite Mode: {'Enabled' if lite_config.enabled else 'Disabled'}")
if lite_config.enabled:
typer.echo(f" Skip Type Validation: {lite_config.skip_type_validation}")
typer.echo(f" Skip Model Validation: {lite_config.skip_model_validation}")
typer.echo(f" Minimal Imports: {lite_config.minimal_imports}")
typer.echo(f"\nPre-warming: {'Enabled' if PrewarmManager.is_enabled() else 'Disabled'}")
cache = get_provider_cache()
stats = cache.get_stats()
typer.echo("\nProvider Cache:")
typer.echo(f" Hits: {stats['hits']}")
typer.echo(f" Misses: {stats['misses']}")
typer.echo(f" Size: {stats['size']}")
typer.echo("\n" + "=" * 50)
typer.echo("\nTo enable optimizations, set environment variables:")
typer.echo(" PRAISONAI_LITE_MODE=1")
typer.echo(" PRAISONAI_SKIP_TYPE_VALIDATION=1")
typer.echo(" PRAISONAI_MINIMAL_IMPORTS=1")
return
if prewarm:
typer.echo("🔥 Enabling provider pre-warming...")
PrewarmManager.enable()
import os
if os.environ.get('OPENAI_API_KEY'):
typer.echo(" Pre-warming OpenAI...")
PrewarmManager.prewarm_provider('openai')
if os.environ.get('ANTHROPIC_API_KEY'):
typer.echo(" Pre-warming Anthropic...")
PrewarmManager.prewarm_provider('anthropic')
typer.echo("✅ Pre-warming initiated (runs in background)")
if lite:
typer.echo("⚡ Lite mode configuration:")
typer.echo(" Set these environment variables to enable:")
typer.echo(" export PRAISONAI_LITE_MODE=1")
typer.echo(" export PRAISONAI_SKIP_TYPE_VALIDATION=1")
typer.echo(" export PRAISONAI_MINIMAL_IMPORTS=1")
if not prewarm and not lite and not show_config:
typer.echo("Use --show to see current config, or --prewarm/--lite to enable optimizations")
@app.callback(invoke_without_command=True)
def profile_callback(ctx: typer.Context):
"""Show profile help if no subcommand."""
if ctx.invoked_subcommand is None:
typer.echo(ctx.get_help())
+907 -14
View File
@@ -1,20 +1,913 @@
"""Backward-compatibility shim for :mod:`praisonai.cli.commands.rag`.
"""
RAG command group for PraisonAI CLI.
The implementation moved to :mod:`praisonai_code.cli.commands.rag` as part
of the praisonai-code extraction (issue #2516 / parent #2512).
Provides RAG (Retrieval Augmented Generation) commands:
- index: Build/update an index from sources
- query: One-shot question -> answer with citations
- chat: Interactive RAG chat loop
- eval: Evaluate retrieval quality
- serve: Start RAG microservice (optional)
This shim aliases the moved module into ``sys.modules`` under the old dotted
path so that:
* ``from praisonai.cli.commands.rag import X`` keeps working, and
* ``unittest.mock.patch("praisonai.cli.commands.rag.X")`` patches the very
same module object that the implementation executes against.
All commands support --profile for performance profiling using the existing
praisonai profile infrastructure.
"""
import sys as _sys
import typer
from typing import Optional, List
from pathlib import Path
from contextlib import contextmanager
import time
import json as json_module
from praisonai_code.cli.commands import rag as _impl
app = typer.Typer(help="RAG (Retrieval Augmented Generation) commands")
# Make the old dotted path resolve to the exact same module object so that
# attribute patching / monkeypatching stays transparent across both paths.
_sys.modules[__name__] = _impl
@contextmanager
def rag_profiler(enabled: bool, profile_out: Optional[Path], profile_top: int = 20):
"""
Context manager for RAG command profiling.
Uses the existing praisonai.profiler infrastructure when available,
falls back to basic timing if not.
"""
if not enabled:
yield None
return
import sys
start_time = time.perf_counter()
start_modules = set(sys.modules.keys())
# Try to use the full profiler
profiler = None
try:
from praisonai.profiler import Profiler
Profiler.enable()
Profiler.clear()
profiler = Profiler
except ImportError:
pass
# Try tracemalloc for memory
try:
import tracemalloc
tracemalloc.start()
except Exception:
tracemalloc = None
profile_data = {
"command": "rag",
"start_time": time.time(),
"metrics": {},
"imports": [],
"top_functions": [],
}
try:
yield profile_data
finally:
end_time = time.perf_counter()
elapsed_ms = (end_time - start_time) * 1000
# Collect metrics
profile_data["metrics"]["wall_time_ms"] = elapsed_ms
profile_data["metrics"]["wall_time_s"] = elapsed_ms / 1000
# Memory usage
if tracemalloc:
try:
current, peak = tracemalloc.get_traced_memory()
tracemalloc.stop()
profile_data["metrics"]["peak_memory_mb"] = peak / (1024 * 1024)
profile_data["metrics"]["current_memory_mb"] = current / (1024 * 1024)
except Exception:
pass
# New imports during execution
end_modules = set(sys.modules.keys())
new_modules = end_modules - start_modules
profile_data["imports"] = sorted(list(new_modules))[:profile_top]
profile_data["metrics"]["modules_imported"] = len(new_modules)
# Get profiler data if available
if profiler:
try:
profiler.disable()
stats = profiler.get_statistics()
if stats:
profile_data["top_functions"] = stats.get("top_functions", [])[:profile_top]
profile_data["metrics"].update(stats.get("summary", {}))
except Exception:
pass
# Save to file if requested
if profile_out:
try:
profile_out.parent.mkdir(parents=True, exist_ok=True)
with open(profile_out, "w") as f:
json_module.dump(profile_data, f, indent=2, default=str)
except Exception as e:
print(f"Warning: Failed to save profile: {e}")
# Print summary
from rich.console import Console
console = Console(stderr=True)
console.print(f"\n[dim]Profile: {elapsed_ms:.2f}ms wall time[/dim]")
if "peak_memory_mb" in profile_data["metrics"]:
console.print(f"[dim]Profile: {profile_data['metrics']['peak_memory_mb']:.2f}MB peak memory[/dim]")
console.print(f"[dim]Profile: {len(new_modules)} modules imported[/dim]")
if profile_out:
console.print(f"[dim]Profile saved to: {profile_out}[/dim]")
@app.command("index")
def rag_index(
sources: List[str] = typer.Argument(..., help="Source files, directories, or URLs to index"),
collection: str = typer.Option("default", "--collection", "-c", help="Collection/index name"),
chunking: str = typer.Option("recursive", "--chunking", help="Chunking strategy: token, sentence, recursive, semantic"),
chunk_size: int = typer.Option(512, "--chunk-size", help="Chunk size in tokens"),
config: Optional[Path] = typer.Option(None, "--config", "-f", help="Config file path"),
verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
profile: bool = typer.Option(False, "--profile", help="Enable performance profiling"),
profile_out: Optional[Path] = typer.Option(None, "--profile-out", help="Save profile to JSON file"),
profile_top: int = typer.Option(20, "--profile-top", help="Top N items in profile"),
):
"""
Index documents for RAG retrieval.
This command uses the Knowledge substrate for indexing.
Equivalent to: praisonai knowledge index
Examples:
praisonai rag index ./docs
praisonai rag index paper.pdf --collection research
praisonai rag index ./data --chunking semantic --chunk-size 256
praisonai rag index ./docs --profile --profile-out ./profile.json
"""
from rich.console import Console
from rich.progress import Progress, SpinnerColumn, TextColumn
console = Console()
with rag_profiler(profile, profile_out, profile_top) as profile_data:
try:
# Lazy import to avoid startup cost
from praisonaiagents.knowledge import Knowledge
# Build config - use unified knowledge path
knowledge_config = {
"vector_store": {
"provider": "chroma",
"config": {
"collection_name": collection,
"path": f"./.praison/knowledge/{collection}",
}
}
}
# Load config file if provided
if config and config.exists():
import yaml
with open(config) as f:
file_config = yaml.safe_load(f)
if "knowledge" in file_config:
knowledge_config.update(file_config["knowledge"])
# Initialize Knowledge
knowledge = Knowledge(config=knowledge_config, verbose=verbose)
# Index sources
with Progress(
SpinnerColumn(),
TextColumn("[progress.description]{task.description}"),
console=console,
transient=True,
) as progress:
for source in sources:
task = progress.add_task(f"Indexing {source}...", total=None)
try:
result = knowledge.add(source)
count = len(result.get("results", [])) if isinstance(result, dict) else 0
console.print(f"[green]✓[/green] Indexed {source}: {count} chunks")
except Exception as e:
console.print(f"[red]✗[/red] Failed to index {source}: {e}")
progress.remove_task(task)
console.print(f"\n[bold green]Indexing complete![/bold green] Collection: {collection}")
if profile_data:
profile_data["command"] = "rag index"
profile_data["collection"] = collection
profile_data["sources"] = sources
except ImportError as e:
console.print(f"[red]Error:[/red] Missing dependency: {e}")
console.print("Install with: pip install 'praisonaiagents[knowledge]'")
raise typer.Exit(1)
except Exception as e:
console.print(f"[red]Error:[/red] {e}")
raise typer.Exit(1)
@app.command("query")
def rag_query(
question: str = typer.Argument(..., help="Question to answer"),
collection: str = typer.Option("default", "--collection", "-c", help="Collection to query"),
top_k: int = typer.Option(5, "--top-k", "-k", help="Number of results to retrieve"),
hybrid: bool = typer.Option(False, "--hybrid", help="Use hybrid retrieval (dense + BM25)"),
rerank: bool = typer.Option(False, "--rerank", help="Enable reranking of results"),
citations: bool = typer.Option(True, "--citations/--no-citations", help="Include citations"),
config: Optional[Path] = typer.Option(None, "--config", "-f", help="Config file path"),
verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
profile: bool = typer.Option(False, "--profile", help="Enable performance profiling"),
profile_out: Optional[Path] = typer.Option(None, "--profile-out", help="Save profile to JSON file"),
profile_top: int = typer.Option(20, "--profile-top", help="Top N items in profile"),
):
"""
Query the RAG system with a question.
Examples:
praisonai rag query "What is the main finding?"
praisonai rag query "Summarize the document" --collection research
praisonai rag query "Key points?" --top-k 10 --no-citations
praisonai rag query "Summary?" --hybrid --rerank
praisonai rag query "Summary?" --profile --profile-out ./profile.json
"""
from rich.console import Console
from rich.panel import Panel
from rich.markdown import Markdown
console = Console()
with rag_profiler(profile, profile_out, profile_top) as profile_data:
try:
from praisonaiagents.knowledge import Knowledge
from praisonaiagents.rag import RAG, RAGConfig
from praisonaiagents.rag.models import RetrievalStrategy
# Build config - use unified knowledge path
knowledge_config = {
"vector_store": {
"provider": "chroma",
"config": {
"collection_name": collection,
"path": f"./.praison/knowledge/{collection}",
}
}
}
# Add hybrid retrieval config if enabled
if hybrid:
knowledge_config["retrieval"] = {
"strategy": "hybrid",
}
# Load config file if provided
if config and config.exists():
import yaml
with open(config) as f:
file_config = yaml.safe_load(f)
if "knowledge" in file_config:
knowledge_config.update(file_config["knowledge"])
# Initialize
knowledge = Knowledge(config=knowledge_config, verbose=verbose)
# Determine retrieval strategy
retrieval_strategy = RetrievalStrategy.HYBRID if hybrid else RetrievalStrategy.BASIC
rag_config = RAGConfig(
top_k=top_k,
include_citations=citations,
retrieval_strategy=retrieval_strategy,
rerank=rerank,
)
rag = RAG(knowledge=knowledge, config=rag_config)
# Query
if verbose:
strategy_str = "hybrid (dense + BM25)" if hybrid else "dense"
rerank_str = " with reranking" if rerank else ""
console.print(f"[dim]Querying collection '{collection}' using {strategy_str}{rerank_str}, top_k={top_k}...[/dim]")
result = rag.query(question)
# Display answer
console.print(Panel(
Markdown(result.answer),
title="[bold]Answer[/bold]",
border_style="green",
))
# Display citations
if citations and result.citations:
console.print("\n[bold]Sources:[/bold]")
for citation in result.citations:
score_str = f"[dim](score: {citation.score:.2f})[/dim]" if citation.score else ""
console.print(f" [{citation.id}] {citation.source} {score_str}")
if verbose:
snippet = citation.text[:200] + "..." if len(citation.text) > 200 else citation.text
console.print(f" [dim]{snippet}[/dim]")
# Show metadata if verbose
if verbose:
console.print(f"\n[dim]Elapsed: {result.metadata.get('elapsed_seconds', 0):.2f}s[/dim]")
if profile_data:
profile_data["command"] = "rag query"
profile_data["collection"] = collection
profile_data["question"] = question
except ImportError as e:
console.print(f"[red]Error:[/red] Missing dependency: {e}")
console.print("Install with: pip install 'praisonaiagents[knowledge]'")
raise typer.Exit(1)
except Exception as e:
console.print(f"[red]Error:[/red] {e}")
if verbose:
import traceback
console.print(traceback.format_exc())
raise typer.Exit(1)
@app.command("chat")
def rag_chat(
collection: str = typer.Option("default", "--collection", "-c", help="Collection to chat with"),
top_k: int = typer.Option(5, "--top-k", "-k", help="Number of results per query"),
hybrid: bool = typer.Option(False, "--hybrid", help="Use hybrid retrieval (dense + BM25)"),
rerank: bool = typer.Option(False, "--rerank", help="Enable reranking of results"),
config: Optional[Path] = typer.Option(None, "--config", "-f", help="Config file path"),
stream: bool = typer.Option(True, "--stream/--no-stream", help="Stream responses"),
):
"""
Interactive RAG chat session.
Examples:
praisonai rag chat
praisonai rag chat --collection research
praisonai rag chat --hybrid --rerank
praisonai rag chat --no-stream
"""
from rich.console import Console
from rich.panel import Panel
from rich.markdown import Markdown
console = Console()
try:
from praisonaiagents.knowledge import Knowledge
from praisonaiagents.rag import RAG, RAGConfig
from praisonaiagents.rag.models import RetrievalStrategy
# Build config - use unified knowledge path
knowledge_config = {
"vector_store": {
"provider": "chroma",
"config": {
"collection_name": collection,
"path": f"./.praison/knowledge/{collection}",
}
}
}
# Add hybrid retrieval config if enabled
if hybrid:
knowledge_config["retrieval"] = {
"strategy": "hybrid",
}
# Load config file if provided
if config and config.exists():
import yaml
with open(config) as f:
file_config = yaml.safe_load(f)
if "knowledge" in file_config:
knowledge_config.update(file_config["knowledge"])
# Initialize
knowledge = Knowledge(config=knowledge_config)
retrieval_strategy = RetrievalStrategy.HYBRID if hybrid else RetrievalStrategy.BASIC
rag_config = RAGConfig(
top_k=top_k,
include_citations=True,
stream=stream,
retrieval_strategy=retrieval_strategy,
rerank=rerank,
)
rag = RAG(knowledge=knowledge, config=rag_config)
console.print(Panel(
f"[bold]RAG Chat[/bold] - Collection: {collection}\n"
"Type your questions. Use 'exit' or Ctrl+C to quit.",
border_style="blue",
))
while True:
try:
question = console.input("\n[bold blue]You:[/bold blue] ").strip()
if not question:
continue
if question.lower() in ("exit", "quit", "q"):
console.print("[dim]Goodbye![/dim]")
break
console.print("\n[bold green]Assistant:[/bold green]")
if stream:
# Stream response
full_response = ""
for chunk in rag.stream(question):
console.print(chunk, end="")
full_response += chunk
console.print() # Newline after streaming
# Get citations separately
citations = rag.get_citations(question)
else:
result = rag.query(question)
console.print(Markdown(result.answer))
citations = result.citations
# Show citations
if citations:
console.print("\n[dim]Sources:[/dim]")
for c in citations[:3]: # Show top 3
console.print(f" [dim][{c.id}] {c.source}[/dim]")
except KeyboardInterrupt:
console.print("\n[dim]Goodbye![/dim]")
break
except Exception as e:
console.print(f"[red]Error:[/red] {e}")
except ImportError as e:
console.print(f"[red]Error:[/red] Missing dependency: {e}")
console.print("Install with: pip install 'praisonaiagents[knowledge]'")
raise typer.Exit(1)
except Exception as e:
console.print(f"[red]Error:[/red] {e}")
raise typer.Exit(1)
@app.command("eval")
def rag_eval(
test_file: Path = typer.Argument(..., help="JSON file with test queries"),
collection: str = typer.Option("default", "--collection", "-c", help="Collection to evaluate"),
top_k: int = typer.Option(5, "--top-k", "-k", help="Number of results to retrieve"),
output: Optional[Path] = typer.Option(None, "--output", "-o", help="Output results to file"),
verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
profile: bool = typer.Option(False, "--profile", help="Enable performance profiling"),
profile_out: Optional[Path] = typer.Option(None, "--profile-out", help="Save profile to JSON file"),
profile_top: int = typer.Option(20, "--profile-top", help="Top N items in profile"),
):
"""
Evaluate RAG retrieval quality.
Test file format (JSON):
[
{"query": "What is X?", "expected_doc": "doc1.pdf", "expected_answer_contains": "answer"},
...
]
Examples:
praisonai rag eval golden_queries.json
praisonai rag eval tests.json --collection research --output results.json
praisonai rag eval tests.json --profile --profile-out ./profile.json
"""
import json
from rich.console import Console
from rich.table import Table
console = Console()
with rag_profiler(profile, profile_out, profile_top) as profile_data:
try:
from praisonaiagents.knowledge import Knowledge
from praisonaiagents.rag import RAG, RAGConfig
# Load test file
if not test_file.exists():
console.print(f"[red]Error:[/red] Test file not found: {test_file}")
raise typer.Exit(1)
with open(test_file) as f:
tests = json.load(f)
if not isinstance(tests, list):
console.print("[red]Error:[/red] Test file must contain a JSON array")
raise typer.Exit(1)
# Build config - use unified knowledge path
knowledge_config = {
"vector_store": {
"provider": "chroma",
"config": {
"collection_name": collection,
"path": f"./.praison/knowledge/{collection}",
}
}
}
# Initialize
knowledge = Knowledge(config=knowledge_config, verbose=verbose)
rag_config = RAGConfig(top_k=top_k, include_citations=True)
rag = RAG(knowledge=knowledge, config=rag_config)
# Run evaluation
results = []
passed = 0
failed = 0
console.print(f"\n[bold]Evaluating {len(tests)} queries...[/bold]\n")
for i, test in enumerate(tests):
query = test.get("query", "")
expected_doc = test.get("expected_doc", "")
expected_contains = test.get("expected_answer_contains", "")
result = rag.query(query)
# Check if expected doc is in citations
doc_found = any(
expected_doc in c.source for c in result.citations
) if expected_doc else True
# Check if answer contains expected text
answer_ok = (
expected_contains.lower() in result.answer.lower()
) if expected_contains else True
test_passed = doc_found and answer_ok
if test_passed:
passed += 1
status = "[green]PASS[/green]"
else:
failed += 1
status = "[red]FAIL[/red]"
results.append({
"query": query,
"passed": test_passed,
"doc_found": doc_found,
"answer_ok": answer_ok,
"answer": result.answer[:200],
"citations": [c.source for c in result.citations],
})
if verbose:
console.print(f" {status} Query: {query[:50]}...")
# Summary table
table = Table(title="Evaluation Results")
table.add_column("Metric", style="bold")
table.add_column("Value")
table.add_row("Total Tests", str(len(tests)))
table.add_row("Passed", f"[green]{passed}[/green]")
table.add_row("Failed", f"[red]{failed}[/red]")
table.add_row("Pass Rate", f"{passed/len(tests)*100:.1f}%")
console.print(table)
# Save results if output specified
if output:
with open(output, "w") as f:
json.dump(results, f, indent=2)
console.print(f"\n[dim]Results saved to {output}[/dim]")
# Exit with error if any failed
if failed > 0:
raise typer.Exit(1)
if profile_data:
profile_data["command"] = "rag eval"
profile_data["collection"] = collection
profile_data["test_file"] = str(test_file)
profile_data["metrics"]["tests_total"] = len(tests)
profile_data["metrics"]["tests_passed"] = passed
profile_data["metrics"]["tests_failed"] = failed
except ImportError as e:
console.print(f"[red]Error:[/red] Missing dependency: {e}")
console.print("Install with: pip install 'praisonaiagents[knowledge]'")
raise typer.Exit(1)
except typer.Exit:
raise
except Exception as e:
console.print(f"[red]Error:[/red] {e}")
if verbose:
import traceback
console.print(traceback.format_exc())
raise typer.Exit(1)
@app.command("serve")
def rag_serve(
collection: str = typer.Option("default", "--collection", "-c", help="Collection to serve"),
host: str = typer.Option("127.0.0.1", "--host", "-h", help="Host to bind"),
port: int = typer.Option(8080, "--port", "-p", help="Port to bind"),
hybrid: bool = typer.Option(False, "--hybrid", help="Use hybrid retrieval (dense + BM25)"),
rerank: bool = typer.Option(False, "--rerank", help="Enable reranking of results"),
openai_compat: bool = typer.Option(False, "--openai-compat", help="Enable OpenAI-compatible /v1/chat/completions endpoint"),
config: Optional[Path] = typer.Option(None, "--config", "-f", help="Config file path"),
verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
profile: bool = typer.Option(False, "--profile", help="Enable performance profiling"),
profile_out: Optional[Path] = typer.Option(None, "--profile-out", help="Save profile to JSON file"),
profile_top: int = typer.Option(20, "--profile-top", help="Top N items in profile"),
):
"""
Start RAG microservice API.
DEPRECATED: Use `praisonai serve rag` instead.
Endpoints:
GET /health - Health check
POST /rag/query - Query with JSON body {"question": "...", "top_k": 5, "hybrid": false}
POST /rag/chat - Streaming chat (SSE)
POST /v1/chat/completions - OpenAI-compatible endpoint (with --openai-compat)
Examples:
praisonai rag serve
praisonai rag serve --collection research --port 9000
praisonai rag serve --hybrid --rerank
praisonai rag serve --openai-compat --port 8080
praisonai rag serve --profile --profile-out ./profile.json
"""
import sys
# Print deprecation warning
print("\n\033[93m⚠ DEPRECATION WARNING:\033[0m", file=sys.stderr)
print("\033[93m'praisonai rag serve' is deprecated and will be removed in a future version.\033[0m", file=sys.stderr)
print("\033[93mPlease use 'praisonai serve rag' instead.\033[0m\n", file=sys.stderr)
from rich.console import Console
console = Console()
with rag_profiler(profile, profile_out, profile_top) as profile_data:
try:
import uvicorn
from fastapi import FastAPI, HTTPException
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
from typing import List as TypingList, Optional as TypingOptional
except ImportError:
console.print("[red]Error:[/red] FastAPI/uvicorn not installed")
console.print("Install with: pip install 'praisonai[rag-api]'")
raise typer.Exit(1)
try:
from praisonaiagents.knowledge import Knowledge
from praisonaiagents.rag import RAG, RAGConfig
from praisonaiagents.rag.models import RetrievalStrategy
# Build config - use unified knowledge path
knowledge_config = {
"vector_store": {
"provider": "chroma",
"config": {
"collection_name": collection,
"path": f"./.praison/knowledge/{collection}",
}
}
}
# Add hybrid retrieval config if enabled
if hybrid:
knowledge_config["retrieval"] = {
"strategy": "hybrid",
}
# Load config file if provided
if config and config.exists():
import yaml
with open(config) as f:
file_config = yaml.safe_load(f)
if "knowledge" in file_config:
knowledge_config.update(file_config["knowledge"])
# Initialize
knowledge = Knowledge(config=knowledge_config, verbose=verbose)
retrieval_strategy = RetrievalStrategy.HYBRID if hybrid else RetrievalStrategy.BASIC
rag_config = RAGConfig(
retrieval_strategy=retrieval_strategy,
rerank=rerank,
)
rag = RAG(knowledge=knowledge, config=rag_config)
# Create FastAPI app
api = FastAPI(
title="PraisonAI RAG API",
version="1.0.0",
description="RAG microservice with hybrid retrieval and OpenAI-compatible mode",
)
# Request/Response models
class QueryRequest(BaseModel):
question: str
top_k: int = 5
include_citations: bool = True
hybrid: bool = Field(default=False, description="Use hybrid retrieval")
rerank: bool = Field(default=False, description="Enable reranking")
class CitationModel(BaseModel):
id: str
source: str
text: str
score: float = 0.0
doc_id: TypingOptional[str] = None
chunk_id: TypingOptional[str] = None
class QueryResponse(BaseModel):
answer: str
citations: TypingList[CitationModel]
metadata: dict
# OpenAI-compatible models
class ChatMessage(BaseModel):
role: str
content: str
class ChatCompletionRequest(BaseModel):
model: str = "rag"
messages: TypingList[ChatMessage]
temperature: float = 0.7
max_tokens: TypingOptional[int] = None
stream: bool = False
rag: bool = Field(default=True, description="Enable RAG retrieval")
class ChatCompletionChoice(BaseModel):
index: int
message: ChatMessage
finish_reason: str = "stop"
class ChatCompletionResponse(BaseModel):
id: str
object: str = "chat.completion"
created: int
model: str
choices: TypingList[ChatCompletionChoice]
usage: dict = Field(default_factory=dict)
@api.get("/health")
def health():
return {
"status": "healthy",
"collection": collection,
"hybrid": hybrid,
"rerank": rerank,
"openai_compat": openai_compat,
}
@api.post("/rag/query", response_model=QueryResponse)
def query_endpoint(request: QueryRequest):
try:
# Update config based on request
rag.config.top_k = request.top_k
rag.config.include_citations = request.include_citations
# Override retrieval strategy if requested
if request.hybrid:
rag.config.retrieval_strategy = RetrievalStrategy.HYBRID
if request.rerank:
rag.config.rerank = True
result = rag.query(request.question)
return QueryResponse(
answer=result.answer,
citations=[
CitationModel(
id=c.id,
source=c.source,
text=c.text,
score=c.score,
doc_id=c.doc_id,
chunk_id=c.chunk_id,
)
for c in result.citations
],
metadata=result.metadata,
)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@api.post("/rag/chat")
def chat_endpoint(request: QueryRequest):
"""Streaming RAG chat endpoint using Server-Sent Events."""
import json as json_lib
def generate():
try:
# Update config
rag.config.top_k = request.top_k
if request.hybrid:
rag.config.retrieval_strategy = RetrievalStrategy.HYBRID
if request.rerank:
rag.config.rerank = True
for chunk in rag.stream(request.question):
yield f"data: {json_lib.dumps({'content': chunk})}\n\n"
# Send citations at the end
if request.include_citations:
citations = rag.get_citations(request.question)
yield f"data: {json_lib.dumps({'citations': [c.to_dict() for c in citations]})}\n\n"
yield "data: [DONE]\n\n"
except Exception as e:
yield f"data: {json_lib.dumps({'error': str(e)})}\n\n"
return StreamingResponse(
generate(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "Connection": "keep-alive"},
)
# OpenAI-compatible endpoint (only if enabled)
if openai_compat:
@api.post("/v1/chat/completions")
def openai_chat_completions(request: ChatCompletionRequest):
"""OpenAI-compatible chat completions endpoint with RAG."""
import time as time_module
import uuid
try:
# Extract the last user message as the question
user_messages = [m for m in request.messages if m.role == "user"]
if not user_messages:
raise HTTPException(status_code=400, detail="No user message found")
question = user_messages[-1].content
# Use RAG if enabled
if request.rag:
result = rag.query(question)
answer = result.answer
else:
# Direct LLM call without RAG
if rag.llm:
answer = rag._generate(question)
else:
answer = "LLM not available"
response = ChatCompletionResponse(
id=f"chatcmpl-{uuid.uuid4().hex[:8]}",
created=int(time_module.time()),
model=request.model,
choices=[
ChatCompletionChoice(
index=0,
message=ChatMessage(role="assistant", content=answer),
finish_reason="stop",
)
],
usage={
"prompt_tokens": 0, # Not tracked
"completion_tokens": 0,
"total_tokens": 0,
},
)
return response
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
# Print startup info
console.print("\n[bold green]Starting RAG API server[/bold green]")
console.print(f" Collection: {collection}")
console.print(f" Hybrid retrieval: {'enabled' if hybrid else 'disabled'}")
console.print(f" Reranking: {'enabled' if rerank else 'disabled'}")
console.print(f" OpenAI-compat: {'enabled' if openai_compat else 'disabled'}")
console.print(f" URL: http://{host}:{port}")
console.print(f" Docs: http://{host}:{port}/docs")
console.print("\n[dim]Endpoints:[/dim]")
console.print(" GET /health")
console.print(" POST /rag/query")
console.print(" POST /rag/chat (SSE streaming)")
if openai_compat:
console.print(" POST /v1/chat/completions (OpenAI-compatible)")
console.print()
if profile_data:
profile_data["command"] = "rag serve"
profile_data["collection"] = collection
profile_data["hybrid"] = hybrid
profile_data["rerank"] = rerank
profile_data["openai_compat"] = openai_compat
uvicorn.run(api, host=host, port=port)
except ImportError as e:
console.print(f"[red]Error:[/red] Missing dependency: {e}")
console.print("Install with: pip install 'praisonaiagents[knowledge]'")
raise typer.Exit(1)
except Exception as e:
console.print(f"[red]Error:[/red] {e}")
if verbose:
import traceback
console.print(traceback.format_exc())
raise typer.Exit(1)
@@ -1,20 +1,52 @@
"""Backward-compatibility shim for :mod:`praisonai.cli.commands.realtime`.
"""
Realtime command group for PraisonAI CLI.
The implementation moved to :mod:`praisonai_code.cli.commands.realtime` as part
of the praisonai-code extraction (issue #2516 / parent #2512).
This shim aliases the moved module into ``sys.modules`` under the old dotted
path so that:
* ``from praisonai.cli.commands.realtime import X`` keeps working, and
* ``unittest.mock.patch("praisonai.cli.commands.realtime.X")`` patches the very
same module object that the implementation executes against.
Provides realtime interaction commands.
"""
import sys as _sys
from typing import Optional
from praisonai_code.cli.commands import realtime as _impl
import typer
# Make the old dotted path resolve to the exact same module object so that
# attribute patching / monkeypatching stays transparent across both paths.
_sys.modules[__name__] = _impl
app = typer.Typer(help="Realtime interaction mode")
@app.callback(invoke_without_command=True)
def realtime_main(
ctx: typer.Context,
model: Optional[str] = typer.Option(None, "--model", "-m", help="LLM model to use"),
verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
port: int = typer.Option(8085, "--port", "-p", help="Port for realtime UI"),
):
"""
Start realtime interaction mode.
Now routes to the new aiui-based realtime interface.
Examples:
praisonai realtime
praisonai realtime --model gpt-4o --port 9000
"""
import os
if model:
os.environ["MODEL_NAME"] = model
print("🎤 Launching PraisonAI Realtime Voice Interface...")
print("Note: Migrated from Chainlit to aiui.")
try:
from praisonai.cli.commands.ui import _launch_aiui_app
_launch_aiui_app(
app_dir="ui_realtime",
default_app_name="ui_realtime",
port=port,
host="127.0.0.1",
app_file=None,
reload=False,
ui_name="Realtime Voice"
)
except ImportError:
print('\033[91mERROR: Realtime UI is not installed.\033[0m')
print('Install with: pip install "praisonai[ui]"')
raise typer.Exit(1)
+679 -15
View File
@@ -1,20 +1,684 @@
"""Backward-compatibility shim for :mod:`praisonai.cli.commands.recipe`.
"""
Recipe command group for PraisonAI CLI.
The implementation moved to :mod:`praisonai_code.cli.commands.recipe` as part
of the praisonai-code extraction (issue #2516 / parent #2512).
This shim aliases the moved module into ``sys.modules`` under the old dotted
path so that:
* ``from praisonai.cli.commands.recipe import X`` keeps working, and
* ``unittest.mock.patch("praisonai.cli.commands.recipe.X")`` patches the very
same module object that the implementation executes against.
Provides recipe management commands.
"""
import sys as _sys
import typer
from praisonai_code.cli.commands import recipe as _impl
app = typer.Typer(help="Recipe management")
# Make the old dotted path resolve to the exact same module object so that
# attribute patching / monkeypatching stays transparent across both paths.
_sys.modules[__name__] = _impl
@app.command("create")
def recipe_create(
goal: str = typer.Argument(..., help="Natural language goal for the recipe"),
output: str = typer.Option(".", "--output", "-o", help="Output directory"),
no_optimize: bool = typer.Option(False, "--no-optimize", help="Skip optimization loop"),
iterations: int = typer.Option(3, "--iterations", help="Max optimization iterations"),
threshold: float = typer.Option(8.0, "--threshold", help="Score threshold to stop"),
agents: str = typer.Option(None, "--agents", help="Custom agents (format: name:role=X,goal=Y;name2:...)"),
tools: str = typer.Option(None, "--tools", help="Custom tools per agent (format: agent:tool1,tool2;...)"),
agent_types: str = typer.Option(None, "--agent-types", help="Agent types (format: agent:image;agent2:audio;...)"),
):
"""Create a new recipe from a natural language goal.
Examples:
praisonai recipe create "Build a web scraper for news"
praisonai recipe create "Research AI trends" --no-optimize
praisonai recipe create "Research AI" --agents "researcher:role=AI Researcher,goal=Find papers"
praisonai recipe create "Research AI" --tools "researcher:internet_search,arxiv"
"""
from praisonai_code.cli.main import PraisonAI
import sys
argv = ['recipe', 'create', goal]
if output != ".":
argv.extend(['--output', output])
if no_optimize:
argv.append('--no-optimize')
if iterations != 3:
argv.extend(['--iterations', str(iterations)])
if threshold != 8.0:
argv.extend(['--threshold', str(threshold)])
if agents:
argv.extend(['--agents', agents])
if tools:
argv.extend(['--tools', tools])
if agent_types:
argv.extend(['--agent-types', agent_types])
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@app.command("list")
def recipe_list():
"""List available recipes."""
from praisonai_code.cli.main import PraisonAI
import sys
argv = ['recipe', 'list']
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@app.command("run")
def recipe_run(
name: str = typer.Argument(..., help="Recipe name"),
model: str = typer.Option(None, "--model", "-m", help="LLM model to use"),
var: list[str] = typer.Option(None, "--var", help="Variable override (key=value), can be used multiple times"),
save: bool = typer.Option(False, "--save", "-s", help="Save replay trace for debugging"),
trace_name: str = typer.Option(None, "--name", "-n", help="Custom trace name (e.g., run-abc123). Overwrites if exists."),
verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
output: str = typer.Option(None, "--output", "-o", help="Output mode: silent, status, trace, verbose, debug"),
debug: bool = typer.Option(False, "--debug", help="Enable debug mode"),
profile: bool = typer.Option(False, "--profile", help="Enable profiling"),
deep_profile: bool = typer.Option(False, "--deep-profile", help="Enable deep profiling"),
dry_run: bool = typer.Option(False, "--dry-run", help="Validate without executing"),
force: bool = typer.Option(False, "--force", help="Force execution even with missing deps"),
json_output: bool = typer.Option(False, "--json", help="Output in JSON format"),
):
"""Run a recipe with optional variable overrides.
Examples:
praisonai recipe run ai-url-blog-generator --var url="https://example.com/article"
praisonai recipe run ai-dynamic-blog-generator --var topic="LangGraph 0.3" --var style="coding"
praisonai recipe run ai-wordpress-post-generator --save --verbose
praisonai recipe run ai-topic-gatherer --save --name my-test-run --var topic="AI"
"""
from praisonai_code.cli.main import PraisonAI
import sys
argv = ['recipe', 'run', name]
if model:
argv.extend(['--model', model])
# Pass --var directly to the recipe handler (features/recipe.py)
# Don't convert to --workflow-var as that gets consumed by main.py's parser
if var:
for v in var:
argv.extend(['--var', v])
# Forward all flags to the recipe handler
if save:
argv.append('--save')
if trace_name:
argv.extend(['--name', trace_name])
if verbose:
argv.append('--verbose')
if output:
argv.extend(['--output', output])
if debug:
argv.append('--debug')
if profile:
argv.append('--profile')
if deep_profile:
argv.append('--deep-profile')
if dry_run:
argv.append('--dry-run')
if force:
argv.append('--force')
if json_output:
argv.append('--json')
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@app.command("info")
def recipe_info(
name: str = typer.Argument(..., help="Recipe name"),
verbose: bool = typer.Option(False, "--verbose", "-v", help="Show full YAML content"),
):
"""Show detailed information about a recipe.
Displays recipe metadata, agents, steps, and tools.
Examples:
praisonai recipe info image-to-blog-generator
praisonai recipe info url-to-blog-generator --verbose
"""
from pathlib import Path
from rich.console import Console
from rich.table import Table
from rich.panel import Panel
from rich.syntax import Syntax
import yaml
console = Console()
# Find recipe path
recipe_path = None
# Check template search paths
try:
from praisonai.recipe.core import get_template_search_paths
for search_path in get_template_search_paths():
candidate = search_path / name
if candidate.exists() and (candidate / "agents.yaml").exists():
recipe_path = candidate
break
except ImportError:
pass
# Also check agent_recipes package
if not recipe_path:
try:
import agent_recipes
if hasattr(agent_recipes, 'get_template_path'):
template_base = Path(agent_recipes.get_template_path(""))
candidate = template_base / name
if candidate.exists() and (candidate / "agents.yaml").exists():
recipe_path = candidate
except ImportError:
pass
# Check current directory
if not recipe_path:
candidate = Path(name)
if candidate.exists() and (candidate / "agents.yaml").exists():
recipe_path = candidate
if not recipe_path:
console.print(f"[red]❌ Recipe not found: {name}[/red]")
console.print("[dim]Hint: Run 'praisonai recipe list' to see available recipes[/dim]")
raise typer.Exit(1)
# Load agents.yaml
yaml_path = recipe_path / "agents.yaml"
try:
with open(yaml_path) as f:
config = yaml.safe_load(f)
except Exception as e:
console.print(f"[red]❌ Error loading recipe: {e}[/red]")
raise typer.Exit(1)
# Display recipe info
console.print()
console.print(Panel(f"[bold cyan]{name}[/bold cyan]", title="Recipe Info"))
# Metadata
metadata = config.get("metadata", {})
if metadata:
console.print("\n[bold]Metadata:[/bold]")
meta_table = Table(show_header=False, box=None)
meta_table.add_column("Key", style="dim")
meta_table.add_column("Value")
for key, value in metadata.items():
if isinstance(value, list):
value = ", ".join(str(v) for v in value)
elif isinstance(value, dict):
value = str(value)
meta_table.add_row(key, str(value))
console.print(meta_table)
# Topic
topic = config.get("topic", "Not specified")
console.print(f"\n[bold]Topic:[/bold] {topic}")
# Agents
agents = config.get("agents", {})
if agents:
console.print(f"\n[bold]Agents ({len(agents)}):[/bold]")
agent_table = Table(show_header=True, header_style="bold")
agent_table.add_column("Name", style="green")
agent_table.add_column("Role")
agent_table.add_column("Tools", style="cyan")
agent_table.add_column("LLM", style="dim")
for agent_name, agent_config in agents.items():
tools = agent_config.get("tools", [])
tools_str = ", ".join(tools) if tools else "-"
llm = agent_config.get("llm", "default")
agent_table.add_row(
agent_name,
agent_config.get("role", "-"),
tools_str,
str(llm)
)
console.print(agent_table)
# Steps
steps = config.get("steps", [])
if steps:
console.print(f"\n[bold]Steps ({len(steps)}):[/bold]")
step_table = Table(show_header=True, header_style="bold")
step_table.add_column("#", style="dim")
step_table.add_column("Name", style="green")
step_table.add_column("Agent", style="cyan")
step_table.add_column("Action (truncated)")
for i, step in enumerate(steps, 1):
step_name = step.get("name", f"step_{i}")
agent = step.get("agent", "-")
action = step.get("action", "")
# Truncate action for display
action_preview = action[:60].replace("\n", " ") + "..." if len(action) > 60 else action.replace("\n", " ")
step_table.add_row(str(i), step_name, agent, action_preview)
console.print(step_table)
# Tools.py
tools_path = recipe_path / "tools.py"
if tools_path.exists():
console.print("\n[bold]Custom Tools:[/bold] [green]✓ tools.py present[/green]")
else:
console.print("\n[bold]Custom Tools:[/bold] [dim]No tools.py[/dim]")
# Full YAML if verbose
if verbose:
console.print("\n[bold]Full YAML:[/bold]")
with open(yaml_path) as f:
yaml_content = f.read()
syntax = Syntax(yaml_content, "yaml", theme="monokai", line_numbers=True)
console.print(syntax)
# Path
console.print(f"\n[dim]Path: {recipe_path}[/dim]")
@app.command("install")
def recipe_install(
source: str = typer.Argument(..., help="Recipe source (path or URL)"),
):
"""Install a recipe."""
from praisonai_code.cli.main import PraisonAI
import sys
argv = ['recipe', 'install', source]
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@app.command("judge")
def recipe_judge(
trace_id: str = typer.Argument(..., help="Trace ID to judge (e.g., run-abc123)"),
yaml_file: str = typer.Option(None, "--yaml", "-y", help="YAML file path for fix recommendations"),
output: str = typer.Option(None, "--output", "-o", help="Output plan file (default: judge_plan.yaml)"),
dry_run: bool = typer.Option(False, "--dry-run", help="Show plan without saving"),
context: bool = typer.Option(False, "--context", help="Evaluate context flow between agents (default mode)"),
memory: bool = typer.Option(False, "--memory", help="Evaluate memory utilization (store/search effectiveness)"),
knowledge: bool = typer.Option(False, "--knowledge", help="Evaluate knowledge retrieval effectiveness"),
goal: str = typer.Option(None, "--goal", "-g", help="Override recipe goal for evaluation (extracted from YAML if not provided)"),
chunked: bool = typer.Option(False, "--chunked", help="Use chunked evaluation for large outputs (preserves all content)"),
no_auto_chunk: bool = typer.Option(False, "--no-auto-chunk", help="Disable automatic chunking (auto-chunk is enabled by default)"),
chunk_size: int = typer.Option(8000, "--chunk-size", help="Max characters per chunk (default: 8000)"),
max_chunks: int = typer.Option(5, "--max-chunks", help="Max chunks per agent (default: 5)"),
aggregation: str = typer.Option("weighted_average", "--aggregation", help="Score aggregation: weighted_average, average, min, max"),
):
"""Judge a recipe execution trace and generate fix recommendations.
Similar to 'terraform plan' - analyzes the trace and generates a plan of fixes.
The judge now includes:
- Dynamic failure detection (LLM determines if task failed)
- Previous step quality context (each agent sees how prior agents performed)
- Recipe goal evaluation (evaluates against overall workflow objective)
- Input validation status (detects unresolved template variables)
Evaluation Modes:
- --context: Evaluate context flow between agents (default)
- --memory: Evaluate memory utilization (store/search effectiveness)
- --knowledge: Evaluate knowledge retrieval effectiveness
Examples:
praisonai recipe judge run-abc123
praisonai recipe judge run-abc123 --goal "Analyze image and create blog post"
praisonai recipe judge run-abc123 --memory
praisonai recipe judge run-abc123 --knowledge
praisonai recipe judge run-abc123 --yaml agents.yaml --output plan.yaml
praisonai recipe judge run-abc123 --dry-run
"""
from praisonai.replay import (
ContextTraceReader,
ContextEffectivenessJudge,
generate_plan_from_report,
format_judge_report,
)
# Determine evaluation mode (default to context)
mode = "context"
if memory:
mode = "memory"
elif knowledge:
mode = "knowledge"
mode_emoji = {"context": "🔄", "memory": "🧠", "knowledge": "📚"}
# auto_chunk is True by default unless --no-auto-chunk is specified
auto_chunk = not no_auto_chunk
chunked_indicator = " [chunked]" if chunked else (" [auto-chunk]" if auto_chunk else "")
print(f"{mode_emoji.get(mode, '🔍')} Judging trace: {trace_id} (mode: {mode}){chunked_indicator}")
try:
reader = ContextTraceReader(trace_id)
events = reader.get_all()
if not events:
print(f"❌ No events found for trace: {trace_id}")
raise typer.Exit(1)
print(f" 📊 Found {len(events)} events")
if chunked:
print(f" 📦 Chunked evaluation: {chunk_size} chars/chunk, max {max_chunks} chunks, {aggregation} aggregation")
elif auto_chunk:
print(" 🔄 Auto-chunk: will chunk if content exceeds model context window")
# Run judge with mode-specific evaluation
judge = ContextEffectivenessJudge(
mode=mode,
chunked=chunked,
auto_chunk=auto_chunk,
chunk_size=chunk_size,
max_chunks=max_chunks,
aggregation_strategy=aggregation,
)
report = judge.judge_trace(events, session_id=trace_id, yaml_file=yaml_file)
# Display report
print(format_judge_report(report))
# Generate plan if yaml_file provided
if yaml_file:
plan = generate_plan_from_report(report, yaml_file=yaml_file)
print(plan.format_summary())
if not dry_run:
output_file = output or "judge_plan.yaml"
plan.save(output_file)
print(f"\n✅ Plan saved to: {output_file}")
print(f" Run 'praisonai recipe apply {output_file}' to apply fixes")
else:
print("\n💡 Tip: Add --yaml <file> to generate actionable fix recommendations")
except Exception as e:
print(f"❌ Error: {e}")
raise typer.Exit(1)
@app.command("optimize")
def recipe_optimize(
recipe_path: str = typer.Argument(..., help="Path to recipe folder"),
target: str = typer.Argument(None, help="Optional optimization target (e.g., 'improve error handling')"),
iterations: int = typer.Option(3, "--iterations", "-i", help="Maximum optimization iterations"),
threshold: float = typer.Option(8.0, "--threshold", "-t", help="Score threshold to stop (1-10)"),
input_data: str = typer.Option("", "--input", help="Input data for recipe runs"),
criteria: str = typer.Option(None, "--criteria", "-c", help="Custom evaluation criteria (for domain-agnostic optimization)"),
verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
):
"""Optimize a recipe using AI judge feedback.
Iteratively runs the recipe, judges output, and applies improvements.
Supports custom criteria for domain-agnostic optimization (e.g., water flow, data pipelines).
Examples:
praisonai recipe optimize my-recipe
praisonai recipe optimize my-recipe "improve error handling"
praisonai recipe optimize my-recipe --iterations 5 --threshold 9.0
praisonai recipe optimize my-recipe --input '{"query": "test"}'
praisonai recipe optimize my-recipe --criteria "Water flow is optimal: no leaks, pressure within range"
"""
from pathlib import Path
import logging
if verbose:
logging.basicConfig(level=logging.INFO)
recipe_path_obj = Path(recipe_path)
if not recipe_path_obj.exists():
print(f"❌ Recipe not found: {recipe_path}")
raise typer.Exit(1)
print(f"🔄 Optimizing recipe: {recipe_path}")
print(f" Max iterations: {iterations}")
print(f" Score threshold: {threshold}")
if target:
print(f" Target: {target}")
try:
from praisonai.cli.features.recipe_optimizer import RecipeOptimizer
# Create custom judge if criteria provided
custom_judge = None
if criteria:
try:
from praisonaiagents.eval import Judge
custom_judge = Judge(criteria=criteria)
print(f" Custom criteria: {criteria[:50]}{'...' if len(criteria) > 50 else ''}")
except ImportError:
print(" ⚠️ Custom criteria requires praisonaiagents. Using default judge.")
optimizer = RecipeOptimizer(
max_iterations=iterations,
score_threshold=threshold,
judge=custom_judge,
criteria=criteria,
)
final_report = optimizer.optimize(
recipe_path=recipe_path_obj,
input_data=input_data,
optimization_target=target,
)
if final_report:
score = getattr(final_report, 'overall_score', 0)
print(f"\n{'✅' if score >= threshold else '📊'} Final score: {score}/10")
if score >= threshold:
print(" Recipe optimization complete!")
else:
print(" Threshold not reached. Consider running more iterations.")
else:
print("❌ Optimization failed - no report generated")
raise typer.Exit(1)
except ImportError as e:
print(f"❌ Missing dependency: {e}")
print(" Install with: pip install litellm")
raise typer.Exit(1)
except Exception as e:
print(f"❌ Error: {e}")
raise typer.Exit(1)
@app.command("serve")
def recipe_serve(
recipe: str = typer.Argument(None, help="Recipe name or path to serve (optional, serves all if not specified)"),
host: str = typer.Option("127.0.0.1", "--host", "-h", help="Server host"),
port: int = typer.Option(8765, "--port", "-p", help="Server port"),
reload: bool = typer.Option(False, "--reload", "-r", help="Enable hot reload"),
workers: int = typer.Option(1, "--workers", "-w", help="Number of worker processes"),
config: str = typer.Option(None, "--config", "-c", help="Path to serve.yaml config file"),
api_key: str = typer.Option(None, "--api-key", help="API key for authentication"),
cors: str = typer.Option("*", "--cors", help="CORS origins (comma-separated or *)"),
metrics: bool = typer.Option(False, "--metrics", help="Enable /metrics endpoint"),
admin: bool = typer.Option(False, "--admin", help="Enable /admin endpoints"),
):
"""Start HTTP server for recipe execution.
DEPRECATED: Use `praisonai serve recipe` instead.
Serves recipes as REST API endpoints.
Examples:
praisonai recipe serve
praisonai recipe serve my-recipe --port 8080
praisonai recipe serve --host 0.0.0.0 --port 8000
praisonai recipe serve --config serve.yaml
praisonai recipe serve --api-key my-secret-key
praisonai recipe serve --workers 4
"""
import sys
# Print deprecation warning
print("\n\033[93m⚠ DEPRECATION WARNING:\033[0m", file=sys.stderr)
print("\033[93m'praisonai recipe serve' is deprecated and will be removed in a future version.\033[0m", file=sys.stderr)
print("\033[93mPlease use 'praisonai serve recipe' instead.\033[0m\n", file=sys.stderr)
print(f"🚀 Starting recipe server...")
print(f" Host: {host}")
print(f" Port: {port}")
if recipe:
print(f" Recipe: {recipe}")
if config:
print(f" Config: {config}")
if api_key:
print(f" Auth: API Key enabled")
if workers > 1:
print(f" Workers: {workers}")
auth_mode = serve_config.get("auth", "none")
if host not in ("127.0.0.1", "localhost") and auth_mode == "none" and not api_key:
print(
"\n\033[91mError:\033[0m Auth required for non-localhost binding. "
"Use --api-key or --auth api-key",
file=sys.stderr,
)
raise typer.Exit(1)
try:
from praisonai.recipe.serve import serve, load_config
# Load config from file if provided
serve_config = {}
if config:
serve_config = load_config(config)
# Override with CLI options
if api_key:
serve_config["api_key"] = api_key
serve_config["auth"] = "api-key"
if cors:
serve_config["cors_origins"] = cors
if metrics:
serve_config["enable_metrics"] = True
if admin:
serve_config["enable_admin"] = True
if recipe:
serve_config["recipes"] = [recipe]
print(f"\n📡 Server running at http://{host}:{port}")
print(f" Health: http://{host}:{port}/health")
print(f" Recipes: http://{host}:{port}/v1/recipes")
print(f" OpenAPI: http://{host}:{port}/openapi.json")
if metrics:
print(f" Metrics: http://{host}:{port}/metrics")
print(f"\n Press Ctrl+C to stop\n")
serve(
host=host,
port=port,
reload=reload,
config=serve_config,
workers=workers,
)
except ImportError as e:
print(f"❌ Missing dependency: {e}")
print(" Install with: pip install praisonai[serve]")
raise typer.Exit(1)
except Exception as e:
print(f"❌ Error: {e}")
raise typer.Exit(1)
@app.command("apply")
def recipe_apply(
plan_file: str = typer.Argument(..., help="Plan file to apply (from 'recipe judge')"),
confirm: bool = typer.Option(False, "--confirm", "-c", help="Apply without confirmation"),
fix_ids: str = typer.Option(None, "--fix-ids", help="Comma-separated fix IDs to apply"),
no_backup: bool = typer.Option(False, "--no-backup", help="Skip creating backup"),
dry_run: bool = typer.Option(False, "--dry-run", help="Preview changes without applying"),
):
"""Apply fixes from a judge plan to YAML files.
Similar to 'terraform apply' - applies the planned fixes.
Examples:
praisonai recipe apply judge_plan.yaml
praisonai recipe apply judge_plan.yaml --confirm
praisonai recipe apply judge_plan.yaml --fix-ids fix_001,fix_002
praisonai recipe apply judge_plan.yaml --dry-run
"""
from praisonai.replay import JudgePlan, PlanApplier
from pathlib import Path
print(f"📋 Loading plan: {plan_file}")
try:
if not Path(plan_file).exists():
print(f"❌ Plan file not found: {plan_file}")
raise typer.Exit(1)
plan = JudgePlan.load(plan_file)
applier = PlanApplier(plan)
# Show preview
print(applier.preview())
if dry_run:
print("\n🔍 Dry run - no changes applied")
return
# Confirm before applying
if not confirm:
response = typer.prompt("Apply these fixes? [y/N]", default="n")
if response.lower() != "y":
print("❌ Aborted")
raise typer.Exit(0)
# Parse fix IDs if provided
fix_id_list = None
if fix_ids:
fix_id_list = [f.strip() for f in fix_ids.split(",")]
# Apply fixes
result = applier.apply(backup=not no_backup, fix_ids=fix_id_list)
# Validate
if applier.validate():
print("✅ YAML validation passed")
else:
print("⚠️ YAML validation failed - consider rolling back")
if result.get("backup_path"):
print(f" Backup available at: {result['backup_path']}")
except Exception as e:
print(f"❌ Error: {e}")
raise typer.Exit(1)
+825 -15
View File
@@ -1,20 +1,830 @@
"""Backward-compatibility shim for :mod:`praisonai.cli.commands.replay`.
"""
Replay command group for PraisonAI CLI.
The implementation moved to :mod:`praisonai_code.cli.commands.replay` as part
of the praisonai-code extraction (issue #2516 / parent #2512).
This shim aliases the moved module into ``sys.modules`` under the old dotted
path so that:
* ``from praisonai.cli.commands.replay import X`` keeps working, and
* ``unittest.mock.patch("praisonai.cli.commands.replay.X")`` patches the very
same module object that the implementation executes against.
Provides context replay functionality:
- replay list: List available traces
- replay context <session_id>: Interactive replay of a trace
"""
import sys as _sys
from typing import Optional
from praisonai_code.cli.commands import replay as _impl
import typer
# Make the old dotted path resolve to the exact same module object so that
# attribute patching / monkeypatching stays transparent across both paths.
_sys.modules[__name__] = _impl
app = typer.Typer(help="Context replay for debugging agent execution")
@app.command("list")
def replay_list(
limit: int = typer.Option(
20,
"--limit",
"-n",
help="Maximum number of traces to show",
),
json_output: bool = typer.Option(
False,
"--json",
"-j",
help="Output as JSON",
),
):
"""List available context traces."""
from ..output.console import get_output_controller
output = get_output_controller()
try:
from praisonai.replay import list_traces
traces = list_traces(limit=limit)
except ImportError:
output.print_error("Replay module not available")
raise typer.Exit(1)
if json_output or output.is_json_mode:
output.print_json({
"traces": [t.to_dict() for t in traces]
})
return
if not traces:
output.print_info("No traces found. Run agents with trace=True to create traces.")
return
# Format as table
headers = ["Session ID", "Events", "Size", "Modified"]
rows = []
for trace in traces:
modified = trace.modified_at.strftime("%Y-%m-%d %H:%M")
size_kb = trace.size_bytes / 1024
size_str = f"{size_kb:.1f} KB" if size_kb >= 1 else f"{trace.size_bytes} B"
rows.append([
trace.session_id[:40] + "..." if len(trace.session_id) > 40 else trace.session_id,
str(trace.event_count),
size_str,
modified,
])
output.print_table(headers, rows, title="Available Context Traces")
output.print("\nUse 'praisonai replay context <session_id>' to replay a trace.")
@app.command("context")
def replay_context(
session_id: str = typer.Argument(
...,
help="Session ID or path to trace file",
),
start: int = typer.Option(
0,
"--start",
"-s",
help="Start from event number (1-based)",
),
no_rich: bool = typer.Option(
False,
"--no-rich",
help="Disable Rich formatting",
),
dump: bool = typer.Option(
False,
"--dump",
"-d",
help="Dump all events non-interactively (for scripting)",
),
json_output: bool = typer.Option(
False,
"--json",
"-j",
help="Output as JSON (implies --dump)",
),
full: bool = typer.Option(
False,
"--full",
"-f",
help="Show full context content (messages, responses) without truncation",
),
stats: bool = typer.Option(
False,
"--stats",
help="Show session statistics (tokens, costs, duplicates)",
),
cost: bool = typer.Option(
False,
"--cost",
help="Show cost breakdown only",
),
):
"""Interactive replay of a context trace.
Use --dump for non-interactive output (useful for scripting/verification).
Use --json for machine-readable output.
Use --full to show complete context content at each stage.
Use --stats to show session statistics (tokens, costs, duplicates).
Use --cost to show cost breakdown only.
"""
from ..output.console import get_output_controller
output = get_output_controller()
try:
from praisonai.replay import ContextTraceReader, ReplayPlayer
from praisonai.replay.analyzer import TraceAnalyzer, format_stats_output, format_cost_output
except ImportError:
output.print_error("Replay module not available")
raise typer.Exit(1)
# Load trace
reader = ContextTraceReader(session_id)
if not reader.exists:
output.print_error(f"Trace not found: {session_id}")
output.print_info("Use 'praisonai replay list' to see available traces.")
raise typer.Exit(1)
events = reader.get_all()
if not events:
output.print_error(f"No events in trace: {session_id}")
raise typer.Exit(1)
# Stats mode - show statistics only
if stats:
analyzer = TraceAnalyzer(events)
result = analyzer.analyze()
if json_output:
output.print_json(result.to_dict())
else:
output.print(format_stats_output(result))
return
# Cost mode - show cost breakdown only
if cost:
analyzer = TraceAnalyzer(events)
result = analyzer.analyze()
if json_output:
output.print_json({"cost": result.to_dict()["cost_stats"]})
else:
output.print(format_cost_output(result))
return
# JSON output implies dump mode
if json_output:
dump = True
# Non-interactive dump mode
if dump:
_dump_events(output, events, session_id, json_output, full)
return
output.print_success(f"Loaded {len(events)} events from: {session_id}")
# Create and run player with full mode support
player = ReplayPlayer(reader, use_rich=not no_rich, full_mode=full)
# Set start position if specified
if start > 0:
if player.goto(start - 1): # Convert to 0-based
output.print(f"Starting from event {start}")
else:
output.print_warning(f"Invalid start position. Valid range: 1-{len(events)}")
player.run()
def _dump_events(output, events, session_id: str, json_output: bool, full: bool = False):
"""Dump all events non-interactively.
Args:
output: Output controller
events: List of trace events
session_id: Session identifier
json_output: Whether to output as JSON
full: Whether to show full content without truncation
"""
# Import analyzer for stats
from praisonai.replay.analyzer import TraceAnalyzer
if json_output or output.is_json_mode:
# Include analysis in JSON output
analyzer = TraceAnalyzer(events)
result = analyzer.analyze()
output.print_json({
"session_id": session_id,
"total_events": len(events),
"analysis": result.to_dict(),
"events": [e.to_dict() if hasattr(e, 'to_dict') else e for e in events],
})
return
# Text output - formatted for readability
output.print(f"\n{'='*60}")
output.print(f" CONTEXT REPLAY: {session_id}")
output.print(f" Total Events: {len(events)}")
output.print(f"{'='*60}\n")
# Track running totals
running_prompt_tokens = 0
running_completion_tokens = 0
running_cost = 0.0
for i, event in enumerate(events):
# Get event details
if hasattr(event, 'event_type'):
et = event.event_type
if hasattr(et, 'value'):
et = et.value
else:
et = event.get('event_type', 'unknown')
agent_name = getattr(event, 'agent_name', None) or (event.get('agent_name') if isinstance(event, dict) else None)
seq = getattr(event, 'sequence_num', i) or (event.get('sequence_num', i) if isinstance(event, dict) else i)
branch_id = getattr(event, 'branch_id', None) or (event.get('branch_id') if isinstance(event, dict) else None)
# Get token/cost info
prompt_tokens = getattr(event, 'prompt_tokens', 0) or (event.get('prompt_tokens', 0) if isinstance(event, dict) else 0)
completion_tokens = getattr(event, 'completion_tokens', 0) or (event.get('completion_tokens', 0) if isinstance(event, dict) else 0)
cost_usd = getattr(event, 'cost_usd', 0) or (event.get('cost_usd', 0) if isinstance(event, dict) else 0)
# Update running totals
running_prompt_tokens += prompt_tokens
running_completion_tokens += completion_tokens
running_cost += cost_usd
# Format event header
header = f"[{seq + 1:3d}] {et.upper()}"
if agent_name:
header += f" ({agent_name})"
if branch_id:
header += f" [branch: {branch_id}]"
output.print(f"\n{'-'*50}")
output.print(header)
# Show token/cost info for LLM events
if prompt_tokens or completion_tokens:
output.print(f" prompt_tokens: {prompt_tokens:,}")
output.print(f" completion_tokens: {completion_tokens:,}")
if cost_usd:
output.print(f" cost: ${cost_usd:.6f}")
# Show event-specific data
data = getattr(event, 'data', None) or (event.get('data') if isinstance(event, dict) else None)
if data:
for key, value in data.items():
if value is not None:
value_str = str(value)
# Full mode: show complete content; otherwise truncate
if not full and len(value_str) > 100:
value_str = value_str[:100] + "..."
# For messages array in full mode, format nicely
if full and key == "messages" and isinstance(value, list):
output.print(f" {key}:")
for msg_idx, msg in enumerate(value):
role = msg.get("role", "unknown") if isinstance(msg, dict) else "unknown"
content = msg.get("content", "") if isinstance(msg, dict) else str(msg)
output.print(f" [{msg_idx + 1}] {role}:")
# Indent content lines
for line in str(content).split('\n')[:20]: # Limit lines in full mode
output.print(f" {line}")
if len(str(content).split('\n')) > 20:
output.print(f" ... ({len(str(content).split(chr(10)))} lines total)")
else:
output.print(f" {key}: {value_str}")
# Show token info if available (legacy field)
tokens_used = getattr(event, 'tokens_used', 0) or (event.get('tokens_used', 0) if isinstance(event, dict) else 0)
if tokens_used and not prompt_tokens and not completion_tokens:
output.print(f" tokens_used: {tokens_used}")
# Show summary with running totals
output.print(f"\n{'='*60}")
output.print(f" SESSION SUMMARY")
output.print(f"{'='*60}")
output.print(f" Total Events: {len(events)}")
output.print(f" Prompt Tokens: {running_prompt_tokens:,}")
output.print(f" Completion Tokens: {running_completion_tokens:,}")
output.print(f" Total Tokens: {running_prompt_tokens + running_completion_tokens:,}")
output.print(f" Total Cost: ${running_cost:.6f}")
# Show duplicate analysis if in full mode
if full:
analyzer = TraceAnalyzer(events)
result = analyzer.analyze()
if result.duplicates:
output.print(f"\n DUPLICATE CONTENT DETECTED:")
output.print(f" Duplicates Found: {result.duplicate_count}")
output.print(f" Wasted Tokens: {result.total_wasted_tokens:,}")
for dup in result.duplicates[:3]:
output.print(f" - Hash {dup.content_hash[:12]}... ({dup.duplicate_count}x, ~{dup.wasted_tokens:,} wasted)")
else:
output.print(f"\n CONTEXT EFFICIENCY: No duplicates detected ✓")
output.print(f"{'='*60}\n")
@app.command("show")
def replay_show(
session_id: str = typer.Argument(
...,
help="Session ID or path to trace file",
),
event_num: int = typer.Option(
None,
"--event",
"-e",
help="Show specific event number (1-based)",
),
agent: Optional[str] = typer.Option(
None,
"--agent",
"-a",
help="Filter by agent name",
),
event_type: Optional[str] = typer.Option(
None,
"--type",
"-t",
help="Filter by event type",
),
json_output: bool = typer.Option(
False,
"--json",
"-j",
help="Output as JSON",
),
):
"""Show trace events without interactive mode."""
from ..output.console import get_output_controller
output = get_output_controller()
try:
from praisonai.replay import ContextTraceReader
except ImportError:
output.print_error("Replay module not available")
raise typer.Exit(1)
reader = ContextTraceReader(session_id)
if not reader.exists:
output.print_error(f"Trace not found: {session_id}")
raise typer.Exit(1)
# Get events with filters
if event_num is not None:
events = [reader[event_num - 1]] if 0 < event_num <= len(reader) else []
elif agent:
events = reader.get_by_agent(agent)
elif event_type:
events = reader.get_by_type(event_type)
else:
events = reader.get_all()
if json_output or output.is_json_mode:
output.print_json({
"session_id": session_id,
"total_events": len(reader),
"filtered_events": len(events),
"events": [e.to_dict() if hasattr(e, 'to_dict') else e for e in events],
})
return
if not events:
output.print_info("No events match the filter criteria.")
return
output.print(f"Session: {session_id}")
output.print(f"Showing {len(events)} of {len(reader)} events\n")
for i, event in enumerate(events):
# Get event details
if hasattr(event, 'event_type'):
et = event.event_type
if hasattr(et, 'value'):
et = et.value
else:
et = event.get('event_type', 'unknown')
agent_name = getattr(event, 'agent_name', None) or (event.get('agent_name') if isinstance(event, dict) else None)
seq = getattr(event, 'sequence_num', i) or (event.get('sequence_num', i) if isinstance(event, dict) else i)
line = f"[{seq + 1}] {et.upper()}"
if agent_name:
line += f" ({agent_name})"
output.print(line)
@app.command("delete")
def replay_delete(
session_id: str = typer.Argument(
...,
help="Session ID to delete",
),
force: bool = typer.Option(
False,
"--force",
"-f",
help="Skip confirmation",
),
):
"""Delete a context trace."""
from ..output.console import get_output_controller
output = get_output_controller()
try:
from praisonai.replay import ContextTraceReader
except ImportError:
output.print_error("Replay module not available")
raise typer.Exit(1)
reader = ContextTraceReader(session_id)
if not reader.exists:
output.print_error(f"Trace not found: {session_id}")
raise typer.Exit(1)
if not force:
confirm = typer.confirm(f"Delete trace '{session_id}' ({len(reader)} events)?")
if not confirm:
output.print("Cancelled.")
raise typer.Exit(0)
try:
reader.path.unlink()
output.print_success(f"Deleted trace: {session_id}")
except Exception as e:
output.print_error(f"Failed to delete: {e}")
raise typer.Exit(1)
@app.command("flow")
def replay_flow(
session_id: str = typer.Argument(
...,
help="Session ID or path to trace file",
),
format: str = typer.Option(
"ascii",
"--format",
"-f",
help="Output format: ascii, json",
),
):
"""Visualize agent flow from a context trace."""
from ..output.console import get_output_controller
output = get_output_controller()
try:
from praisonai.replay import ContextTraceReader
except ImportError:
output.print_error("Replay module not available")
raise typer.Exit(1)
reader = ContextTraceReader(session_id)
if not reader.exists:
output.print_error(f"Trace not found: {session_id}")
raise typer.Exit(1)
events = reader.get_all()
if not events:
output.print_error(f"No events in trace: {session_id}")
raise typer.Exit(1)
# Build flow structure from events
flow_data = _build_flow_from_events(events)
if format == "json":
output.print_json(flow_data)
return
# ASCII visualization
_print_ascii_flow(output, flow_data, session_id)
def _build_flow_from_events(events) -> dict:
"""Build flow structure from context events."""
agents = {}
handoffs = []
for event in events:
event_type = event.event_type
if hasattr(event_type, 'value'):
event_type = event_type.value
agent_name = event.agent_name
if event_type == "agent_start" and agent_name:
if agent_name not in agents:
agents[agent_name] = {
"name": agent_name,
"start_time": event.timestamp,
"end_time": None,
"messages": 0,
"tool_calls": [],
"llm_calls": 0,
}
elif event_type == "agent_end" and agent_name:
if agent_name in agents:
agents[agent_name]["end_time"] = event.timestamp
elif event_type == "agent_handoff":
handoffs.append({
"from": event.data.get("from_agent"),
"to": event.data.get("to_agent"),
"reason": event.data.get("reason"),
"timestamp": event.timestamp,
})
elif event_type == "message_added" and agent_name:
if agent_name in agents:
agents[agent_name]["messages"] += 1
elif event_type == "tool_call_start" and agent_name:
tool_name = event.data.get("tool_name", "unknown")
if agent_name in agents:
agents[agent_name]["tool_calls"].append(tool_name)
elif event_type == "llm_request" and agent_name:
if agent_name in agents:
agents[agent_name]["llm_calls"] += 1
# Determine agent order based on start times
agent_order = sorted(
agents.keys(),
key=lambda a: agents[a].get("start_time", 0) or 0
)
return {
"session_id": events[0].session_id if events else "",
"agents": agents,
"agent_order": agent_order,
"handoffs": handoffs,
"total_events": len(events),
}
def _print_ascii_flow(output, flow_data: dict, session_id: str):
"""Print ASCII flow visualization."""
agents = flow_data["agents"]
agent_order = flow_data["agent_order"]
handoffs = flow_data["handoffs"]
output.print(f"\n{'='*60}")
output.print(f" AGENT FLOW: {session_id}")
output.print(f"{'='*60}\n")
if not agent_order:
output.print(" No agents found in trace.")
return
# Print agent boxes with connections
for i, agent_name in enumerate(agent_order):
agent = agents[agent_name]
# Agent box
box_width = 40
output.print(f" ┌{'─' * box_width}┐")
output.print(f" │ {'Agent: ' + agent_name:<{box_width-2}} │")
output.print(f" │{' ' * box_width}│")
output.print(f" │ Messages: {agent['messages']:<{box_width-15}} │")
output.print(f" │ LLM Calls: {agent['llm_calls']:<{box_width-16}} │")
if agent['tool_calls']:
tools_str = ", ".join(agent['tool_calls'][:3])
if len(agent['tool_calls']) > 3:
tools_str += f" (+{len(agent['tool_calls'])-3} more)"
output.print(f" │ Tools: {tools_str:<{box_width-13}} │")
output.print(f" └{'─' * box_width}┘")
# Connection to next agent
if i < len(agent_order) - 1:
next_agent = agent_order[i + 1]
# Find handoff reason
handoff_reason = None
for h in handoffs:
if h["from"] == agent_name and h["to"] == next_agent:
handoff_reason = h.get("reason")
break
output.print(" │")
output.print(" ▼")
if handoff_reason:
reason_short = handoff_reason[:35] + "..." if len(handoff_reason) > 35 else handoff_reason
output.print(f" [{reason_short}]")
output.print("")
# Summary
output.print("\n" + "─"*60)
output.print(" Summary:")
output.print(f" Agents: {len(agent_order)}")
output.print(f" Handoffs: {len(handoffs)}")
output.print(f" Total Events: {flow_data['total_events']}")
output.print("─"*60 + "\n")
@app.command("dashboard")
def replay_dashboard(
session_id: Optional[str] = typer.Argument(
None,
help="Session ID (optional, shows all recent if not specified)",
),
limit: int = typer.Option(
5,
"--limit",
"-n",
help="Number of recent sessions to analyze",
),
json_output: bool = typer.Option(
False,
"--json",
"-j",
help="Output as JSON",
),
):
"""Show context analytics dashboard with usage patterns.
Displays:
- Token usage by agent (bar chart)
- Cost breakdown by tool
- Duplicate content patterns
- Optimization recommendations
"""
from ..output.console import get_output_controller
output = get_output_controller()
try:
from praisonai.replay import list_traces, ContextTraceReader
from praisonai.replay.analyzer import TraceAnalyzer
except ImportError:
output.print_error("Replay module not available")
raise typer.Exit(1)
# Get traces to analyze
if session_id:
traces_to_analyze = [session_id]
else:
traces = list_traces(limit=limit)
if not traces:
output.print_info("No traces found. Run agents with trace=True to create traces.")
return
traces_to_analyze = [t.session_id for t in traces]
# Aggregate stats across sessions
all_stats = []
total_tokens = 0
total_cost = 0.0
total_duplicates = 0
total_wasted = 0
agent_tokens = {}
tool_costs = {}
for sid in traces_to_analyze:
reader = ContextTraceReader(sid)
if not reader.exists:
continue
events = reader.get_all()
if not events:
continue
analyzer = TraceAnalyzer(events)
result = analyzer.analyze()
all_stats.append(result)
# Aggregate using correct attribute paths
total_tokens += result.token_stats.total_tokens
total_cost += result.cost_stats.total_cost
total_duplicates += result.duplicate_count
total_wasted += result.total_wasted_tokens
# By agent - tokens is a dict with {prompt, completion, total}
for agent, tokens in result.token_stats.by_agent.items():
agent_total = tokens.get("total", 0) if isinstance(tokens, dict) else tokens
agent_tokens[agent] = agent_tokens.get(agent, 0) + agent_total
# By tool - cost is a dict with {count, cost}
for tool, cost_info in result.cost_stats.by_tool.items():
tool_cost = cost_info.get("cost", 0.0) if isinstance(cost_info, dict) else cost_info
tool_costs[tool] = tool_costs.get(tool, 0.0) + tool_cost
if json_output:
output.print_json({
"sessions_analyzed": len(all_stats),
"total_tokens": total_tokens,
"total_cost": total_cost,
"total_duplicates": total_duplicates,
"total_wasted_tokens": total_wasted,
"tokens_by_agent": agent_tokens,
"cost_by_tool": tool_costs,
})
return
# Display dashboard
output.print("\n" + "="*70)
output.print(" CONTEXT ANALYTICS DASHBOARD")
output.print("="*70)
output.print(f"\n Sessions Analyzed: {len(all_stats)}")
output.print(f" Total Tokens: {total_tokens:,}")
output.print(f" Total Cost: ${total_cost:.4f}")
# Token usage by agent (bar chart)
if agent_tokens:
output.print("\n TOKEN USAGE BY AGENT:")
output.print(" " + "-"*50)
max_tokens = max(agent_tokens.values()) if agent_tokens else 1
for agent, tokens in sorted(agent_tokens.items(), key=lambda x: -x[1]):
bar_len = int((tokens / max_tokens) * 30)
bar = "█" * bar_len
pct = (tokens / total_tokens * 100) if total_tokens > 0 else 0
output.print(f" {agent[:20]:<20} {bar:<30} {tokens:>10,} ({pct:.1f}%)")
# Cost by tool
if tool_costs:
output.print("\n COST BY TOOL:")
output.print(" " + "-"*50)
for tool, cost in sorted(tool_costs.items(), key=lambda x: -x[1]):
output.print(f" {tool:<25} ${cost:.4f}")
# Context efficiency
output.print("\n CONTEXT EFFICIENCY:")
output.print(" " + "-"*50)
if total_duplicates > 0:
waste_pct = (total_wasted / total_tokens * 100) if total_tokens > 0 else 0
output.print(f" ⚠️ Duplicates Found: {total_duplicates}")
output.print(f" ⚠️ Wasted Tokens: {total_wasted:,} ({waste_pct:.1f}%)")
output.print("\n RECOMMENDATIONS:")
output.print(" • Enable session-level deduplication in workflow")
output.print(" • Consider using context: {strategy: smart} in YAML")
output.print(" • Review tool outputs for redundant content")
else:
output.print(" ✅ Context is optimized! No duplicates detected.")
output.print("\n" + "="*70 + "\n")
@app.command("cleanup")
def replay_cleanup(
max_age_days: int = typer.Option(
7,
"--max-age",
help="Delete traces older than N days",
),
max_size_mb: int = typer.Option(
100,
"--max-size",
help="Delete oldest traces if total size exceeds N MB",
),
dry_run: bool = typer.Option(
False,
"--dry-run",
help="Show what would be deleted without deleting",
),
):
"""Clean up old context traces."""
from ..output.console import get_output_controller
output = get_output_controller()
try:
from praisonai.replay.storage import cleanup_old_traces, list_traces
except ImportError:
output.print_error("Replay module not available")
raise typer.Exit(1)
if dry_run:
from datetime import datetime
traces = list_traces(limit=10000)
now = datetime.now()
to_delete = []
for trace in traces:
age_days = (now - trace.modified_at).days
if age_days > max_age_days:
to_delete.append(trace)
if to_delete:
output.print(f"Would delete {len(to_delete)} traces older than {max_age_days} days:")
for t in to_delete[:10]:
output.print(f" - {t.session_id}")
if len(to_delete) > 10:
output.print(f" ... and {len(to_delete) - 10} more")
else:
output.print("No traces would be deleted.")
return
deleted = cleanup_old_traces(max_age_days=max_age_days, max_size_mb=max_size_mb)
if deleted > 0:
output.print_success(f"Deleted {deleted} old traces.")
else:
output.print_info("No traces needed cleanup.")
+524 -15
View File
@@ -1,20 +1,529 @@
"""Backward-compatibility shim for :mod:`praisonai.cli.commands.schedule`.
"""
Schedule command group for PraisonAI CLI.
The implementation moved to :mod:`praisonai_code.cli.commands.schedule` as part
of the praisonai-code extraction (issue #2516 / parent #2512).
This shim aliases the moved module into ``sys.modules`` under the old dotted
path so that:
* ``from praisonai.cli.commands.schedule import X`` keeps working, and
* ``unittest.mock.patch("praisonai.cli.commands.schedule.X")`` patches the very
same module object that the implementation executes against.
Provides scheduler management.
"""
import sys as _sys
from typing import Optional
from praisonai_code.cli.commands import schedule as _impl
import typer
# Make the old dotted path resolve to the exact same module object so that
# attribute patching / monkeypatching stays transparent across both paths.
_sys.modules[__name__] = _impl
from ..output.console import get_output_controller
app = typer.Typer(help="Scheduler management")
def _run_schedule(args: list) -> int:
"""Run schedule command with args."""
try:
from praisonai.cli.features.agent_scheduler import AgentSchedulerHandler
# Parse subcommand
if args and args[0] in ['start', 'list', 'stop', 'logs', 'restart', 'delete', 'describe', 'save', 'stop-all', 'stats']:
subcommand = args[0]
remaining = args[1:] if len(args) > 1 else []
# Create minimal args namespace
class Args:
pass
fake_args = Args()
return AgentSchedulerHandler.handle_daemon_command(subcommand, fake_args, remaining)
return 0
except ImportError as e:
output = get_output_controller()
output.print_error(f"Scheduler module not available: {e}")
return 4
@app.command("add")
def schedule_add_cmd(
name: str = typer.Argument(..., help="Schedule name (e.g. 'morning-hello')"),
schedule: str = typer.Option(..., "--schedule", "-s", help="When to run: 'hourly', 'daily', '*/30m', 'cron:0 9 * * *', 'at:2026-03-01T09:00', 'in 20 minutes'"),
message: str = typer.Option("", "--message", "-m", help="Prompt / reminder text"),
agent: str = typer.Option("", "--agent", "-a", help="Agent ID to execute this job (default: first registered agent)"),
deliver: str = typer.Option("", "--deliver", "-d", help="Delivery token: 'origin', 'telegram', 'all', or 'platform:chat_id[:thread_id]'"),
channel: str = typer.Option("", "--channel", help="[Legacy] Delivery platform: telegram, discord, slack, whatsapp"),
channel_id: str = typer.Option("", "--channel-id", help="[Legacy] Target chat/channel ID on the platform"),
session_id: str = typer.Option("", "--session-id", help="Session ID to preserve conversation context"),
pre_run: str = typer.Option("", "--pre-run", help="Cheap pre-run gate command: exit 0 + output => run (output seeds the prompt); non-zero => skip (no model tokens, no delivery)"),
condition: str = typer.Option("", "--condition", help="Natural-language / expression alias for the pre-run gate"),
json_output: bool = typer.Option(False, "--json", help="Output JSON"),
):
"""Add a job to the schedule store (with optional delivery target).
Examples:
praisonai schedule add "morning-hello" -s "cron:0 9 * * *" -m "say hello"
praisonai schedule add "news" -s daily -m "news summary" --deliver telegram
praisonai schedule add "report" -s hourly -m "status report" --deliver all
praisonai schedule add "tg-reminder" -s daily -m "check email" --agent support --channel telegram --channel-id 12345
praisonai schedule add "inbox-watch" -s "*/5m" -m "Summarise new emails" --pre-run "scripts/new_mail.sh" --deliver telegram
"""
output = get_output_controller()
try:
from praisonaiagents.tools.schedule_tools import schedule_add as _schedule_add
# Build delivery target based on new or legacy format
delivery_kwargs = {}
if deliver:
# New token-based delivery
delivery_kwargs["deliver"] = deliver
elif channel or channel_id:
# Legacy explicit channel/channel_id
delivery_kwargs["channel"] = channel
delivery_kwargs["channel_id"] = channel_id
if session_id:
delivery_kwargs["session_id"] = session_id
result = _schedule_add(
name=name,
schedule=schedule,
message=message,
agent_id=agent,
**delivery_kwargs
)
# ``pre_run``/``condition`` run an arbitrary host shell command, so they
# are NOT part of the LLM-callable schedule_add surface. The CLI is a
# trusted, human-driven surface, so set them on the stored job here.
if (pre_run or condition) and "Error" not in result:
try:
from praisonaiagents.tools.schedule_tools import _get_store
store = _get_store()
job = store.get_by_name(name)
if job is not None:
job.pre_run = pre_run or None
job.condition = condition or None
store.update(job)
except Exception as e:
output.print_error(f"Failed to set pre-run gate: {e}")
raise typer.Exit(1)
if json_output:
import json as _json
print(_json.dumps({"result": result}))
else:
if "Error" in result:
output.print_error(result)
raise typer.Exit(1)
else:
output.print_success(result)
except ImportError as e:
output.print_error(f"Schedule tools not available: {e}")
raise typer.Exit(4)
@app.command("start")
def schedule_start(
agents_file: str = typer.Argument("agents.yaml", help="Agents YAML file"),
interval: Optional[str] = typer.Option(None, "--interval", "-i", help="Schedule interval (e.g., 'hourly', '*/30m')"),
daemon: bool = typer.Option(True, "--daemon/--no-daemon", help="Run as daemon"),
name: Optional[str] = typer.Option(None, "--name", "-n", help="Job name"),
):
"""Start scheduled agent execution."""
args = ["start", agents_file]
if interval:
args.extend(["--interval", interval])
if not daemon:
args.append("--no-daemon")
if name:
args.extend(["--name", name])
raise typer.Exit(_run_schedule(args))
@app.command("stop")
def schedule_stop(
job_id: Optional[str] = typer.Argument(None, help="Job ID to stop (or 'all')"),
):
"""Stop scheduled job(s)."""
if job_id == "all":
raise typer.Exit(_run_schedule(["stop-all"]))
elif job_id:
raise typer.Exit(_run_schedule(["stop", job_id]))
else:
raise typer.Exit(_run_schedule(["stop"]))
@app.command("list")
def schedule_list(
json_output: bool = typer.Option(False, "--json", help="Output JSON"),
):
"""List scheduled jobs."""
args = ["list"]
if json_output:
args.append("--json")
raise typer.Exit(_run_schedule(args))
@app.command("logs")
def schedule_logs(
job_id: Optional[str] = typer.Argument(None, help="Job ID"),
tail: int = typer.Option(50, "--tail", "-n", help="Number of lines"),
follow: bool = typer.Option(False, "--follow", "-f", help="Follow log output"),
):
"""View scheduler logs."""
args = ["logs"]
if job_id:
args.append(job_id)
args.extend(["--tail", str(tail)])
if follow:
args.append("--follow")
raise typer.Exit(_run_schedule(args))
@app.command("restart")
def schedule_restart(
job_id: str = typer.Argument(..., help="Job ID to restart"),
):
"""Restart a scheduled job."""
raise typer.Exit(_run_schedule(["restart", job_id]))
@app.command("delete")
def schedule_delete(
job_id: str = typer.Argument(..., help="Job ID to delete"),
confirm: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation"),
):
"""Delete a scheduled job."""
if not confirm:
confirmed = typer.confirm(f"Delete job {job_id}?")
if not confirmed:
output = get_output_controller()
output.print_info("Cancelled")
raise typer.Exit(0)
raise typer.Exit(_run_schedule(["delete", job_id]))
@app.command("describe")
def schedule_describe(
job_id: str = typer.Argument(..., help="Job ID"),
json_output: bool = typer.Option(False, "--json", help="Output JSON"),
):
"""Show job details."""
args = ["describe", job_id]
if json_output:
args.append("--json")
raise typer.Exit(_run_schedule(args))
@app.command("stats")
def schedule_stats(
json_output: bool = typer.Option(False, "--json", help="Output JSON"),
):
"""Show scheduler statistics."""
args = ["stats"]
if json_output:
args.append("--json")
raise typer.Exit(_run_schedule(args))
# ── Blueprint commands ─────────────────────────────────────────────────
@app.command("blueprint")
def schedule_blueprint(
blueprint_name: str = typer.Argument(..., help="Blueprint name (morning-brief, important-mail, weekly-review)"),
hour: Optional[int] = typer.Option(None, "--hour", help="Delivery hour (0-23)"),
minute: Optional[int] = typer.Option(None, "--minute", help="Delivery minute (0-59)"),
weekdays: Optional[str] = typer.Option(None, "--weekdays", help="Days: mon-fri, daily, weekends, or a single day"),
focus: Optional[str] = typer.Option(None, "--focus", help="Focus area"),
interval: Optional[int] = typer.Option(None, "--interval", help="Interval in minutes (for interval-based blueprints)"),
keywords: Optional[str] = typer.Option(None, "--keywords", help="Priority keywords (for important-mail)"),
deliver: str = typer.Option("", "--deliver", "-d", help="Delivery target"),
agent: str = typer.Option("", "--agent", "-a", help="Agent ID"),
json_output: bool = typer.Option(False, "--json", help="Output JSON"),
):
"""Create a schedule from a blueprint template.
Examples:
praisonai schedule blueprint morning-brief --hour 8 --weekdays mon-fri --deliver telegram
praisonai schedule blueprint important-mail --interval 30 --keywords urgent,deadline --deliver telegram
praisonai schedule blueprint weekly-review --hour 17 --weekdays fri --focus tech
"""
output = get_output_controller()
try:
from praisonai.scheduler.blueprint_catalogue import BlueprintCatalogue
from praisonaiagents.tools.schedule_tools import schedule_add as _schedule_add
catalogue = BlueprintCatalogue()
bp = catalogue.get_blueprint(blueprint_name)
if bp is None:
available = [b.name for b in catalogue.list_blueprints()]
output.print_error(
f"Blueprint '{blueprint_name}' not found. "
f"Available: {', '.join(available)}"
)
raise typer.Exit(1)
# Build slots dict from CLI args
cli_slot_map: dict = {
"hour": hour, "minute": minute, "weekdays": weekdays,
"focus": focus, "interval_minutes": interval, "keywords": keywords,
}
slots = {}
for slot in bp.slots:
cli_val = cli_slot_map.get(slot.name)
if cli_val is not None:
slots[slot.name] = cli_val
resolved = catalogue.resolve_slots(bp, slots)
prompt = catalogue.materialize_prompt(bp, resolved)
schedule_expr = catalogue.materialize_schedule(bp, resolved)
final_deliver = deliver or bp.default_deliver
result = _schedule_add(
name=blueprint_name,
schedule=schedule_expr,
message=prompt,
deliver=final_deliver,
agent_id=agent or bp.default_agent,
)
if json_output:
import json as _json
print(_json.dumps({"result": result, "blueprint": blueprint_name,
"schedule": schedule_expr}))
else:
output.print_success(result)
except typer.Exit:
raise
except Exception as e:
output.print_error(str(e))
raise typer.Exit(1)
@app.command("blueprint-list")
def schedule_blueprint_list(
category: Optional[str] = typer.Option(None, "--category", "-c", help="Filter by category"),
json_output: bool = typer.Option(False, "--json", help="Output JSON"),
):
"""List available blueprints."""
output = get_output_controller()
try:
from praisonai.scheduler.blueprint_catalogue import BlueprintCatalogue
catalogue = BlueprintCatalogue()
blueprints = catalogue.list_blueprints(category=category)
if json_output:
import json as _json
print(_json.dumps([{
"name": bp.name,
"description": bp.description,
"category": bp.category,
"tags": bp.tags,
"slots": [
{"name": s.name, "type": s.type, "label": s.label,
"default": s.default, "choices": s.choices}
for s in bp.slots
],
"default_deliver": bp.default_deliver,
"builtin": bp.builtin,
} for bp in blueprints]))
else:
if not blueprints:
output.print_info("No blueprints found.")
else:
output.print_header(f"Available blueprints ({len(blueprints)}):")
for bp in blueprints:
slot_desc = ", ".join(
f"{s.name}={s.default}" if s.default is not None else s.name
for s in bp.slots
)
tag = " [builtin]" if bp.builtin else " [custom]"
output.print_info(f" {bp.name} [{bp.category}]{tag} — {bp.description}")
output.print_info(f" Slots: {slot_desc}")
if bp.default_deliver:
output.print_info(f" Default delivery: {bp.default_deliver}")
except Exception as e:
output.print_error(str(e))
raise typer.Exit(1)
# ── Suggestion commands ─────────────────────────────────────────────────
@app.command("suggestions")
def schedule_suggestions(
json_output: bool = typer.Option(False, "--json", help="Output JSON"),
):
"""List pending automation suggestions."""
output = get_output_controller()
try:
from praisonai.scheduler.suggestion_engine import SuggestionEngine
engine = SuggestionEngine()
pending = engine.pending()
if json_output:
import json as _json
print(_json.dumps([{
"id": s.id,
"blueprint_name": s.blueprint_name,
"slots": s.slots,
"reason": s.reason,
"created_at": s.created_at,
"expires_at": s.expires_at,
} for s in pending]))
else:
if not pending:
output.print_info("No pending suggestions.")
else:
output.print_header(f"Suggestions ({len(pending)}):")
for s in pending:
slot_str = ", ".join(f"{k}={v}" for k, v in s.slots.items())
output.print_info(f" [{s.id}] Blueprint: {s.blueprint_name}")
output.print_info(f" Reason: {s.reason or 'N/A'}")
output.print_info(f" Slots: {slot_str or '(defaults)'}")
output.print_info(f" Accept: praisonai schedule suggestion-accept {s.id}")
output.print_info(f" Dismiss: praisonai schedule suggestion-dismiss {s.id}")
except Exception as e:
output.print_error(str(e))
raise typer.Exit(1)
@app.command("suggestion-accept")
def schedule_suggestion_accept(
suggestion_id: str = typer.Argument(..., help="Suggestion ID to accept"),
deliver: str = typer.Option("", "--deliver", "-d", help="Override delivery target"),
):
"""Accept a suggestion and create the schedule job."""
output = get_output_controller()
try:
from praisonai.scheduler.suggestion_engine import SuggestionEngine
from praisonai.scheduler.blueprint_catalogue import BlueprintCatalogue
from praisonaiagents.tools.schedule_tools import schedule_add as _schedule_add
engine = SuggestionEngine()
sug = engine.get_suggestion(suggestion_id)
if sug is None or sug.dismissed or sug.accepted:
output.print_error(f"Suggestion '{suggestion_id}' not found or already handled.")
raise typer.Exit(1)
# Reject expired suggestions (expires_at == 0 means no expiry)
import time as _time
if sug.expires_at != 0 and sug.expires_at <= _time.time():
output.print_error(f"Suggestion '{suggestion_id}' has expired.")
raise typer.Exit(1)
catalogue = BlueprintCatalogue()
bp = catalogue.get_blueprint(sug.blueprint_name)
if bp is None:
output.print_error(f"Blueprint '{sug.blueprint_name}' for suggestion not found.")
raise typer.Exit(1)
resolved = catalogue.resolve_slots(bp, sug.slots)
prompt = catalogue.materialize_prompt(bp, resolved)
schedule_expr = catalogue.materialize_schedule(bp, resolved)
final_deliver = deliver or sug.deliver or bp.default_deliver
result = _schedule_add(
name=sug.blueprint_name,
schedule=schedule_expr,
message=prompt,
deliver=final_deliver,
accept_suggestion=suggestion_id,
)
if result.startswith("Error") or "already exists" in result:
output.print_error(result)
raise typer.Exit(1)
output.print_success(f"Suggestion accepted. {result}")
except typer.Exit:
raise
except Exception as e:
output.print_error(str(e))
raise typer.Exit(1)
@app.command("suggestion-dismiss")
def schedule_suggestion_dismiss(
suggestion_id: str = typer.Argument(..., help="Suggestion ID to dismiss"),
):
"""Dismiss a suggestion without creating a job."""
output = get_output_controller()
try:
from praisonai.scheduler.suggestion_engine import SuggestionEngine
engine = SuggestionEngine()
ok = engine.dismiss(suggestion_id)
if ok:
output.print_info(f"Suggestion '{suggestion_id}' dismissed.")
else:
output.print_error(f"Suggestion '{suggestion_id}' not found.")
raise typer.Exit(1)
except typer.Exit:
raise
except Exception as e:
output.print_error(str(e))
raise typer.Exit(1)
@app.command("suggestion-propose")
def schedule_suggestion_propose(
blueprint_name: str = typer.Argument(..., help="Blueprint to suggest"),
reason: str = typer.Option("", "--reason", "-r", help="Why this is being suggested"),
hour: Optional[int] = typer.Option(None, "--hour"),
minute: Optional[int] = typer.Option(None, "--minute"),
weekdays: Optional[str] = typer.Option(None, "--weekdays"),
focus: Optional[str] = typer.Option(None, "--focus"),
interval: Optional[int] = typer.Option(None, "--interval"),
keywords: Optional[str] = typer.Option(None, "--keywords", help="Priority keywords (for important-mail)"),
deliver: str = typer.Option("", "--deliver", "-d", help="Suggested delivery target"),
):
"""Propose a blueprint as a suggestion (manual/CLI trigger)."""
output = get_output_controller()
try:
from praisonai.scheduler.blueprint_catalogue import BlueprintCatalogue
from praisonai.scheduler.suggestion_engine import SuggestionEngine
catalogue = BlueprintCatalogue()
bp = catalogue.get_blueprint(blueprint_name)
if bp is None:
available = [b.name for b in catalogue.list_blueprints()]
output.print_error(
f"Blueprint '{blueprint_name}' not found. "
f"Available: {', '.join(available)}"
)
raise typer.Exit(1)
cli_slot_map: dict = {
"hour": hour, "minute": minute, "weekdays": weekdays,
"focus": focus, "interval_minutes": interval,
"keywords": keywords,
}
slots = {}
for slot in bp.slots:
val = cli_slot_map.get(slot.name)
if val is not None:
slots[slot.name] = val
engine = SuggestionEngine()
sug_id = engine.propose(
blueprint_name=blueprint_name,
slots=slots,
deliver=deliver,
reason=reason or f"Suggestion from CLI for {blueprint_name}",
)
if sug_id:
output.print_success(f"Suggestion created (id: {sug_id}).")
output.print_info(f" Accept: praisonai schedule suggestion-accept {sug_id}")
output.print_info(f" Dismiss: praisonai schedule suggestion-dismiss {sug_id}")
else:
output.print_warning("Suggestion not created (cap reached or duplicate).")
except typer.Exit:
raise
except Exception as e:
output.print_error(str(e))
raise typer.Exit(1)
@app.callback(invoke_without_command=True)
def schedule_callback(ctx: typer.Context):
"""Show schedule help or list jobs."""
if ctx.invoked_subcommand is None:
schedule_list(json_output=False)
@@ -1,20 +1,711 @@
"""Backward-compatibility shim for :mod:`praisonai.cli.commands.standardise`.
"""
CLI commands for the FDEP standardisation system.
The implementation moved to :mod:`praisonai_code.cli.commands.standardise` as part
of the praisonai-code extraction (issue #2516 / parent #2512).
This shim aliases the moved module into ``sys.modules`` under the old dotted
path so that:
* ``from praisonai.cli.commands.standardise import X`` keeps working, and
* ``unittest.mock.patch("praisonai.cli.commands.standardise.X")`` patches the very
same module object that the implementation executes against.
Commands:
- praisonai standardise check - Check for standardisation issues
- praisonai standardise report - Generate detailed report
- praisonai standardise fix - Fix issues (with --apply)
- praisonai standardise init - Initialise a new feature
- praisonai standardise ai - AI-powered generation
- praisonai standardise checkpoint - Create undo checkpoint
- praisonai standardise undo - Undo to previous checkpoint
- praisonai standardise redo - Redo after undo
"""
import sys as _sys
import sys
from pathlib import Path
from praisonai_code.cli.commands import standardise as _impl
# Make the old dotted path resolve to the exact same module object so that
# attribute patching / monkeypatching stays transparent across both paths.
_sys.modules[__name__] = _impl
def _lazy_import_engine():
"""Lazy import to avoid heavy imports at CLI startup."""
from praisonai.standardise import StandardiseEngine, StandardiseConfig
return StandardiseEngine, StandardiseConfig
def standardise_command(args=None):
"""Main entry point for standardise command."""
import argparse
parser = argparse.ArgumentParser(
prog="praisonai standardise",
description="Standardise documentation and examples (FDEP)",
)
subparsers = parser.add_subparsers(dest="subcommand", help="Subcommands")
# check subcommand
check_parser = subparsers.add_parser(
"check",
help="Check for standardisation issues (default)",
)
_add_common_args(check_parser)
# report subcommand
report_parser = subparsers.add_parser(
"report",
help="Generate detailed report",
)
_add_common_args(report_parser)
report_parser.add_argument(
"--format", "-f",
choices=["text", "json", "markdown"],
default="text",
help="Report format (default: text)",
)
report_parser.add_argument(
"--output", "-o",
type=str,
help="Output file path (default: stdout)",
)
# fix subcommand
fix_parser = subparsers.add_parser(
"fix",
help="Fix standardisation issues",
)
_add_common_args(fix_parser)
fix_parser.add_argument(
"--apply",
action="store_true",
help="Actually apply changes (default: dry-run)",
)
fix_parser.add_argument(
"--no-backup",
action="store_true",
help="Don't create backups before changes",
)
# init subcommand
init_parser = subparsers.add_parser(
"init",
help="Initialise a new feature with all required artifacts",
)
init_parser.add_argument(
"feature",
type=str,
help="Feature slug to initialise",
)
_add_common_args(init_parser)
init_parser.add_argument(
"--apply",
action="store_true",
help="Actually create files (default: dry-run)",
)
# ai subcommand
ai_parser = subparsers.add_parser(
"ai",
help="AI-powered generation of docs/examples",
)
ai_parser.add_argument(
"feature",
type=str,
help="Feature slug to generate content for",
)
ai_parser.add_argument(
"--type", "-t",
choices=["docs", "examples", "all"],
default="all",
help="Type of content to generate",
)
ai_parser.add_argument(
"--apply",
action="store_true",
help="Actually create files (default: dry-run preview)",
)
ai_parser.add_argument(
"--verify",
action="store_true",
help="Verify generated content with AI",
)
ai_parser.add_argument(
"--model",
type=str,
default="gpt-4o-mini",
help="LLM model to use (default: gpt-4o-mini)",
)
_add_common_args(ai_parser)
# checkpoint subcommand
checkpoint_parser = subparsers.add_parser(
"checkpoint",
help="Create an undo checkpoint",
)
checkpoint_parser.add_argument(
"--message", "-m",
type=str,
help="Checkpoint message",
)
checkpoint_parser.add_argument(
"--path", "-p",
type=str,
default=".",
help="Repository path",
)
# undo subcommand
undo_parser = subparsers.add_parser(
"undo",
help="Undo to a previous checkpoint",
)
undo_parser.add_argument(
"--checkpoint",
type=str,
help="Specific checkpoint ID to restore",
)
undo_parser.add_argument(
"--list",
action="store_true",
help="List available checkpoints",
)
undo_parser.add_argument(
"--path", "-p",
type=str,
default=".",
help="Repository path",
)
# redo subcommand
redo_parser = subparsers.add_parser(
"redo",
help="Redo after an undo",
)
redo_parser.add_argument(
"--path", "-p",
type=str,
default=".",
help="Repository path",
)
# Parse args
parsed = parser.parse_args(args)
# Default to check if no subcommand
if not parsed.subcommand:
parsed.subcommand = "check"
parsed = parser.parse_args(["check"] + (args or []))
# Execute subcommand
if parsed.subcommand == "check":
return _run_check(parsed)
elif parsed.subcommand == "report":
return _run_report(parsed)
elif parsed.subcommand == "fix":
return _run_fix(parsed)
elif parsed.subcommand == "init":
return _run_init(parsed)
elif parsed.subcommand == "ai":
return _run_ai(parsed)
elif parsed.subcommand == "checkpoint":
return _run_checkpoint(parsed)
elif parsed.subcommand == "undo":
return _run_undo(parsed)
elif parsed.subcommand == "redo":
return _run_redo(parsed)
return 0
def _add_common_args(parser):
"""Add common arguments to a parser."""
parser.add_argument(
"--path", "-p",
type=str,
default=".",
help="Project root path (default: current directory)",
)
parser.add_argument(
"--feature",
type=str,
help="Specific feature slug to check",
)
parser.add_argument(
"--scope",
choices=["all", "docs", "examples", "sdk", "cli"],
default="all",
help="Scope of check (default: all)",
)
parser.add_argument(
"--ci",
action="store_true",
help="CI mode (no prompts, machine-readable output)",
)
parser.add_argument(
"--dry-run",
action="store_true",
default=True,
help="Don't make changes (default: true)",
)
def _create_config(parsed):
"""Create config from parsed arguments."""
_, StandardiseConfig = _lazy_import_engine()
config = StandardiseConfig(
project_root=Path(parsed.path).resolve() if hasattr(parsed, "path") else None,
dry_run=getattr(parsed, "dry_run", True) and not getattr(parsed, "apply", False),
backup=not getattr(parsed, "no_backup", False),
scope=getattr(parsed, "scope", "all"),
feature_filter=getattr(parsed, "feature", None),
ci_mode=getattr(parsed, "ci", False),
report_format=getattr(parsed, "format", "text"),
)
return config
def _run_check(parsed) -> int:
"""Run the check subcommand."""
StandardiseEngine, StandardiseConfig = _lazy_import_engine()
config = _create_config(parsed)
engine = StandardiseEngine(config)
report = engine.check()
# Print report
output = engine.reports.generate(report, "text")
print(output)
# Return exit code
if config.ci_mode:
return engine.get_exit_code(report)
return 0
def _run_report(parsed) -> int:
"""Run the report subcommand."""
StandardiseEngine, StandardiseConfig = _lazy_import_engine()
config = _create_config(parsed)
engine = StandardiseEngine(config)
report = engine.check()
output = engine.reports.generate(report, parsed.format)
if parsed.output:
Path(parsed.output).write_text(output, encoding="utf-8")
print(f"Report saved to {parsed.output}")
else:
print(output)
if config.ci_mode:
return engine.get_exit_code(report)
return 0
def _run_fix(parsed) -> int:
"""Run the fix subcommand."""
StandardiseEngine, StandardiseConfig = _lazy_import_engine()
config = _create_config(parsed)
engine = StandardiseEngine(config)
actions = engine.fix(
feature=parsed.feature,
apply=parsed.apply,
)
if not actions["planned"]:
print("No fixes needed.")
return 0
print(f"Planned actions ({len(actions['planned'])}):")
for action in actions["planned"]:
print(f" • {action}")
if parsed.apply:
print(f"\nApplied actions ({len(actions['applied'])}):")
for action in actions["applied"]:
print(f" ✓ {action}")
else:
print("\nRun with --apply to execute these changes.")
return 0
def _run_init(parsed) -> int:
"""Run the init subcommand."""
StandardiseEngine, StandardiseConfig = _lazy_import_engine()
config = _create_config(parsed)
engine = StandardiseEngine(config)
try:
created = engine.init(parsed.feature)
except ValueError as e:
print(f"Error: {e}")
return 2
if not created:
print(f"Feature '{parsed.feature}' already has all required artifacts.")
return 0
print(f"{'Would create' if config.dry_run else 'Created'} artifacts for '{parsed.feature}':")
for artifact_type, path in created.items():
status = "📝" if config.dry_run else "✓"
print(f" {status} {artifact_type}: {path}")
if config.dry_run:
print("\nRun with --apply to create these files.")
return 0
async def _run_ai_with_runtime(parsed, config, slug):
"""Helper function to run AI generation with proper runtime lifecycle."""
from praisonai.standardise.ai_generator import AIGenerator
from praisonai.standardise.models import ArtifactType
# Start ACP/LSP runtime for context gathering
runtime = None
try:
from praisonai_code.cli.features.interactive_runtime import create_runtime
runtime = create_runtime(
workspace=str(config.sdk_root or config.project_root or "."),
lsp=True,
acp=False # ACP not needed for generation
)
# Start runtime
await runtime.start()
if runtime.lsp_ready:
print("🔧 LSP server ready for code intelligence")
except Exception:
# Runtime is optional, continue without it
pass
try:
return await _run_generation_logic(parsed, config, slug, runtime)
finally:
# Cleanup runtime
if runtime:
try:
await runtime.stop()
except Exception:
pass
async def _run_generation_logic(parsed, config, slug, runtime):
"""Main AI generation logic."""
from praisonai.standardise.ai_generator import AIGenerator
from praisonai.standardise.models import ArtifactType
generator = AIGenerator(
model=parsed.model,
sdk_root=config.sdk_root,
docs_root=config.docs_root,
examples_root=config.examples_root,
use_fast_context=True,
)
# Determine which artifacts to generate
artifact_types = []
gen_type = getattr(parsed, "type", "all")
if gen_type in ["docs", "all"]:
artifact_types.extend([
ArtifactType.DOCS_CONCEPT,
ArtifactType.DOCS_FEATURE,
ArtifactType.DOCS_CLI,
ArtifactType.DOCS_SDK,
])
if gen_type in ["examples", "all"]:
artifact_types.extend([
ArtifactType.EXAMPLE_BASIC,
ArtifactType.EXAMPLE_ADVANCED,
])
print(f"🤖 AI Generation for '{slug.normalised}'")
print("=" * 50)
results = []
for artifact_type in artifact_types:
print(f"\n📝 Generating {artifact_type.value}...")
try:
# Generate with execution verification for examples
content, output_path, verification_info = generator.generate(
slug, artifact_type,
dry_run=not parsed.apply,
verify_execution=True,
)
# Show verification results for examples
if verification_info:
if verification_info.get("execution_passed"):
print(" ✅ Code verified: runs successfully")
elif verification_info.get("requires_external"):
libs = ", ".join(verification_info.get("missing_libraries", []))
print(f" ⚠️ Requires external libraries: {libs}")
elif not verification_info.get("syntax_valid"):
print(f" ❌ Syntax error: {verification_info.get('error', 'Unknown')[:100]}")
else:
print(f" ❌ Execution failed after {verification_info.get('attempt', 1)} attempts")
if verification_info.get("error"):
print(f" Error: {verification_info['error'][:150]}")
# AI content verification (optional)
if parsed.verify:
print(" 🔍 AI content review...")
is_valid, summary, issues = generator.verify(content, artifact_type, slug)
if is_valid:
print(f" ✅ {summary}")
else:
print(f" ⚠️ {summary}")
for issue in issues[:3]:
print(f" - {issue}")
# Check if we should write
can_write = True
if verification_info and not verification_info.get("success"):
if not verification_info.get("requires_external"):
can_write = False
print(" ⛔ Not writing: code doesn't run")
if parsed.apply and output_path and can_write:
print(f" ✓ Created: {output_path}")
results.append((artifact_type.value, True, output_path))
elif not parsed.apply:
print(f" 📋 Preview ({len(content)} chars)")
# Show first few lines
preview_lines = content.split("\n")[:10]
for line in preview_lines:
print(f" {line[:60]}")
if len(content.split("\n")) > 10:
print(" ...")
results.append((artifact_type.value, True, None))
else:
results.append((artifact_type.value, False, "Verification failed"))
except Exception as e:
print(f" ❌ Error: {e}")
results.append((artifact_type.value, False, str(e)))
print("\n" + "=" * 50)
print("Summary:")
success_count = sum(1 for _, success, _ in results if success)
print(f" Generated: {success_count}/{len(results)}")
if not parsed.apply:
print("\nRun with --apply to create these files.")
return 0
def _run_ai(parsed) -> int:
"""Run the AI generation subcommand with ACP/LSP runtime support."""
import asyncio
from praisonai.standardise.models import FeatureSlug
config = _create_config(parsed)
slug = FeatureSlug.from_string(parsed.feature)
if not slug.is_valid:
print(f"Error: Invalid feature slug: {slug.validation_error}")
return 2
# Run the async workflow with single event loop
return asyncio.run(_run_ai_with_runtime(parsed, config, slug))
def _run_checkpoint(parsed) -> int:
"""Run the checkpoint subcommand."""
from praisonai.standardise.undo_redo import UndoRedoManager
manager = UndoRedoManager(Path(parsed.path).resolve())
success, result = manager.create_checkpoint(parsed.message)
if success:
print(f"✓ Checkpoint created: {result}")
else:
print(f"❌ Failed: {result}")
return 1
return 0
def _run_undo(parsed) -> int:
"""Run the undo subcommand."""
from praisonai.standardise.undo_redo import UndoRedoManager
manager = UndoRedoManager(Path(parsed.path).resolve())
# List checkpoints if requested
if getattr(parsed, "list", False):
checkpoints = manager.list_checkpoints()
if not checkpoints:
print("No checkpoints found.")
return 0
print("Available checkpoints:")
for cp_id, date, message in checkpoints:
print(f" • {cp_id} ({date}): {message[:50]}")
return 0
# Undo to checkpoint
success, result = manager.undo(parsed.checkpoint)
if success:
print(f"✓ {result}")
else:
print(f"❌ {result}")
return 1
return 0
def _run_redo(parsed) -> int:
"""Run the redo subcommand."""
from praisonai.standardise.undo_redo import UndoRedoManager
manager = UndoRedoManager(Path(parsed.path).resolve())
success, result = manager.redo()
if success:
print(f"✓ {result}")
else:
print(f"❌ {result}")
return 1
return 0
# Click-based alternative for integration with main CLI
def register_click_commands(app):
"""Register click commands with the main CLI app."""
try:
import click
except ImportError:
return
@app.group(name="standardise")
def standardise_group():
"""Standardise documentation and examples (FDEP)."""
pass
@standardise_group.command(name="check")
@click.option("--path", "-p", default=".", help="Project root path")
@click.option("--feature", help="Specific feature slug to check")
@click.option("--scope", type=click.Choice(["all", "docs", "examples", "sdk", "cli"]), default="all")
@click.option("--ci", is_flag=True, help="CI mode")
def check_cmd(path, feature, scope, ci):
"""Check for standardisation issues."""
StandardiseEngine, StandardiseConfig = _lazy_import_engine()
config = StandardiseConfig(
project_root=Path(path).resolve(),
feature_filter=feature,
scope=scope,
ci_mode=ci,
)
engine = StandardiseEngine(config)
report = engine.check()
output = engine.reports.generate(report, "text")
click.echo(output)
if ci:
sys.exit(engine.get_exit_code(report))
@standardise_group.command(name="report")
@click.option("--path", "-p", default=".", help="Project root path")
@click.option("--format", "-f", type=click.Choice(["text", "json", "markdown"]), default="text")
@click.option("--output", "-o", help="Output file path")
@click.option("--ci", is_flag=True, help="CI mode")
def report_cmd(path, format, output, ci):
"""Generate detailed report."""
StandardiseEngine, StandardiseConfig = _lazy_import_engine()
config = StandardiseConfig(
project_root=Path(path).resolve(),
ci_mode=ci,
)
engine = StandardiseEngine(config)
report = engine.check()
content = engine.reports.generate(report, format)
if output:
Path(output).write_text(content, encoding="utf-8")
click.echo(f"Report saved to {output}")
else:
click.echo(content)
@standardise_group.command(name="fix")
@click.option("--path", "-p", default=".", help="Project root path")
@click.option("--feature", help="Specific feature slug to fix")
@click.option("--apply", is_flag=True, help="Actually apply changes")
@click.option("--no-backup", is_flag=True, help="Don't create backups")
def fix_cmd(path, feature, apply, no_backup):
"""Fix standardisation issues."""
StandardiseEngine, StandardiseConfig = _lazy_import_engine()
config = StandardiseConfig(
project_root=Path(path).resolve(),
feature_filter=feature,
dry_run=not apply,
backup=not no_backup,
)
engine = StandardiseEngine(config)
actions = engine.fix(feature=feature, apply=apply)
if not actions["planned"]:
click.echo("No fixes needed.")
return
click.echo(f"Planned actions ({len(actions['planned'])}):")
for action in actions["planned"]:
click.echo(f" • {action}")
if apply:
click.echo(f"\nApplied actions ({len(actions['applied'])}):")
for action in actions["applied"]:
click.echo(f" ✓ {action}")
else:
click.echo("\nRun with --apply to execute these changes.")
@standardise_group.command(name="init")
@click.argument("feature")
@click.option("--path", "-p", default=".", help="Project root path")
@click.option("--apply", is_flag=True, help="Actually create files")
def init_cmd(feature, path, apply):
"""Initialise a new feature with all required artifacts."""
StandardiseEngine, StandardiseConfig = _lazy_import_engine()
config = StandardiseConfig(
project_root=Path(path).resolve(),
dry_run=not apply,
)
engine = StandardiseEngine(config)
try:
created = engine.init(feature)
except ValueError as e:
click.echo(f"Error: {e}", err=True)
sys.exit(2)
if not created:
click.echo(f"Feature '{feature}' already has all required artifacts.")
return
action_word = "Would create" if config.dry_run else "Created"
click.echo(f"{action_word} artifacts for '{feature}':")
for artifact_type, artifact_path in created.items():
status = "📝" if config.dry_run else "✓"
click.echo(f" {status} {artifact_type}: {artifact_path}")
if config.dry_run:
click.echo("\nRun with --apply to create these files.")
return standardise_group
if __name__ == "__main__":
sys.exit(standardise_command())
+608 -15
View File
@@ -1,20 +1,613 @@
"""Backward-compatibility shim for :mod:`praisonai.cli.commands.train`.
"""
Train command group for PraisonAI CLI.
The implementation moved to :mod:`praisonai_code.cli.commands.train` as part
of the praisonai-code extraction (issue #2516 / parent #2512).
This shim aliases the moved module into ``sys.modules`` under the old dotted
path so that:
* ``from praisonai.cli.commands.train import X`` keeps working, and
* ``unittest.mock.patch("praisonai.cli.commands.train.X")`` patches the very
same module object that the implementation executes against.
Provides two types of training:
1. LLM Fine-tuning: praisonai train dataset.json
2. Agent Training: praisonai train agents [OPTIONS]
"""
import sys as _sys
import json
from pathlib import Path
from typing import Optional
from praisonai_code.cli.commands import train as _impl
import typer
# Make the old dotted path resolve to the exact same module object so that
# attribute patching / monkeypatching stays transparent across both paths.
_sys.modules[__name__] = _impl
app = typer.Typer(help="Training commands for models and agents")
@app.callback()
def train_callback():
"""
Training commands for models and agents.
For LLM fine-tuning:
praisonai train llm dataset.json
For agent training (iterative improvement):
praisonai train agents --input "What is Python?"
praisonai train agents --input "Explain AI" --human
"""
pass
@app.command("llm")
def train_llm(
dataset: str = typer.Argument(..., help="Training dataset path"),
model: Optional[str] = typer.Option(None, "--model", "-m", help="Base model to fine-tune"),
verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
):
"""
Fine-tune LLM models using Unsloth.
Examples:
praisonai train llm dataset.json
praisonai train llm --model llama-3.1 dataset.json
"""
from praisonai_code.cli.main import PraisonAI
import sys
argv = ['train', dataset]
if model:
argv.extend(['--model', model])
if verbose:
argv.append('--verbose')
original_argv = sys.argv
sys.argv = ['praisonai'] + argv
try:
praison = PraisonAI()
praison.main()
except SystemExit:
pass
finally:
sys.argv = original_argv
@app.command("agents")
def train_agents(
agent_file: Optional[str] = typer.Argument(
None,
help="Path to agent YAML file or Python file"
),
iterations: int = typer.Option(
3,
"--iterations", "-n",
help="Number of training iterations"
),
human: bool = typer.Option(
False,
"--human", "-h",
help="Use human feedback instead of LLM grading"
),
scenarios: Optional[str] = typer.Option(
None,
"--scenarios", "-s",
help="Path to scenarios JSON file"
),
input_text: Optional[str] = typer.Option(
None,
"--input", "-i",
help="Single input text for training (alternative to scenarios file)"
),
expected: Optional[str] = typer.Option(
None,
"--expected", "-e",
help="Expected output for the input (optional)"
),
output_dir: Optional[str] = typer.Option(
None,
"--output", "-o",
help="Output directory for training data"
),
model: Optional[str] = typer.Option(
None,
"--model", "-m",
help="LLM model for grading (default: gpt-4o-mini)"
),
verbose: bool = typer.Option(
True,
"--verbose/--quiet", "-v/-q",
help="Show detailed progress"
),
dry_run: bool = typer.Option(
False,
"--dry-run",
help="Show what would happen without running"
),
storage_backend: Optional[str] = typer.Option(
None,
"--storage-backend",
help="Storage backend: 'file', 'sqlite', or 'redis://url'. Default: file"
),
storage_path: Optional[str] = typer.Option(
None,
"--storage-path",
help="Path for storage backend (file dir or sqlite db path)"
),
):
"""
Train agents through iterative feedback loops.
Two modes available:
1. LLM-as-Judge (default): Agent runs, LLM grades output,
provides suggestions, agent re-runs with feedback.
Fully automated, no human intervention needed.
2. Human-in-the-Loop (--human): Agent runs, you review
and provide feedback, agent re-runs with your feedback.
Examples:
# Simple: Train with a single input
praisonai train agents --input "What is Python?"
# With expected output
praisonai train agents --input "What is 2+2?" --expected "4"
# With scenarios file
praisonai train agents --scenarios scenarios.json
# Human feedback mode
praisonai train agents --input "Explain AI" --human
# More iterations
praisonai train agents --input "Hello" --iterations 5
# With agent file
praisonai train agents my_agent.yaml --scenarios scenarios.json
"""
from ..output.console import get_output_controller
output = get_output_controller()
# Validate inputs
if not input_text and not scenarios:
output.print_error(
"No training input provided",
remediation="Use --input 'text' or --scenarios file.json"
)
raise typer.Exit(1)
if dry_run:
output.print_panel(
f"Mode: {'Human-in-the-Loop' if human else 'LLM-as-Judge'}\n"
f"Iterations: {iterations}\n"
f"Input: {input_text or 'from scenarios file'}\n"
f"Scenarios file: {scenarios or 'N/A'}\n"
f"Agent file: {agent_file or 'default agent'}\n"
f"Output dir: {output_dir or '~/.praison/train/'}",
title="Dry Run - Would execute:"
)
return
# Lazy import to avoid overhead
try:
from praisonai.train.agents import AgentTrainer, TrainingScenario
except ImportError as e:
output.print_error(f"Failed to import training module: {e}")
raise typer.Exit(1)
# Create or load agent
agent = None
if agent_file:
agent = _load_agent_from_file(agent_file, output)
if agent is None:
raise typer.Exit(1)
else:
# Create a simple default agent
try:
from praisonaiagents import Agent
agent = Agent(
instructions="You are a helpful assistant. Provide clear, accurate, and helpful responses."
)
except ImportError:
output.print_error(
"praisonaiagents not installed",
remediation="pip install praisonaiagents"
)
raise typer.Exit(1)
# Create trainer
storage_dir = Path(output_dir) if output_dir else None
from praisonai.train.agents.grader import TrainingGrader
grader = TrainingGrader(model=model) if model else None
# Create storage backend if specified
backend = None
if storage_backend:
backend = _create_storage_backend(storage_backend, storage_path, output)
if backend is None and storage_backend not in ("file", None):
raise typer.Exit(1)
trainer = AgentTrainer(
agent=agent,
iterations=iterations,
human_mode=human,
grader=grader,
storage_dir=storage_dir,
storage_backend=backend,
verbose=verbose,
)
# Add scenarios
if scenarios:
scenarios_list = _load_scenarios_from_file(scenarios, output)
if scenarios_list is None:
raise typer.Exit(1)
trainer.add_scenarios(scenarios_list)
elif input_text:
trainer.add_scenario(TrainingScenario(
id="cli-input",
input_text=input_text,
expected_output=expected,
))
# Run training
try:
report = trainer.run()
if not verbose:
report.print_summary()
output.print_success(
f"Training complete! Session: {report.session_id}\n"
f"Average score: {report.avg_score:.1f}/10\n"
f"Improvement: {report.improvement:+.1f}"
)
except KeyboardInterrupt:
output.print_warning("Training interrupted by user")
raise typer.Exit(130)
except Exception as e:
output.print_error(f"Training failed: {e}")
raise typer.Exit(1)
@app.command("list")
def train_list(
limit: int = typer.Option(20, "--limit", "-n", help="Max sessions to show"),
json_output: bool = typer.Option(False, "--json", "-j", help="Output as JSON"),
):
"""List all training sessions."""
from ..output.console import get_output_controller
output = get_output_controller()
try:
from praisonai.train.agents.storage import list_training_sessions
except ImportError:
output.print_error("Training module not available")
raise typer.Exit(1)
sessions = list_training_sessions(limit=limit)
if json_output or output.is_json_mode:
output.print_json({
"sessions": [s.to_dict() for s in sessions]
})
return
if not sessions:
output.print_info("No training sessions found")
return
headers = ["Session ID", "Iterations", "Size", "Modified"]
rows = []
for s in sessions:
modified = s.modified_at.strftime("%Y-%m-%d %H:%M")
size_kb = s.size_bytes / 1024
size_str = f"{size_kb:.1f} KB" if size_kb >= 1 else f"{s.size_bytes} B"
rows.append([
s.session_id[:30] + "..." if len(s.session_id) > 30 else s.session_id,
str(s.iteration_count),
size_str,
modified,
])
output.print_table(headers, rows, title="Training Sessions")
@app.command("show")
def train_show(
session_id: str = typer.Argument(..., help="Session ID to show"),
iterations_flag: bool = typer.Option(False, "--iterations", "-i", help="Show detailed iteration info"),
json_output: bool = typer.Option(False, "--json", "-j", help="Output as JSON"),
):
"""
Show details of a training session.
Examples:
# Show session summary
praisonai train show train-abc123
# Show with detailed iterations
praisonai train show train-abc123 --iterations
# Output as JSON
praisonai train show train-abc123 --json
"""
from ..output.console import get_output_controller
output = get_output_controller()
try:
from praisonai.train.agents.storage import TrainingStorage
except ImportError:
output.print_error("Training module not available")
raise typer.Exit(1)
storage = TrainingStorage(session_id=session_id)
if not storage.storage_path.exists():
output.print_error(f"Session not found: {session_id}")
raise typer.Exit(1)
report = storage.load_report()
iterations = storage.load_iterations()
if json_output or output.is_json_mode:
output.print_json({
"session_id": session_id,
"report": report.to_dict() if report else None,
"iterations": [it.to_dict() for it in iterations],
})
return
if report:
report.print_summary()
# Show best iteration
best = report.get_best_iteration()
if best:
output.print(f"\n✨ Best Iteration: #{best.iteration_num} (Score: {best.score}/10)")
if iterations:
output.print("\nIterations:")
# Find best score for highlighting
best_score = max(it.score for it in iterations) if iterations else 0
for it in iterations:
# Highlight best iteration
marker = "★" if it.score == best_score else " "
feedback_preview = it.feedback[:50] + "..." if len(it.feedback) > 50 else it.feedback
output.print(f" {marker} [{it.iteration_num}] Score: {it.score}/10 - {feedback_preview}")
# Show suggestions if --iterations flag
if iterations_flag and it.suggestions:
for suggestion in it.suggestions[:3]: # Show top 3 suggestions
output.print(f" → {suggestion}")
@app.command("apply")
def train_apply(
session_id: str = typer.Argument(..., help="Training session ID to apply"),
agent_file: Optional[str] = typer.Option(
None,
"--agent", "-a",
help="Path to agent YAML file"
),
iteration: Optional[int] = typer.Option(
None,
"--iteration", "-n",
help="Specific iteration number (default: best score)"
),
run_prompt: Optional[str] = typer.Option(
None,
"--run", "-r",
help="Run agent with this prompt after applying training"
),
json_output: bool = typer.Option(False, "--json", "-j", help="Output as JSON"),
):
"""
Apply training to an agent.
Loads training suggestions from a session and applies them to an agent
via hooks. By default, uses the iteration with the best score.
Examples:
# Apply best iteration to default agent
praisonai train apply train-abc123
# Apply specific iteration
praisonai train apply train-abc123 --iteration 2
# Apply to agent from YAML file
praisonai train apply train-abc123 --agent my_agent.yaml
# Apply and run immediately
praisonai train apply train-abc123 --run "Hello, how are you?"
"""
from ..output.console import get_output_controller
output = get_output_controller()
try:
from praisonai.train.agents import apply_training, get_training_profile
from praisonai.train.agents.storage import TrainingStorage
except ImportError as e:
output.print_error(f"Training module not available: {e}")
raise typer.Exit(1)
# Verify session exists
storage = TrainingStorage(session_id=session_id)
if not storage.storage_path.exists():
output.print_error(f"Session not found: {session_id}")
raise typer.Exit(1)
# Get the training profile
profile = get_training_profile(
session_id=session_id,
iteration=iteration,
agent_name="agent",
)
if profile is None:
output.print_error(f"Could not load training profile from session {session_id}")
raise typer.Exit(1)
if json_output or output.is_json_mode:
output.print_json({
"session_id": session_id,
"iteration": profile.iteration_num,
"score": profile.quality_score,
"suggestions": profile.suggestions,
"summary": profile.summary,
})
if not run_prompt:
return
# Show profile info
output.print_panel(
f"Session: {profile.session_id}\n"
f"Iteration: {profile.iteration_num}\n"
f"Score: {profile.quality_score}/10\n"
f"Suggestions: {len(profile.suggestions)}",
title="Training Profile"
)
if profile.suggestions:
output.print("\nSuggestions:")
for i, suggestion in enumerate(profile.suggestions, 1):
output.print(f" {i}. {suggestion}")
# If run_prompt provided, create agent and run
if run_prompt:
# Create or load agent
agent = None
if agent_file:
agent = _load_agent_from_file(agent_file, output)
if agent is None:
raise typer.Exit(1)
else:
try:
from praisonaiagents import Agent
agent = Agent(
instructions="You are a helpful assistant."
)
except ImportError:
output.print_error(
"praisonaiagents not installed",
remediation="pip install praisonaiagents"
)
raise typer.Exit(1)
# Apply training
success = apply_training(agent, profile=profile)
if not success:
output.print_error("Failed to apply training to agent")
raise typer.Exit(1)
output.print_success("Training applied successfully!")
output.print(f"\nRunning agent with prompt: {run_prompt}\n")
# Run the agent
try:
if hasattr(agent, 'chat'):
response = agent.chat(run_prompt)
elif hasattr(agent, 'start'):
response = agent.start(run_prompt)
else:
output.print_error("Agent has no chat or start method")
raise typer.Exit(1)
output.print_panel(str(response), title="Agent Response")
except Exception as e:
output.print_error(f"Agent execution failed: {e}")
raise typer.Exit(1)
else:
output.print_info(
"\nTo apply this training to an agent, use:\n"
f" praisonai train apply {session_id} --run 'Your prompt here'\n"
"\nOr in Python:\n"
f" from praisonai.train.agents import apply_training\n"
f" apply_training(agent, session_id='{session_id}')"
)
def _load_agent_from_file(file_path: str, output) -> Optional[object]:
"""Load agent from YAML or Python file."""
path = Path(file_path)
if not path.exists():
output.print_error(f"File not found: {file_path}")
return None
if path.suffix in ('.yaml', '.yml'):
try:
from praisonai_code.cli.main import PraisonAI
praison = PraisonAI(agent_file=str(path))
# Return the agents object
if hasattr(praison, 'agents') and praison.agents:
return praison.agents
output.print_error("Could not load agents from YAML")
return None
except Exception as e:
output.print_error(f"Failed to load YAML: {e}")
return None
elif path.suffix == '.py':
output.print_warning(
"Python file support coming soon. "
"For now, use YAML files or --input flag."
)
return None
else:
output.print_error(f"Unsupported file type: {path.suffix}")
return None
def _create_storage_backend(backend_type: str, storage_path: Optional[str], output):
"""Create storage backend from CLI options."""
try:
if backend_type == "file":
from praisonaiagents.storage import FileBackend
return FileBackend(storage_dir=storage_path or "~/.praison/train")
elif backend_type == "sqlite":
from praisonaiagents.storage import SQLiteBackend
db_path = storage_path or "~/.praison/train.db"
return SQLiteBackend(db_path=db_path)
elif backend_type.startswith("redis://"):
from praisonaiagents.storage import RedisBackend
return RedisBackend(url=backend_type, prefix="train:")
else:
output.print_error(
f"Unknown storage backend: {backend_type}",
remediation="Use 'file', 'sqlite', or 'redis://url'"
)
return None
except ImportError as e:
output.print_error(f"Failed to import storage backend: {e}")
return None
except Exception as e:
output.print_error(f"Failed to create storage backend: {e}")
return None
def _load_scenarios_from_file(file_path: str, output) -> Optional[list]:
"""Load scenarios from JSON file."""
path = Path(file_path)
if not path.exists():
output.print_error(f"Scenarios file not found: {file_path}")
return None
try:
with open(path) as f:
data = json.load(f)
if isinstance(data, list):
return data
elif isinstance(data, dict) and "scenarios" in data:
return data["scenarios"]
else:
output.print_error("Invalid scenarios format. Expected list or {scenarios: [...]}")
return None
except json.JSONDecodeError as e:
output.print_error(f"Invalid JSON: {e}")
return None
+286 -15
View File
@@ -1,20 +1,291 @@
"""Backward-compatibility shim for :mod:`praisonai.cli.commands.validate`.
"""
YAML configuration validation command.
The implementation moved to :mod:`praisonai_code.cli.commands.validate` as part
of the praisonai-code extraction (issue #2516 / parent #2512).
This shim aliases the moved module into ``sys.modules`` under the old dotted
path so that:
* ``from praisonai.cli.commands.validate import X`` keeps working, and
* ``unittest.mock.patch("praisonai.cli.commands.validate.X")`` patches the very
same module object that the implementation executes against.
Validates agents/tasks/workflow YAML configurations with comprehensive error checking.
"""
import sys as _sys
import sys
from pathlib import Path
from typing import Optional
from praisonai_code.cli.commands import validate as _impl
import typer
from rich.console import Console
from rich.table import Table
# Make the old dotted path resolve to the exact same module object so that
# attribute patching / monkeypatching stays transparent across both paths.
_sys.modules[__name__] = _impl
app = typer.Typer(
help="Validate YAML configuration files",
no_args_is_help=True
)
console = Console()
@app.command()
def validate(
file: str = typer.Argument(
...,
help="Path to YAML configuration file to validate"
),
strict: bool = typer.Option(
False,
"--strict",
help="Treat warnings as errors (strict validation mode)"
),
quiet: bool = typer.Option(
False,
"--quiet", "-q",
help="Only show errors, suppress success messages"
),
json_output: bool = typer.Option(
False,
"--json",
help="Output validation results as JSON"
),
):
"""
Validate a YAML configuration file for agents, tasks, and workflows.
This command performs comprehensive validation including:
- Schema validation for required and optional fields
- Type checking for all configuration values
- Cross-reference validation (tasks -> agents, tools -> definitions)
- Detection of unknown fields with suggestions
Examples:
praisonai validate agents.yaml
praisonai validate agents.yaml --strict
praisonai validate my-config.yaml --json
"""
from praisonai.config.validator import ConfigValidator
import json as json_module
file_path = Path(file)
# Handle JSON output for missing file
if not file_path.exists():
if json_output:
output = {
"file": str(file_path),
"valid": False,
"errors": [f"File not found: {file}"],
"warnings": [],
"strict_mode": strict
}
sys.stdout.write(json_module.dumps(output, indent=2) + "\n")
else:
console.print(f"[red]✗ File not found:[/red] {file}", style="bold")
sys.exit(1)
# Initialize validator
validator = ConfigValidator()
# Validate the file
result = validator.validate_yaml_file(str(file_path), strict=strict)
# Output JSON if requested
if json_output:
output = {
"file": str(file_path),
"valid": result.valid,
"errors": result.errors,
"warnings": result.warnings,
"strict_mode": strict
}
sys.stdout.write(json_module.dumps(output, indent=2) + "\n")
sys.exit(0 if result.valid else 1)
# Display results
if result.valid:
if not quiet:
console.print(f"[green]✓ Configuration is valid[/green]: {file_path}", style="bold")
if result.warnings:
console.print(f"\n[yellow]Warnings ({len(result.warnings)}):[/yellow]")
for i, warning in enumerate(result.warnings, 1):
console.print(f" {i}. {warning}")
sys.exit(0)
else:
console.print(f"[red]✗ Configuration validation failed[/red]: {file_path}", style="bold")
if result.errors:
console.print(f"\n[red]Errors ({len(result.errors)}):[/red]")
for i, error in enumerate(result.errors, 1):
console.print(f" {i}. {error}")
if result.warnings and not strict:
console.print(f"\n[yellow]Warnings ({len(result.warnings)}):[/yellow]")
for i, warning in enumerate(result.warnings, 1):
console.print(f" {i}. {warning}")
if not quiet:
console.print(f"\n[dim]Fix the errors above and run validation again.[/dim]")
if result.warnings and not strict:
console.print("[dim]Use --strict to treat warnings as errors.[/dim]")
sys.exit(1)
@app.command()
def check(
directory: str = typer.Argument(
".",
help="Directory to search for YAML files"
),
pattern: str = typer.Option(
"*.yaml",
"--pattern", "-p",
help="Glob pattern for YAML files"
),
strict: bool = typer.Option(
False,
"--strict",
help="Treat warnings as errors"
),
stop_on_error: bool = typer.Option(
False,
"--stop-on-error",
help="Stop checking files after first error"
),
):
"""
Check all YAML configuration files in a directory.
Examples:
praisonai validate check .
praisonai validate check ./configs --pattern "*.yml"
praisonai validate check . --strict --stop-on-error
"""
from praisonai.config.validator import ConfigValidator
import glob
# Find all matching files
dir_path = Path(directory)
if not dir_path.exists():
console.print(f"[red]✗ Directory not found:[/red] {directory}", style="bold")
sys.exit(1)
# Find files matching pattern
yaml_files = list(dir_path.glob(pattern))
if pattern == "*.yaml":
yaml_files.extend(dir_path.glob("*.yml"))
if not yaml_files:
console.print(f"[yellow]No YAML files found matching pattern:[/yellow] {pattern}")
sys.exit(0)
# Initialize validator
validator = ConfigValidator()
# Create results table
table = Table(title=f"Validation Results for {len(yaml_files)} file(s)")
table.add_column("File", style="cyan")
table.add_column("Status", style="bold")
table.add_column("Errors", justify="center")
table.add_column("Warnings", justify="center")
total_valid = 0
total_invalid = 0
total_errors = 0
total_warnings = 0
for yaml_file in sorted(yaml_files):
result = validator.validate_yaml_file(str(yaml_file), strict=strict)
if result.valid:
status = "[green]✓ Valid[/green]"
total_valid += 1
else:
status = "[red]✗ Invalid[/red]"
total_invalid += 1
if stop_on_error:
console.print(f"\n[red]Validation failed for:[/red] {yaml_file}")
console.print(result.format_message())
sys.exit(1)
total_errors += len(result.errors)
total_warnings += len(result.warnings)
table.add_row(
str(yaml_file.relative_to(dir_path)),
status,
str(len(result.errors)) if result.errors else "-",
str(len(result.warnings)) if result.warnings else "-"
)
# Display results
console.print(table)
# Summary
console.print(f"\nSummary:")
console.print(f" Valid files: [green]{total_valid}[/green]")
console.print(f" Invalid files: [red]{total_invalid}[/red]")
console.print(f" Total errors: {total_errors}")
console.print(f" Total warnings: {total_warnings}")
sys.exit(0 if total_invalid == 0 else 1)
@app.command()
def schema():
"""
Display the YAML configuration schema documentation.
Shows all available fields, their types, and descriptions for:
- Agent/Role configuration
- Task configuration
- Workflow configuration
- Global settings
"""
from praisonai.config.schema import YAMLConfig, AgentConfig, TaskConfig
import json
console.print("[bold cyan]PraisonAI YAML Configuration Schema[/bold cyan]\n")
# Display main sections
console.print("[bold]Main Configuration Sections:[/bold]")
console.print(" • roles/agents - Define agent roles and capabilities")
console.print(" • tasks - Define tasks and their assignments")
console.print(" • workflow - Configure workflow execution")
console.print(" • tools/toolsets - Global tool configuration")
console.print(" • config - Global settings (acp, lsp)")
console.print()
# Display agent fields
console.print("[bold]Agent/Role Fields:[/bold]")
agent_schema = AgentConfig.model_json_schema()
required_fields = agent_schema.get('required', [])
for field_name, field_info in agent_schema.get('properties', {}).items():
required = " [red](required)[/red]" if field_name in required_fields else ""
description = field_info.get('description', 'No description')
field_type = field_info.get('type', 'any')
# Handle complex types
if 'anyOf' in field_info:
types = [t.get('type', 'object') for t in field_info['anyOf'] if 'type' in t]
field_type = ' | '.join(types) if types else 'mixed'
elif '$ref' in field_info:
field_type = field_info['$ref'].split('/')[-1]
console.print(f" • {field_name}{required} ({field_type}): {description}")
console.print()
# Display task fields
console.print("[bold]Task Fields:[/bold]")
task_schema = TaskConfig.model_json_schema()
required_fields = task_schema.get('required', [])
for field_name, field_info in task_schema.get('properties', {}).items():
required = " [red](required)[/red]" if field_name in required_fields else ""
description = field_info.get('description', 'No description')
field_type = field_info.get('type', 'any')
console.print(f" • {field_name}{required} ({field_type}): {description}")
console.print()
console.print("[dim]Use 'praisonai validate <file>' to validate your configuration.[/dim]")
if __name__ == "__main__":
app()
+357 -4
View File
@@ -1,7 +1,360 @@
"""Backward-compatibility shim for :mod:`praisonai.cli.langfuse_client`."""
"""
Langfuse API Client for PraisonAI CLI.
import sys as _sys
Provides a simple HTTP client for fetching traces, sessions, and observations
from the Langfuse API. Used by the `praisonai langfuse` CLI commands to enable
trace and session viewing without opening the web UI.
import praisonai_code.cli.langfuse_client as _impl
The client reads configuration from:
1. Explicit parameters passed to constructor
2. Environment variables (LANGFUSE_PUBLIC_KEY, LANGFUSE_SECRET_KEY, LANGFUSE_HOST)
3. ~/.praisonai/langfuse.env file (created by `praisonai langfuse config`)
_sys.modules[__name__] = _impl
Example:
from praisonai.cli.langfuse_client import LangfuseClient
# Load from config file automatically
client = LangfuseClient.from_config_file()
# Fetch traces
traces = client.get_traces(limit=10)
# Fetch sessions
sessions = client.get_sessions()
# Get trace details
trace = client.get_trace("trace-id-123")
"""
import os
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional
import requests
from requests.auth import HTTPBasicAuth
class LangfuseAPIError(Exception):
"""Raised when Langfuse API returns an error."""
def __init__(self, message: str, status_code: Optional[int] = None):
super().__init__(message)
self.status_code = status_code
class LangfuseClient:
"""
HTTP client for Langfuse API.
Provides methods to fetch traces, sessions, and detailed trace information
from a Langfuse instance (local Docker or cloud).
Authentication uses HTTP Basic Auth with public_key as username and
secret_key as password (standard Langfuse API authentication).
Attributes:
public_key: Langfuse public API key
secret_key: Langfuse secret API key
host: Langfuse host URL (default: http://localhost:3000)
"""
def __init__(
self,
public_key: str,
secret_key: str,
host: str = "http://localhost:3000"
):
"""
Initialize Langfuse API client.
Args:
public_key: Langfuse public API key
secret_key: Langfuse secret API key
host: Langfuse host URL (default: http://localhost:3000)
Raises:
ValueError: If public_key or secret_key is empty
"""
if not public_key or not secret_key:
raise ValueError("Both public_key and secret_key are required")
self.public_key = public_key
self.secret_key = secret_key
self.host = host.rstrip("/") # Remove trailing slash
self._auth = HTTPBasicAuth(public_key, secret_key)
@classmethod
def from_config_file(cls, config_path: Optional[Path] = None) -> "LangfuseClient":
"""
Create client from praisonai config file.
Reads configuration from ~/.praisonai/langfuse.env file which is
created by `praisonai langfuse config` command.
Args:
config_path: Path to config file (default: ~/.praisonai/langfuse.env)
Returns:
Configured LangfuseClient instance
Raises:
FileNotFoundError: If config file doesn't exist
ValueError: If config file is missing required credentials
"""
if config_path is None:
config_path = Path.home() / ".praisonai" / "langfuse.env"
if not config_path.exists():
raise FileNotFoundError(
f"Langfuse config not found at {config_path}. "
f"Run 'praisonai langfuse config --public-key ... --secret-key ...' to set up."
)
# Load env vars from file
env_vars = {}
with open(config_path, "r") as f:
for line in f:
line = line.strip()
if line and not line.startswith("#") and "=" in line:
key, value = line.split("=", 1)
env_vars[key.strip()] = value.strip()
public_key = env_vars.get("LANGFUSE_PUBLIC_KEY") or os.getenv("LANGFUSE_PUBLIC_KEY")
secret_key = env_vars.get("LANGFUSE_SECRET_KEY") or os.getenv("LANGFUSE_SECRET_KEY")
host = env_vars.get("LANGFUSE_HOST") or env_vars.get("LANGFUSE_BASE_URL") or os.getenv("LANGFUSE_HOST") or os.getenv("LANGFUSE_BASE_URL", "http://localhost:3000")
if not public_key or not secret_key:
raise ValueError(
f"Missing Langfuse credentials in {config_path}. "
f"Ensure LANGFUSE_PUBLIC_KEY and LANGFUSE_SECRET_KEY are set."
)
return cls(public_key=public_key, secret_key=secret_key, host=host)
def _make_request(self, endpoint: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""
Make authenticated GET request to Langfuse API.
Args:
endpoint: API endpoint path (e.g., "/api/public/traces")
params: Optional query parameters
Returns:
JSON response as dictionary
Raises:
LangfuseAPIError: If API request fails
"""
url = f"{self.host}{endpoint}"
try:
response = requests.get(url, auth=self._auth, params=params, timeout=30)
response.raise_for_status()
return response.json()
except requests.exceptions.HTTPError as e:
if response.status_code == 401:
raise LangfuseAPIError(
"Authentication failed. Check your LANGFUSE_PUBLIC_KEY and LANGFUSE_SECRET_KEY.",
status_code=401
)
elif response.status_code == 404:
raise LangfuseAPIError(
f"Resource not found: {endpoint}",
status_code=404
)
else:
raise LangfuseAPIError(
f"API error: {e}",
status_code=response.status_code
)
except requests.exceptions.ConnectionError:
raise LangfuseAPIError(
f"Cannot connect to Langfuse at {self.host}. "
f"Ensure the server is running (try 'praisonai langfuse status')."
)
except requests.exceptions.Timeout:
raise LangfuseAPIError("Request timed out. Langfuse server may be slow or unavailable.")
except Exception as e:
raise LangfuseAPIError(f"Unexpected error: {e}")
def get_traces(
self,
limit: int = 20,
session_id: Optional[str] = None,
user_id: Optional[str] = None,
name: Optional[str] = None,
from_timestamp: Optional[datetime] = None,
to_timestamp: Optional[datetime] = None
) -> List[Dict[str, Any]]:
"""
Fetch traces from Langfuse API.
Args:
limit: Maximum number of traces to return (default: 20, max: 100)
session_id: Filter by session ID
user_id: Filter by user ID
name: Filter by trace name (e.g., agent name)
from_timestamp: Filter traces after this time
to_timestamp: Filter traces before this time
Returns:
List of trace dictionaries
Example:
traces = client.get_traces(limit=10, session_id="abc-123")
for trace in traces:
print(f"Trace {trace['id']}: {trace['name']}")
"""
params: Dict[str, Any] = {"limit": min(limit, 100)}
if session_id:
params["sessionId"] = session_id
if user_id:
params["userId"] = user_id
if name:
params["name"] = name
if from_timestamp:
params["fromTimestamp"] = from_timestamp.isoformat()
if to_timestamp:
params["toTimestamp"] = to_timestamp.isoformat()
response = self._make_request("/api/public/traces", params)
return response.get("data", [])
def get_trace(self, trace_id: str) -> Dict[str, Any]:
"""
Fetch detailed information about a specific trace.
Args:
trace_id: Unique trace identifier
Returns:
Trace detail dictionary including observations and scores
Raises:
LangfuseAPIError: If trace not found or API error
Example:
trace = client.get_trace("trace-123")
print(f"Name: {trace['name']}")
print(f"Observations: {len(trace['observations'])}")
"""
return self._make_request(f"/api/public/traces/{trace_id}")
def get_sessions(
self,
limit: int = 20,
from_timestamp: Optional[datetime] = None,
to_timestamp: Optional[datetime] = None
) -> List[Dict[str, Any]]:
"""
Fetch sessions from Langfuse API.
Args:
limit: Maximum number of sessions to return (default: 20, max: 100)
from_timestamp: Filter sessions after this time
to_timestamp: Filter sessions before this time
Returns:
List of session dictionaries with trace counts
Example:
sessions = client.get_sessions(limit=10)
for session in sessions:
print(f"Session {session['id']}: {session['traceCount']} traces")
"""
params: Dict[str, Any] = {"limit": min(limit, 100)}
if from_timestamp:
params["fromTimestamp"] = from_timestamp.isoformat()
if to_timestamp:
params["toTimestamp"] = to_timestamp.isoformat()
response = self._make_request("/api/public/sessions", params)
return response.get("data", [])
def get_session(self, session_id: str) -> Dict[str, Any]:
"""
Fetch detailed information about a specific session.
Args:
session_id: Unique session identifier
Returns:
Session detail dictionary with traces
Raises:
LangfuseAPIError: If session not found or API error
"""
return self._make_request(f"/api/public/sessions/{session_id}")
def get_observations(
self,
trace_id: Optional[str] = None,
type: Optional[str] = None, # "SPAN", "EVENT", "GENERATION"
limit: int = 100
) -> List[Dict[str, Any]]:
"""
Fetch observations from Langfuse API.
Args:
trace_id: Filter by trace ID
type: Filter by observation type (SPAN, EVENT, GENERATION)
limit: Maximum number of observations (default: 100)
Returns:
List of observation dictionaries
"""
params: Dict[str, Any] = {"limit": min(limit, 100)}
if trace_id:
params["traceId"] = trace_id
if type:
params["type"] = type
response = self._make_request("/api/public/observations", params)
return response.get("data", [])
def get_stats(self) -> Dict[str, Any]:
"""
Get basic statistics about the Langfuse project.
Returns:
Dictionary with trace count, session count, and time range
"""
# Fetch traces with limit=1 to get total count from meta
response = self._make_request("/api/public/traces", {"limit": 1})
total_traces = response.get("meta", {}).get("totalCount", 0)
# Fetch sessions with limit=1 to get total count
sessions_response = self._make_request("/api/public/sessions", {"limit": 1})
total_sessions = sessions_response.get("meta", {}).get("totalCount", 0)
return {
"total_traces": total_traces,
"total_sessions": total_sessions,
"host": self.host
}
def load_env_from_file(file_path: Path) -> Dict[str, str]:
"""
Load environment variables from a .env file.
Args:
file_path: Path to the .env file
Returns:
Dictionary of environment variables
"""
env_vars = {}
if file_path.exists():
with open(file_path, "r") as f:
for line in f:
line = line.strip()
if line and not line.startswith("#") and "=" in line:
key, value = line.split("=", 1)
env_vars[key.strip()] = value.strip()
return env_vars
@@ -0,0 +1,2 @@
# C8 legacy modules — wrapper-owned YAML / framework / auto paths.
# Populated during C8.4 decomposition; import via bridge from praisonai-code.
@@ -0,0 +1,18 @@
"""Framework adapter lazy imports for legacy YAML / multi-framework runs."""
from __future__ import annotations
def fw_registry_module():
from praisonai.framework_adapters import registry
return registry
def fw_validators_module():
from praisonai.framework_adapters import validators
return validators
def fw_workflow_module():
from praisonai.framework_adapters import workflow_framework
return workflow_framework
@@ -0,0 +1,34 @@
"""Lazy loader helpers for wrapper-only modules (C8.4 extraction target)."""
from __future__ import annotations
def get_inbuilt_tools():
from praisonai import inbuilt_tools
return inbuilt_tools
def get_generate_config():
from praisonai.inc.config import generate_config
return generate_config
def get_auto_generator():
from praisonai.auto import AutoGenerator
return AutoGenerator
def get_agents_generator():
from praisonai.agents_generator import AgentsGenerator
return AgentsGenerator
def get_call_module():
import importlib.util
if not importlib.util.find_spec("praisonai.api.call"):
raise ImportError(
'Call feature is not installed. Install with: pip install "praisonai[call]"'
)
from praisonai.api import call as call_module
return call_module
+29 -96
View File
@@ -1,91 +1,44 @@
# C8+ Architecture Backlog
# C8 Architecture Backlog — Reverse Import Elimination
Planning document for work **after** C7 (hot path) and C7.1 (boundary hardening).
See [`C7.1_BOUNDARIES.md`](C7.1_BOUNDARIES.md) for completed scope and
[`ARCHITECTURE.md`](../../../ARCHITECTURE.md) §2 for the current three-tier model.
Planning document for **C8**: reduce lazy wrapper imports in `praisonai-code` from
225 → ~40–60 without new PyPI packages. See [`C7.1_BOUNDARIES.md`](C7.1_BOUNDARIES.md)
and [`ARCHITECTURE.md`](../../../ARCHITECTURE.md) §2.
**Status:** Not started — prioritise when product needs justify the migration cost.
**Status:** Complete on branch `feat/c8-reverse-import-elimination` — **0** reverse import lines (baseline 50).
---
## Current state (post v4.6.110)
## Phase tracker
| Phase | Scope | Baseline target | Status |
|-------|-------|-----------------|--------|
| C8.1 | `_WRAPPER_RESIDENT_COMMANDS`, import gate regex, tests | 50 | Done |
| C8.2A–E | Command repatriation + serve bridge | 0 | Done |
| C8.3 | cli/features/* repatriation | 0 | Done |
| C8.4 | legacy/ modules (inbuilt_tools, prompt_dispatch) | 0 | Done |
| C8.5 | Bridge normalisation + SDK protocols | 0 | Done |
**Out of scope:** `praisonai-bot`, `praisonai-frameworks` PyPI splits.
---
## Current state
| Item | Value |
|------|-------|
| Lazy wrapper import lines in `praisonai-code` | 225 (regression-gated) |
| Allowlisted files | ~47 (see `scripts/c7_wrapper_import_allowlist.txt`) |
| Lazy wrapper import lines | 0 (regression-gated; baseline 50) |
| Allowlisted files | 0 (empty — add only after review) |
| Agentic hot path | Standalone — no module-level `praisonai` import |
| Cross-tier access | `praisonai_code._wrapper_bridge` only |
---
## C8 goals (proposed)
## Repatriation pattern (per command/feature)
### 1. Zero reverse-import elimination
**Objective:** Reduce or remove the 225 lazy `from praisonai` / `import praisonai`
lines in `praisonai-code` without breaking standalone `run`/`chat`/`code`.
**Approach options:**
| Option | Pros | Cons |
|--------|------|------|
| **A. Move implementations down** | Cleaner dependency graph | Large moves; duplicates wrapper logic |
| **B. Extract shared protocols to SDK** | Protocol-first; minimal coupling | Does not remove all wrapper calls |
| **C. RPC / subprocess boundary** | Hard isolation | Latency, complexity |
| **D. Incremental allowlist shrink** | Low risk per PR | Long tail; needs per-file justification |
**Recommended phasing:** Option D first (shrink allowlist file-by-file), then B for
shared interfaces, defer A/C unless a feature explicitly requires hard isolation.
**Acceptance criteria:**
- [ ] Baseline count decreases with each approved migration PR
- [ ] `bash scripts/check_c7_imports.sh` still passes
- [ ] Standalone CI smoke unchanged
- [ ] No new PyPI dependency from `praisonai-code` → `praisonai`
---
### 2. Separate PyPI packages
**Objective:** Optional install targets for heavy subsystems currently bundled in
the `praisonai` wrapper.
| Proposed package | Would own | Depends on |
|------------------|-----------|------------|
| `praisonai-frameworks` | CrewAI, AutoGen, Agno, ADK adapters (already partially aliased in wrapper extras) | `praisonaiagents`, `praisonai-code` |
| `praisonai-bot` | BotOS, platform adapters (Telegram, Discord, Slack, …) | `praisonaiagents`, `praisonai-code` |
| `praisonai-gateway` | WebSocket gateway server + client registry | `praisonaiagents`, optional `praisonai-bot` |
**Publish order (unchanged core):** `praisonaiagents` → `praisonai-code` → optional extras → `praisonai` meta-package
**Acceptance criteria:**
- [ ] `pip install praisonai` remains the full install (meta-package pulls extras)
- [ ] `pip install praisonai-code` stays standalone for terminal agents
- [ ] Each new package has its own import gate / test shard
- [ ] Mintlify docs updated per package
---
### 3. Legacy `cli/main.py` rewrite
**Objective:** Replace the ~7k-line legacy argparse body in
`praisonai_code/cli/main.py` with Typer-native paths or thin delegation, without
breaking `praisonai agents.yaml` / multi-framework YAML workflows.
**Scope:**
- Invocation boundary only — not wholesale feature removal
- Preserve `praisonai.__main__` router (Typer-first, legacy fallback)
- Framework YAML paths continue to use `praisonai.framework_adapters` via bridge
**Acceptance criteria:**
- [ ] `test_c5_backward_compat.py` passes
- [ ] Legacy entry points documented in C7.1 invocation matrix
- [ ] No regression in `--framework crewai` / `--file agents.yaml` flows
1. Move impl from `praisonai_code/...` → `praisonai/...` (replace C5 shim).
2. Delete code copy; add command name to `_WRAPPER_RESIDENT_COMMANDS`.
3. Remove path from allowlist; lower baseline in `check_c7_imports.sh`.
4. Run gates: `check_c7_imports.sh`, `test_c7_1_boundaries.py`, `test_c5_backward_compat.py`.
---
@@ -104,19 +57,7 @@ flowchart TB
---
## Suggested prioritisation
| Priority | Item | Rationale |
|----------|------|-----------|
| P2 | Allowlist shrink (incremental) | Low risk; measurable progress |
| P2 | `praisonai-frameworks` split | Already partially aliased in wrapper extras |
| P3 | `praisonai-bot` split | Large surface; BotOS docs exist |
| P3 | Legacy `main.py` rewrite | High regression risk; defer until test coverage improves |
| P4 | Zero lazy imports | End state; not blocking releases |
---
## Verification (unchanged from C7.1)
## Verification
```bash
bash scripts/check_c7_imports.sh
@@ -126,18 +67,10 @@ pytest src/praisonai/tests/unit/test_c5_backward_compat.py
---
## Out of scope (product gaps, not C8 packaging)
## Out of scope (product gaps)
| Issue | Topic |
|-------|-------|
| #1328 | Channel plugin packs vs in-tree registration |
| #1325 | Canvas-class macOS shell vs Claw |
| #1872 | Runtime Open Federation |
---
## Sign-off
- [ ] C8 scope agreed by Architecture Review Council
- [ ] First incremental allowlist-shrink PR merged
- [ ] Optional package naming published on PyPI
@@ -84,6 +84,25 @@ def test_serve_doctor_skips_without_wrapper():
assert result.status == CheckStatus.SKIP
def test_wrapper_resident_commands_resolve_when_wrapper_installed():
"""Each _WRAPPER_RESIDENT command loads from praisonai.cli.commands.*."""
from praisonai_code._wrapper_bridge import wrapper_available
from praisonai_code.cli.app import _WRAPPER_RESIDENT_COMMANDS
if not wrapper_available():
pytest.skip("requires praisonai wrapper installed")
import importlib
# Commands without a Typer ``app`` (inline or click-only wiring in code app.py)
_skip_app_attr = frozenset({"standardise", "app", "audit"})
for name in sorted(_WRAPPER_RESIDENT_COMMANDS):
if name in _skip_app_attr:
continue
mod = importlib.import_module(f"praisonai.cli.commands.{name}")
assert hasattr(mod, "app"), f"praisonai.cli.commands.{name} must expose Typer app"
def test_run_file_crewai_requires_wrapper():
"""Multi-framework YAML is wrapper-enhanced; standalone must fail clearly."""
from praisonai_code._wrapper_bridge import wrapper_available