mirror of
https://github.com/MervinPraison/PraisonAI.git
synced 2026-11-07 17:33:33 -10:00
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:
1 parent
7546bde9ba
commit
66dcb788ae
86 files changed
+13665
-13861
No files matched your search
@@ -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.
|
||||
@@ -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}")
|
||||
@@ -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()
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
@@ -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
|
||||
|
||||
@@ -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}")
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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}")
|
||||
@@ -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
@@ -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()
|
||||
@@ -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]")
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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())
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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.")
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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()
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user