2087 lines
69 KiB
Python
2087 lines
69 KiB
Python
"""CLI commands for agent runner."""
|
|
|
|
import argparse
|
|
import asyncio
|
|
import json
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
|
|
def register_commands(subparsers: argparse._SubParsersAction) -> None:
|
|
"""Register runner commands with the main CLI."""
|
|
|
|
# run command
|
|
run_parser = subparsers.add_parser(
|
|
"run",
|
|
help="Run an exported agent",
|
|
description="Execute an exported agent with the given input.",
|
|
)
|
|
run_parser.add_argument(
|
|
"agent_path",
|
|
type=str,
|
|
help="Path to agent folder (containing agent.json)",
|
|
)
|
|
run_parser.add_argument(
|
|
"--input",
|
|
"-i",
|
|
type=str,
|
|
help="Input context as JSON string",
|
|
)
|
|
run_parser.add_argument(
|
|
"--input-file",
|
|
"-f",
|
|
type=str,
|
|
help="Input context from JSON file",
|
|
)
|
|
run_parser.add_argument(
|
|
"--output",
|
|
"-o",
|
|
type=str,
|
|
help="Write results to file instead of stdout",
|
|
)
|
|
run_parser.add_argument(
|
|
"--quiet",
|
|
"-q",
|
|
action="store_true",
|
|
help="Only output the final result JSON",
|
|
)
|
|
run_parser.add_argument(
|
|
"--verbose",
|
|
"-v",
|
|
action="store_true",
|
|
help="Show detailed execution logs (steps, LLM calls, etc.)",
|
|
)
|
|
run_parser.add_argument(
|
|
"--tui",
|
|
action="store_true",
|
|
help="Launch interactive terminal dashboard",
|
|
)
|
|
run_parser.add_argument(
|
|
"--model",
|
|
"-m",
|
|
type=str,
|
|
default=None,
|
|
help="LLM model to use (any LiteLLM-compatible name)",
|
|
)
|
|
run_parser.add_argument(
|
|
"--resume-session",
|
|
type=str,
|
|
default=None,
|
|
help="Resume from a specific session ID",
|
|
)
|
|
run_parser.add_argument(
|
|
"--checkpoint",
|
|
type=str,
|
|
default=None,
|
|
help="Resume from a specific checkpoint (requires --resume-session)",
|
|
)
|
|
run_parser.set_defaults(func=cmd_run)
|
|
|
|
# info command
|
|
info_parser = subparsers.add_parser(
|
|
"info",
|
|
help="Show agent information",
|
|
description="Display details about an exported agent.",
|
|
)
|
|
info_parser.add_argument(
|
|
"agent_path",
|
|
type=str,
|
|
help="Path to agent folder (containing agent.json)",
|
|
)
|
|
info_parser.add_argument(
|
|
"--json",
|
|
action="store_true",
|
|
help="Output as JSON",
|
|
)
|
|
info_parser.set_defaults(func=cmd_info)
|
|
|
|
# validate command
|
|
validate_parser = subparsers.add_parser(
|
|
"validate",
|
|
help="Validate an exported agent",
|
|
description="Check that an exported agent is valid and runnable.",
|
|
)
|
|
validate_parser.add_argument(
|
|
"agent_path",
|
|
type=str,
|
|
help="Path to agent folder (containing agent.json)",
|
|
)
|
|
validate_parser.set_defaults(func=cmd_validate)
|
|
|
|
# list command
|
|
list_parser = subparsers.add_parser(
|
|
"list",
|
|
help="List available agents",
|
|
description="List all exported agents in a directory.",
|
|
)
|
|
list_parser.add_argument(
|
|
"directory",
|
|
type=str,
|
|
nargs="?",
|
|
default="exports",
|
|
help="Directory to search (default: exports)",
|
|
)
|
|
list_parser.set_defaults(func=cmd_list)
|
|
|
|
# dispatch command (multi-agent)
|
|
dispatch_parser = subparsers.add_parser(
|
|
"dispatch",
|
|
help="Dispatch request to multiple agents",
|
|
description="Route a request to the best agent(s) using the orchestrator.",
|
|
)
|
|
dispatch_parser.add_argument(
|
|
"agents_dir",
|
|
type=str,
|
|
nargs="?",
|
|
default="exports",
|
|
help="Directory containing agent folders (default: exports)",
|
|
)
|
|
dispatch_parser.add_argument(
|
|
"--input",
|
|
"-i",
|
|
type=str,
|
|
required=True,
|
|
help="Input context as JSON string",
|
|
)
|
|
dispatch_parser.add_argument(
|
|
"--intent",
|
|
type=str,
|
|
help="Description of what you want to accomplish",
|
|
)
|
|
dispatch_parser.add_argument(
|
|
"--agents",
|
|
"-a",
|
|
type=str,
|
|
nargs="+",
|
|
help="Specific agent names to use (default: all in directory)",
|
|
)
|
|
dispatch_parser.add_argument(
|
|
"--quiet",
|
|
"-q",
|
|
action="store_true",
|
|
help="Only output the final result JSON",
|
|
)
|
|
dispatch_parser.set_defaults(func=cmd_dispatch)
|
|
|
|
# shell command (interactive agent session)
|
|
shell_parser = subparsers.add_parser(
|
|
"shell",
|
|
help="Interactive agent session",
|
|
description="Start an interactive REPL session with agents.",
|
|
)
|
|
shell_parser.add_argument(
|
|
"agent_path",
|
|
type=str,
|
|
nargs="?",
|
|
help="Path to agent folder (optional, can select interactively)",
|
|
)
|
|
shell_parser.add_argument(
|
|
"--agents-dir",
|
|
type=str,
|
|
default="exports",
|
|
help="Directory containing agents (default: exports)",
|
|
)
|
|
shell_parser.add_argument(
|
|
"--multi",
|
|
action="store_true",
|
|
help="Enable multi-agent mode with orchestrator",
|
|
)
|
|
shell_parser.add_argument(
|
|
"--no-approve",
|
|
action="store_true",
|
|
help="Disable human-in-the-loop approval (auto-approve all steps)",
|
|
)
|
|
shell_parser.set_defaults(func=cmd_shell)
|
|
|
|
# tui command (interactive agent dashboard)
|
|
tui_parser = subparsers.add_parser(
|
|
"tui",
|
|
help="Launch interactive TUI dashboard",
|
|
description="Browse available agents and launch the terminal dashboard.",
|
|
)
|
|
tui_parser.add_argument(
|
|
"--model",
|
|
"-m",
|
|
type=str,
|
|
default=None,
|
|
help="LLM model to use (any LiteLLM-compatible name)",
|
|
)
|
|
tui_parser.set_defaults(func=cmd_tui)
|
|
|
|
# code command (Hive Coder — framework agent builder)
|
|
code_parser = subparsers.add_parser(
|
|
"code",
|
|
help="Launch Hive Coder to build agents",
|
|
description="Interactive agent builder. Describe what you want and Hive Coder builds it.",
|
|
)
|
|
code_parser.add_argument(
|
|
"--model",
|
|
"-m",
|
|
type=str,
|
|
default=None,
|
|
help="LLM model to use (any LiteLLM-compatible name)",
|
|
)
|
|
code_parser.set_defaults(func=cmd_code)
|
|
|
|
# sessions command group (checkpoint/resume management)
|
|
sessions_parser = subparsers.add_parser(
|
|
"sessions",
|
|
help="Manage agent sessions",
|
|
description="List, inspect, and manage agent execution sessions.",
|
|
)
|
|
sessions_subparsers = sessions_parser.add_subparsers(
|
|
dest="sessions_cmd",
|
|
help="Session management commands",
|
|
)
|
|
|
|
# sessions list
|
|
sessions_list_parser = sessions_subparsers.add_parser(
|
|
"list",
|
|
help="List agent sessions",
|
|
description="List all sessions for an agent.",
|
|
)
|
|
sessions_list_parser.add_argument(
|
|
"agent_path",
|
|
type=str,
|
|
help="Path to agent folder",
|
|
)
|
|
sessions_list_parser.add_argument(
|
|
"--status",
|
|
choices=["all", "active", "failed", "completed", "paused"],
|
|
default="all",
|
|
help="Filter by session status (default: all)",
|
|
)
|
|
sessions_list_parser.add_argument(
|
|
"--has-checkpoints",
|
|
action="store_true",
|
|
help="Show only sessions with checkpoints",
|
|
)
|
|
sessions_list_parser.set_defaults(func=cmd_sessions_list)
|
|
|
|
# sessions show
|
|
sessions_show_parser = sessions_subparsers.add_parser(
|
|
"show",
|
|
help="Show session details",
|
|
description="Display detailed information about a specific session.",
|
|
)
|
|
sessions_show_parser.add_argument(
|
|
"agent_path",
|
|
type=str,
|
|
help="Path to agent folder",
|
|
)
|
|
sessions_show_parser.add_argument(
|
|
"session_id",
|
|
type=str,
|
|
help="Session ID to inspect",
|
|
)
|
|
sessions_show_parser.add_argument(
|
|
"--json",
|
|
action="store_true",
|
|
help="Output as JSON",
|
|
)
|
|
sessions_show_parser.set_defaults(func=cmd_sessions_show)
|
|
|
|
# sessions checkpoints
|
|
sessions_checkpoints_parser = sessions_subparsers.add_parser(
|
|
"checkpoints",
|
|
help="List session checkpoints",
|
|
description="List all checkpoints for a session.",
|
|
)
|
|
sessions_checkpoints_parser.add_argument(
|
|
"agent_path",
|
|
type=str,
|
|
help="Path to agent folder",
|
|
)
|
|
sessions_checkpoints_parser.add_argument(
|
|
"session_id",
|
|
type=str,
|
|
help="Session ID",
|
|
)
|
|
sessions_checkpoints_parser.set_defaults(func=cmd_sessions_checkpoints)
|
|
|
|
# pause command
|
|
pause_parser = subparsers.add_parser(
|
|
"pause",
|
|
help="Pause running session",
|
|
description="Request graceful pause of a running agent session.",
|
|
)
|
|
pause_parser.add_argument(
|
|
"agent_path",
|
|
type=str,
|
|
help="Path to agent folder",
|
|
)
|
|
pause_parser.add_argument(
|
|
"session_id",
|
|
type=str,
|
|
help="Session ID to pause",
|
|
)
|
|
pause_parser.set_defaults(func=cmd_pause)
|
|
|
|
# resume command
|
|
resume_parser = subparsers.add_parser(
|
|
"resume",
|
|
help="Resume session from checkpoint",
|
|
description="Resume a paused or failed session from a checkpoint.",
|
|
)
|
|
resume_parser.add_argument(
|
|
"agent_path",
|
|
type=str,
|
|
help="Path to agent folder",
|
|
)
|
|
resume_parser.add_argument(
|
|
"session_id",
|
|
type=str,
|
|
help="Session ID to resume",
|
|
)
|
|
resume_parser.add_argument(
|
|
"--checkpoint",
|
|
"-c",
|
|
type=str,
|
|
help="Specific checkpoint ID to resume from (default: latest)",
|
|
)
|
|
resume_parser.add_argument(
|
|
"--tui",
|
|
action="store_true",
|
|
help="Resume in TUI dashboard mode",
|
|
)
|
|
resume_parser.set_defaults(func=cmd_resume)
|
|
|
|
# setup-credentials command
|
|
setup_creds_parser = subparsers.add_parser(
|
|
"setup-credentials",
|
|
help="Interactive credential setup",
|
|
description="Guide through setting up required credentials for an agent.",
|
|
)
|
|
setup_creds_parser.add_argument(
|
|
"agent_path",
|
|
type=str,
|
|
nargs="?",
|
|
help="Path to agent folder (optional - runs general setup if not specified)",
|
|
)
|
|
setup_creds_parser.set_defaults(func=cmd_setup_credentials)
|
|
|
|
# serve command (HTTP API server)
|
|
serve_parser = subparsers.add_parser(
|
|
"serve",
|
|
help="Start HTTP API server",
|
|
description="Start an HTTP server exposing REST + SSE APIs for agent control.",
|
|
)
|
|
serve_parser.add_argument(
|
|
"--host",
|
|
type=str,
|
|
default="127.0.0.1",
|
|
help="Host to bind (default: 127.0.0.1)",
|
|
)
|
|
serve_parser.add_argument(
|
|
"--port",
|
|
"-p",
|
|
type=int,
|
|
default=8787,
|
|
help="Port to listen on (default: 8787)",
|
|
)
|
|
serve_parser.add_argument(
|
|
"--agent",
|
|
"-a",
|
|
type=str,
|
|
action="append",
|
|
default=[],
|
|
help="Agent path to preload (repeatable)",
|
|
)
|
|
serve_parser.add_argument(
|
|
"--model",
|
|
"-m",
|
|
type=str,
|
|
default=None,
|
|
help="LLM model for preloaded agents",
|
|
)
|
|
serve_parser.add_argument(
|
|
"--open",
|
|
action="store_true",
|
|
help="Open dashboard in browser after server starts",
|
|
)
|
|
serve_parser.set_defaults(func=cmd_serve)
|
|
|
|
|
|
def _load_resume_state(
|
|
agent_path: str, session_id: str, checkpoint_id: str | None = None
|
|
) -> dict | None:
|
|
"""Load session or checkpoint state for headless resume.
|
|
|
|
Args:
|
|
agent_path: Path to the agent folder (e.g., exports/my_agent)
|
|
session_id: Session ID to resume from
|
|
checkpoint_id: Optional checkpoint ID within the session
|
|
|
|
Returns:
|
|
session_state dict for executor, or None if not found
|
|
"""
|
|
agent_name = Path(agent_path).name
|
|
agent_work_dir = Path.home() / ".hive" / "agents" / agent_name
|
|
session_dir = agent_work_dir / "sessions" / session_id
|
|
|
|
if not session_dir.exists():
|
|
return None
|
|
|
|
if checkpoint_id:
|
|
# Checkpoint-based resume: load checkpoint and extract state
|
|
cp_path = session_dir / "checkpoints" / f"{checkpoint_id}.json"
|
|
if not cp_path.exists():
|
|
return None
|
|
try:
|
|
cp_data = json.loads(cp_path.read_text(encoding="utf-8"))
|
|
except (json.JSONDecodeError, OSError):
|
|
return None
|
|
return {
|
|
"resume_session_id": session_id,
|
|
"memory": cp_data.get("shared_memory", {}),
|
|
"paused_at": cp_data.get("next_node") or cp_data.get("current_node"),
|
|
"execution_path": cp_data.get("execution_path", []),
|
|
"node_visit_counts": {},
|
|
}
|
|
else:
|
|
# Session state resume: load state.json
|
|
state_path = session_dir / "state.json"
|
|
if not state_path.exists():
|
|
return None
|
|
try:
|
|
state_data = json.loads(state_path.read_text(encoding="utf-8"))
|
|
except (json.JSONDecodeError, OSError):
|
|
return None
|
|
progress = state_data.get("progress", {})
|
|
paused_at = progress.get("paused_at") or progress.get("resume_from")
|
|
return {
|
|
"resume_session_id": session_id,
|
|
"memory": state_data.get("memory", {}),
|
|
"paused_at": paused_at,
|
|
"execution_path": progress.get("path", []),
|
|
"node_visit_counts": progress.get("node_visit_counts", {}),
|
|
}
|
|
|
|
|
|
def _prompt_before_start(agent_path: str, runner, model: str | None = None):
|
|
"""Prompt user to start agent or update credentials.
|
|
|
|
Returns:
|
|
Updated runner if user proceeds, None if user aborts.
|
|
"""
|
|
from framework.credentials.setup import CredentialSetupSession
|
|
from framework.runner import AgentRunner
|
|
|
|
while True:
|
|
print()
|
|
try:
|
|
choice = input("Press Enter to start agent, or 'u' to update credentials: ").strip()
|
|
except (EOFError, KeyboardInterrupt):
|
|
print()
|
|
return None
|
|
|
|
if choice == "":
|
|
return runner
|
|
elif choice.lower() == "u":
|
|
session = CredentialSetupSession.from_agent_path(agent_path)
|
|
result = session.run_interactive()
|
|
if result.success:
|
|
# Reload runner with updated credentials
|
|
try:
|
|
runner = AgentRunner.load(agent_path, model=model)
|
|
except Exception as e:
|
|
print(f"Error reloading agent: {e}")
|
|
return None
|
|
# Loop back to prompt again
|
|
elif choice.lower() == "q":
|
|
return None
|
|
|
|
|
|
def cmd_run(args: argparse.Namespace) -> int:
|
|
"""Run an exported agent."""
|
|
import logging
|
|
|
|
from framework.credentials.models import CredentialError
|
|
from framework.runner import AgentRunner
|
|
|
|
# Set logging level (quiet by default for cleaner output)
|
|
if args.quiet:
|
|
logging.basicConfig(level=logging.ERROR, format="%(message)s")
|
|
elif getattr(args, "verbose", False):
|
|
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
|
else:
|
|
logging.basicConfig(level=logging.WARNING, format="%(message)s")
|
|
|
|
# Load input context
|
|
context = {}
|
|
if args.input:
|
|
try:
|
|
context = json.loads(args.input)
|
|
except json.JSONDecodeError as e:
|
|
print(f"Error parsing --input JSON: {e}", file=sys.stderr)
|
|
return 1
|
|
elif args.input_file:
|
|
try:
|
|
with open(args.input_file, encoding="utf-8") as f:
|
|
context = json.load(f)
|
|
except (FileNotFoundError, json.JSONDecodeError) as e:
|
|
print(f"Error reading input file: {e}", file=sys.stderr)
|
|
return 1
|
|
|
|
# Run the agent (with TUI or standard)
|
|
if getattr(args, "tui", False):
|
|
from framework.tui.app import AdenTUI
|
|
|
|
async def run_with_tui():
|
|
try:
|
|
# Load runner inside the async loop to ensure strict loop affinity
|
|
# (only one load — avoids spawning duplicate MCP subprocesses)
|
|
# AgentRunner handles credential setup interactively when stdin is a TTY.
|
|
try:
|
|
runner = AgentRunner.load(
|
|
args.agent_path,
|
|
model=args.model,
|
|
)
|
|
except CredentialError as e:
|
|
print(f"\n{e}", file=sys.stderr)
|
|
return
|
|
except Exception as e:
|
|
print(f"Error loading agent: {e}")
|
|
return
|
|
|
|
# Prompt before starting (allows credential updates)
|
|
if sys.stdin.isatty():
|
|
runner = _prompt_before_start(args.agent_path, runner, args.model)
|
|
if runner is None:
|
|
return
|
|
|
|
# Force setup inside the loop
|
|
if runner._agent_runtime is None:
|
|
try:
|
|
runner._setup()
|
|
except CredentialError as e:
|
|
print(f"\n{e}", file=sys.stderr)
|
|
return
|
|
|
|
# Start runtime before TUI so it's ready for user input
|
|
if runner._agent_runtime and not runner._agent_runtime.is_running:
|
|
await runner._agent_runtime.start()
|
|
|
|
app = AdenTUI(
|
|
runner._agent_runtime,
|
|
resume_session=getattr(args, "resume_session", None),
|
|
resume_checkpoint=getattr(args, "checkpoint", None),
|
|
)
|
|
|
|
# TUI handles execution via ChatRepl — user submits input,
|
|
# ChatRepl calls runtime.trigger_and_wait(). No auto-launch.
|
|
await app.run_async()
|
|
except Exception as e:
|
|
import traceback
|
|
|
|
traceback.print_exc()
|
|
print(f"TUI error: {e}")
|
|
|
|
await runner.cleanup_async()
|
|
return None
|
|
|
|
asyncio.run(run_with_tui())
|
|
print("TUI session ended.")
|
|
return 0
|
|
else:
|
|
# Standard execution — load runner here (not shared with TUI path)
|
|
# AgentRunner handles credential setup interactively when stdin is a TTY.
|
|
try:
|
|
runner = AgentRunner.load(
|
|
args.agent_path,
|
|
model=args.model,
|
|
)
|
|
except CredentialError as e:
|
|
print(f"\n{e}", file=sys.stderr)
|
|
return 1
|
|
except FileNotFoundError as e:
|
|
print(f"Error: {e}", file=sys.stderr)
|
|
return 1
|
|
|
|
# Prompt before starting (allows credential updates)
|
|
if sys.stdin.isatty() and not args.quiet:
|
|
runner = _prompt_before_start(args.agent_path, runner, args.model)
|
|
if runner is None:
|
|
return 1
|
|
|
|
# Load session/checkpoint state for resume (headless mode)
|
|
session_state = None
|
|
resume_session = getattr(args, "resume_session", None)
|
|
checkpoint = getattr(args, "checkpoint", None)
|
|
if resume_session:
|
|
session_state = _load_resume_state(args.agent_path, resume_session, checkpoint)
|
|
if session_state is None:
|
|
print(
|
|
f"Error: Could not load session state for {resume_session}",
|
|
file=sys.stderr,
|
|
)
|
|
return 1
|
|
if not args.quiet:
|
|
resume_node = session_state.get("paused_at", "unknown")
|
|
if checkpoint:
|
|
print(f"Resuming from checkpoint: {checkpoint}")
|
|
else:
|
|
print(f"Resuming session: {resume_session}")
|
|
print(f"Resume point: {resume_node}")
|
|
print()
|
|
|
|
# Auto-inject user_id if the agent expects it but it's not provided
|
|
entry_input_keys = runner.graph.nodes[0].input_keys if runner.graph.nodes else []
|
|
if "user_id" in entry_input_keys and context.get("user_id") is None:
|
|
import os
|
|
|
|
context["user_id"] = os.environ.get("USER", "default_user")
|
|
|
|
if not args.quiet:
|
|
info = runner.info()
|
|
print(f"Agent: {info.name}")
|
|
print(f"Goal: {info.goal_name}")
|
|
print(f"Steps: {info.node_count}")
|
|
print(f"Input: {json.dumps(context)}")
|
|
print()
|
|
print("=" * 60)
|
|
print("Executing agent...")
|
|
print("=" * 60)
|
|
print()
|
|
|
|
result = asyncio.run(runner.run(context, session_state=session_state))
|
|
|
|
# Format output
|
|
output = {
|
|
"success": result.success,
|
|
"steps_executed": result.steps_executed,
|
|
"output": result.output,
|
|
}
|
|
if result.error:
|
|
output["error"] = result.error
|
|
if result.paused_at:
|
|
output["paused_at"] = result.paused_at
|
|
|
|
# Output results
|
|
if args.output:
|
|
with open(args.output, "w", encoding="utf-8") as f:
|
|
json.dump(output, f, indent=2, default=str)
|
|
if not args.quiet:
|
|
print(f"Results written to {args.output}")
|
|
else:
|
|
if args.quiet:
|
|
print(json.dumps(output, indent=2, default=str))
|
|
else:
|
|
print()
|
|
print("=" * 60)
|
|
status_str = "SUCCESS" if result.success else "FAILED"
|
|
print(f"Status: {status_str}")
|
|
print(f"Steps executed: {result.steps_executed}")
|
|
print(f"Path: {' → '.join(result.path)}")
|
|
print("=" * 60)
|
|
|
|
if result.success:
|
|
print("\n--- Results ---")
|
|
# Show only meaningful output keys (skip internal/intermediate values)
|
|
meaningful_keys = ["final_response", "response", "result", "answer", "output"]
|
|
|
|
# Try to find the most relevant output
|
|
shown = False
|
|
for key in meaningful_keys:
|
|
if key in result.output:
|
|
value = result.output[key]
|
|
if isinstance(value, str) and len(value) > 10:
|
|
print(value)
|
|
shown = True
|
|
break
|
|
elif isinstance(value, (dict, list)):
|
|
print(json.dumps(value, indent=2, default=str))
|
|
shown = True
|
|
break
|
|
|
|
# If no meaningful key found, show all non-internal keys
|
|
if not shown:
|
|
for key, value in result.output.items():
|
|
if not key.startswith("_") and key not in [
|
|
"user_id",
|
|
"request",
|
|
"memory_loaded",
|
|
"user_profile",
|
|
"recent_context",
|
|
]:
|
|
if isinstance(value, (dict, list)):
|
|
print(f"\n{key}:")
|
|
value_str = json.dumps(value, indent=2, default=str)
|
|
if len(value_str) > 300:
|
|
value_str = value_str[:300] + "..."
|
|
print(value_str)
|
|
else:
|
|
val_str = str(value)
|
|
if len(val_str) > 200:
|
|
val_str = val_str[:200] + "..."
|
|
print(f"{key}: {val_str}")
|
|
elif result.error:
|
|
print(f"\nError: {result.error}")
|
|
|
|
runner.cleanup()
|
|
return 0 if result.success else 1
|
|
|
|
|
|
def cmd_info(args: argparse.Namespace) -> int:
|
|
"""Show agent information."""
|
|
from framework.credentials.models import CredentialError
|
|
from framework.runner import AgentRunner
|
|
|
|
try:
|
|
runner = AgentRunner.load(args.agent_path)
|
|
except CredentialError as e:
|
|
print(f"\n{e}", file=sys.stderr)
|
|
return 1
|
|
except FileNotFoundError as e:
|
|
print(f"Error: {e}", file=sys.stderr)
|
|
return 1
|
|
|
|
info = runner.info()
|
|
|
|
if args.json:
|
|
print(
|
|
json.dumps(
|
|
{
|
|
"name": info.name,
|
|
"description": info.description,
|
|
"goal_name": info.goal_name,
|
|
"goal_description": info.goal_description,
|
|
"node_count": info.node_count,
|
|
"nodes": info.nodes,
|
|
"edges": info.edges,
|
|
"success_criteria": info.success_criteria,
|
|
"constraints": info.constraints,
|
|
"required_tools": info.required_tools,
|
|
"has_tools_module": info.has_tools_module,
|
|
},
|
|
indent=2,
|
|
)
|
|
)
|
|
else:
|
|
print(f"Agent: {info.name}")
|
|
print(f"Description: {info.description}")
|
|
print()
|
|
print(f"Goal: {info.goal_name}")
|
|
print(f" {info.goal_description}")
|
|
print()
|
|
print(f"Nodes ({info.node_count}):")
|
|
for node in info.nodes:
|
|
inputs = f" [in: {', '.join(node['input_keys'])}]" if node.get("input_keys") else ""
|
|
outputs = f" [out: {', '.join(node['output_keys'])}]" if node.get("output_keys") else ""
|
|
print(f" - {node['id']}: {node['name']}{inputs}{outputs}")
|
|
print()
|
|
print(f"Success Criteria ({len(info.success_criteria)}):")
|
|
for sc in info.success_criteria:
|
|
print(f" - {sc['description']} ({sc['metric']} = {sc['target']})")
|
|
print()
|
|
print(f"Constraints ({len(info.constraints)}):")
|
|
for c in info.constraints:
|
|
print(f" - [{c['type']}] {c['description']}")
|
|
print()
|
|
print(f"Required Tools ({len(info.required_tools)}):")
|
|
for tool in info.required_tools:
|
|
status = "✓" if runner._tool_registry.has_tool(tool) else "✗"
|
|
print(f" {status} {tool}")
|
|
print()
|
|
print(f"Tools Module: {'✓ tools.py found' if info.has_tools_module else '✗ no tools.py'}")
|
|
|
|
runner.cleanup()
|
|
return 0
|
|
|
|
|
|
def cmd_validate(args: argparse.Namespace) -> int:
|
|
"""Validate an exported agent."""
|
|
from framework.credentials.models import CredentialError
|
|
from framework.runner import AgentRunner
|
|
|
|
try:
|
|
runner = AgentRunner.load(args.agent_path)
|
|
except CredentialError as e:
|
|
print(f"\n{e}", file=sys.stderr)
|
|
return 1
|
|
except FileNotFoundError as e:
|
|
print(f"Error: {e}", file=sys.stderr)
|
|
return 1
|
|
|
|
validation = runner.validate()
|
|
|
|
if validation.valid:
|
|
print("✓ Agent is valid")
|
|
else:
|
|
print("✗ Agent has errors:")
|
|
for error in validation.errors:
|
|
print(f" ERROR: {error}")
|
|
|
|
if validation.warnings:
|
|
print("\nWarnings:")
|
|
for warning in validation.warnings:
|
|
print(f" WARNING: {warning}")
|
|
|
|
if validation.missing_tools:
|
|
print("\nMissing tool implementations:")
|
|
for tool in validation.missing_tools:
|
|
print(f" - {tool}")
|
|
print("\nTo fix: Create tools.py in the agent folder or register tools programmatically")
|
|
|
|
runner.cleanup()
|
|
return 0 if validation.valid else 1
|
|
|
|
|
|
def cmd_list(args: argparse.Namespace) -> int:
|
|
"""List available agents."""
|
|
from framework.runner import AgentRunner
|
|
|
|
directory = Path(args.directory)
|
|
if not directory.exists():
|
|
# FIX: Handle missing directory gracefully on fresh install
|
|
print(f"No agents found in {directory}")
|
|
return 0
|
|
|
|
agents = []
|
|
for path in directory.iterdir():
|
|
if _is_valid_agent_dir(path):
|
|
try:
|
|
runner = AgentRunner.load(path)
|
|
info = runner.info()
|
|
agents.append(
|
|
{
|
|
"path": str(path),
|
|
"name": info.name,
|
|
"description": info.description[:60] + "..."
|
|
if len(info.description) > 60
|
|
else info.description,
|
|
"nodes": info.node_count,
|
|
"tools": len(info.required_tools),
|
|
}
|
|
)
|
|
runner.cleanup()
|
|
except Exception as e:
|
|
agents.append(
|
|
{
|
|
"path": str(path),
|
|
"error": str(e),
|
|
}
|
|
)
|
|
|
|
if not agents:
|
|
print(f"No agents found in {directory}")
|
|
return 0
|
|
|
|
print(f"Agents in {directory}:\n")
|
|
for agent in agents:
|
|
if "error" in agent:
|
|
print(f" {agent['path']}: ERROR - {agent['error']}")
|
|
else:
|
|
print(f" {agent['name']}")
|
|
print(f" Path: {agent['path']}")
|
|
print(f" Description: {agent['description']}")
|
|
print(f" Nodes: {agent['nodes']}, Tools: {agent['tools']}")
|
|
print()
|
|
|
|
return 0
|
|
|
|
|
|
def cmd_dispatch(args: argparse.Namespace) -> int:
|
|
"""Dispatch request to multiple agents via orchestrator."""
|
|
from framework.runner import AgentOrchestrator
|
|
|
|
# Parse input
|
|
try:
|
|
context = json.loads(args.input)
|
|
except json.JSONDecodeError as e:
|
|
print(f"Error parsing --input JSON: {e}", file=sys.stderr)
|
|
return 1
|
|
|
|
# Find agents
|
|
agents_dir = Path(args.agents_dir)
|
|
if not agents_dir.exists():
|
|
print(f"Directory not found: {agents_dir}", file=sys.stderr)
|
|
return 1
|
|
|
|
# Create orchestrator and register agents
|
|
orchestrator = AgentOrchestrator()
|
|
|
|
agent_paths = []
|
|
if args.agents:
|
|
# Use specific agents
|
|
for agent_name in args.agents:
|
|
agent_path = agents_dir / agent_name
|
|
if not _is_valid_agent_dir(agent_path):
|
|
print(f"Agent not found: {agent_path}", file=sys.stderr)
|
|
return 1
|
|
agent_paths.append((agent_name, agent_path))
|
|
else:
|
|
# Discover all agents
|
|
for path in agents_dir.iterdir():
|
|
if _is_valid_agent_dir(path):
|
|
agent_paths.append((path.name, path))
|
|
|
|
if not agent_paths:
|
|
print(f"No agents found in {agents_dir}", file=sys.stderr)
|
|
return 1
|
|
|
|
# Register agents
|
|
for name, path in agent_paths:
|
|
try:
|
|
orchestrator.register(name, path)
|
|
if not args.quiet:
|
|
print(f"Registered agent: {name}")
|
|
except Exception as e:
|
|
print(f"Failed to register {name}: {e}", file=sys.stderr)
|
|
|
|
if not args.quiet:
|
|
print()
|
|
print(f"Input: {json.dumps(context)}")
|
|
if args.intent:
|
|
print(f"Intent: {args.intent}")
|
|
print()
|
|
print("=" * 60)
|
|
print("Dispatching to agents...")
|
|
print("=" * 60)
|
|
print()
|
|
|
|
# Dispatch
|
|
result = asyncio.run(orchestrator.dispatch(context, intent=args.intent))
|
|
|
|
# Output results
|
|
if args.quiet:
|
|
output = {
|
|
"success": result.success,
|
|
"handled_by": result.handled_by,
|
|
"results": result.results,
|
|
"error": result.error,
|
|
}
|
|
print(json.dumps(output, indent=2, default=str))
|
|
else:
|
|
print()
|
|
print("=" * 60)
|
|
print(f"Success: {result.success}")
|
|
print(f"Handled by: {', '.join(result.handled_by) or 'none'}")
|
|
if result.error:
|
|
print(f"Error: {result.error}")
|
|
print("=" * 60)
|
|
|
|
if result.results:
|
|
print("\n--- Results by Agent ---")
|
|
for agent_name, data in result.results.items():
|
|
print(f"\n{agent_name}:")
|
|
status = data.get("status", "unknown")
|
|
print(f" Status: {status}")
|
|
if "completed_steps" in data:
|
|
print(f" Steps: {len(data['completed_steps'])}")
|
|
if "results" in data:
|
|
results_preview = json.dumps(data["results"], default=str)
|
|
if len(results_preview) > 200:
|
|
results_preview = results_preview[:200] + "..."
|
|
print(f" Results: {results_preview}")
|
|
|
|
if not args.quiet:
|
|
print(f"\nMessage trace: {len(result.messages)} messages")
|
|
|
|
orchestrator.cleanup()
|
|
return 0 if result.success else 1
|
|
|
|
|
|
def _interactive_approval(request):
|
|
"""Interactive approval callback for HITL mode."""
|
|
from framework.graph import ApprovalDecision, ApprovalResult
|
|
|
|
print()
|
|
print("=" * 60)
|
|
print("🔔 APPROVAL REQUIRED")
|
|
print("=" * 60)
|
|
print(f"\nStep: {request.step_id}")
|
|
print(f"Description: {request.step_description}")
|
|
|
|
if request.approval_message:
|
|
print(f"\nMessage: {request.approval_message}")
|
|
|
|
if request.preview:
|
|
print(f"\nPreview:\n{request.preview}")
|
|
|
|
if request.context:
|
|
print("\n--- Content to be sent ---")
|
|
for key, value in request.context.items():
|
|
print(f"\n[{key}]:")
|
|
if isinstance(value, (dict, list)):
|
|
import json
|
|
|
|
value_str = json.dumps(value, indent=2, default=str)
|
|
# Show more content for approval - up to 2000 chars
|
|
if len(value_str) > 2000:
|
|
value_str = value_str[:2000] + "\n... (truncated)"
|
|
print(value_str)
|
|
else:
|
|
value_str = str(value)
|
|
if len(value_str) > 500:
|
|
value_str = value_str[:500] + "... (truncated)"
|
|
print(f" {value_str}")
|
|
|
|
print()
|
|
print("Options:")
|
|
print(" [a] Approve - Execute as planned")
|
|
print(" [r] Reject - Skip this step")
|
|
print(" [s] Skip all - Reject and skip dependent steps")
|
|
print(" [x] Abort - Stop entire execution")
|
|
print()
|
|
|
|
while True:
|
|
try:
|
|
choice = input("Your choice (a/r/s/x): ").strip().lower()
|
|
except (EOFError, KeyboardInterrupt):
|
|
print("\nAborting...")
|
|
return ApprovalResult(decision=ApprovalDecision.ABORT, reason="User interrupted")
|
|
|
|
if choice == "a":
|
|
print("✓ Approved")
|
|
return ApprovalResult(decision=ApprovalDecision.APPROVE)
|
|
elif choice == "r":
|
|
reason = input("Reason (optional): ").strip() or "Rejected by user"
|
|
print(f"✗ Rejected: {reason}")
|
|
return ApprovalResult(decision=ApprovalDecision.REJECT, reason=reason)
|
|
elif choice == "s":
|
|
print("✗ Rejected (skipping dependent steps)")
|
|
return ApprovalResult(decision=ApprovalDecision.REJECT, reason="User skipped")
|
|
elif choice == "x":
|
|
reason = input("Reason (optional): ").strip() or "Aborted by user"
|
|
print(f"⛔ Aborted: {reason}")
|
|
return ApprovalResult(decision=ApprovalDecision.ABORT, reason=reason)
|
|
else:
|
|
print("Invalid choice. Please enter a, r, s, or x.")
|
|
|
|
|
|
def _format_natural_language_to_json(
|
|
user_input: str, input_keys: list[str], agent_description: str, session_context: dict = None
|
|
) -> dict:
|
|
"""Use Haiku to convert natural language input to JSON based on agent's input schema."""
|
|
import os
|
|
|
|
import anthropic
|
|
|
|
client = anthropic.Anthropic(api_key=os.environ.get("ANTHROPIC_API_KEY"))
|
|
|
|
# Build prompt for Haiku
|
|
session_info = ""
|
|
if session_context:
|
|
# Extract the main field (usually 'objective') that we'll append to
|
|
main_field = input_keys[0] if input_keys else "objective"
|
|
existing_value = session_context.get(main_field, "")
|
|
|
|
session_info = (
|
|
f'\n\nExisting {main_field}: "{existing_value}"\n\n'
|
|
f"The user is providing ADDITIONAL information. Append this new "
|
|
f"information to the existing {main_field} to create an enriched, "
|
|
"more detailed version."
|
|
)
|
|
|
|
prompt = f"""You are formatting user input for an agent that requires specific input fields.
|
|
|
|
Agent: {agent_description}
|
|
|
|
Required input fields: {", ".join(input_keys)}{session_info}
|
|
|
|
User input: {user_input}
|
|
|
|
{"If this is a follow-up, APPEND new info to the existing field value." if session_context else ""}
|
|
|
|
Output ONLY valid JSON, no explanation:"""
|
|
|
|
try:
|
|
message = client.messages.create(
|
|
model="claude-haiku-4-5-20251001", # Fast and cheap
|
|
max_tokens=500,
|
|
messages=[{"role": "user", "content": prompt}],
|
|
)
|
|
|
|
json_str = message.content[0].text.strip()
|
|
# Remove markdown code blocks if present
|
|
if json_str.startswith("```"):
|
|
json_str = json_str.split("```")[1]
|
|
if json_str.startswith("json"):
|
|
json_str = json_str[4:]
|
|
json_str = json_str.strip()
|
|
|
|
return json.loads(json_str)
|
|
except Exception:
|
|
# Fallback: try to infer the main field
|
|
if len(input_keys) == 1:
|
|
return {input_keys[0]: user_input}
|
|
else:
|
|
# Put it in the first field as fallback
|
|
return {input_keys[0]: user_input}
|
|
|
|
|
|
def cmd_shell(args: argparse.Namespace) -> int:
|
|
"""Start an interactive agent session."""
|
|
import logging
|
|
|
|
from framework.credentials.models import CredentialError
|
|
from framework.runner import AgentRunner
|
|
|
|
# Configure logging to show runtime visibility
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format="%(message)s", # Simple format for clean output
|
|
)
|
|
|
|
agents_dir = Path(args.agents_dir)
|
|
|
|
# Multi-agent mode with orchestrator
|
|
if args.multi:
|
|
return _interactive_multi(agents_dir)
|
|
|
|
# Single agent mode
|
|
agent_path = args.agent_path
|
|
if not agent_path:
|
|
# List available agents and let user choose
|
|
agent_path = _select_agent(agents_dir)
|
|
if not agent_path:
|
|
return 1
|
|
|
|
try:
|
|
runner = AgentRunner.load(agent_path)
|
|
except CredentialError as e:
|
|
print(f"\n{e}", file=sys.stderr)
|
|
return 1
|
|
except FileNotFoundError as e:
|
|
print(f"Error: {e}", file=sys.stderr)
|
|
return 1
|
|
|
|
# Set up approval callback by default (unless --no-approve is set)
|
|
if not getattr(args, "no_approve", False):
|
|
runner.set_approval_callback(_interactive_approval)
|
|
print("\n🔔 Human-in-the-loop mode enabled")
|
|
print(" Steps marked for approval will pause for your review")
|
|
else:
|
|
print("\n⚠️ Auto-approve mode: all steps will execute without review")
|
|
|
|
info = runner.info()
|
|
|
|
# Get entry node's input keys for smart formatting
|
|
entry_node = next((n for n in info.nodes if n["id"] == info.entry_node), None)
|
|
entry_input_keys = entry_node["input_keys"] if entry_node else []
|
|
|
|
print(f"\n{'=' * 60}")
|
|
print(f"Agent: {info.name}")
|
|
print(f"Goal: {info.goal_name}")
|
|
print(f"Description: {info.description[:100]}...")
|
|
print(f"{'=' * 60}")
|
|
print("\nInteractive mode. Enter natural language or JSON:")
|
|
print(" /info - Show agent details")
|
|
print(" /nodes - Show agent nodes")
|
|
print(" /reset - Reset conversation state")
|
|
print(" /quit - Exit interactive mode")
|
|
print(" {...} - JSON input to run agent")
|
|
print(" anything else - Natural language (auto-formatted with Haiku)")
|
|
print()
|
|
|
|
# Session state: accumulate context across multiple inputs
|
|
session_memory = {}
|
|
conversation_history = []
|
|
agent_session_state = None # Track paused agent state
|
|
|
|
while True:
|
|
try:
|
|
user_input = input(">>> ").strip()
|
|
except (EOFError, KeyboardInterrupt):
|
|
print("\nExiting...")
|
|
break
|
|
|
|
if not user_input:
|
|
continue
|
|
|
|
if user_input == "/quit":
|
|
break
|
|
|
|
if user_input == "/info":
|
|
print(f"\nAgent: {info.name}")
|
|
print(f"Goal: {info.goal_name}")
|
|
print(f"Description: {info.goal_description}")
|
|
print(f"Nodes: {info.node_count}")
|
|
print(f"Edges: {info.edge_count}")
|
|
print(f"Required tools: {', '.join(info.required_tools)}")
|
|
print()
|
|
continue
|
|
|
|
if user_input == "/nodes":
|
|
print("\nAgent nodes:")
|
|
for node in info.nodes:
|
|
inputs = f" [in: {', '.join(node['input_keys'])}]" if node.get("input_keys") else ""
|
|
outputs = (
|
|
f" [out: {', '.join(node['output_keys'])}]" if node.get("output_keys") else ""
|
|
)
|
|
print(f" {node['id']}: {node['name']}{inputs}{outputs}")
|
|
print(f" {node['description']}")
|
|
print()
|
|
continue
|
|
|
|
if user_input == "/reset":
|
|
session_memory = {}
|
|
conversation_history = []
|
|
agent_session_state = None # Clear agent's internal state too
|
|
print("✓ Conversation state and agent session cleared")
|
|
print()
|
|
continue
|
|
|
|
# Try to parse as JSON first
|
|
try:
|
|
context = json.loads(user_input)
|
|
print("✓ Parsed as JSON")
|
|
except json.JSONDecodeError:
|
|
# Not JSON - check for key=value format
|
|
if "=" in user_input and " " not in user_input.split("=")[0]:
|
|
context = {}
|
|
for part in user_input.split():
|
|
if "=" in part:
|
|
key, value = part.split("=", 1)
|
|
context[key] = value
|
|
print("✓ Parsed as key=value")
|
|
else:
|
|
# Natural language - use Haiku to format
|
|
print("🤖 Formatting with Haiku...")
|
|
try:
|
|
context = _format_natural_language_to_json(
|
|
user_input,
|
|
entry_input_keys,
|
|
info.description,
|
|
session_context=session_memory,
|
|
)
|
|
print(f"✓ Formatted to: {json.dumps(context)}")
|
|
except Exception as e:
|
|
print(f"Error formatting input: {e}")
|
|
print("Please try JSON format: {...} or key=value format")
|
|
continue
|
|
|
|
# Handle context differently based on whether we're resuming or starting fresh
|
|
if agent_session_state:
|
|
# RESUMING: Pass only the new input in the "input" key
|
|
# The executor will restore all session memory automatically
|
|
# The resume node expects fresh input, not merged session context
|
|
run_context = {"input": user_input} # Pass raw user input for resume nodes
|
|
print(f"\n🔄 Resuming from paused state: {agent_session_state.get('paused_at')}")
|
|
print(f"User's answer: {user_input}")
|
|
else:
|
|
# STARTING FRESH: Merge new input with accumulated session memory
|
|
run_context = {**session_memory, **context}
|
|
|
|
# Auto-inject user_id if missing (for personal assistant agents)
|
|
if "user_id" in entry_input_keys and run_context.get("user_id") is None:
|
|
import os
|
|
|
|
run_context["user_id"] = os.environ.get("USER", "default_user")
|
|
|
|
# Add conversation history to context if agent expects it
|
|
if conversation_history:
|
|
run_context["_conversation_history"] = conversation_history.copy()
|
|
|
|
print(f"\nRunning with: {json.dumps(context)}")
|
|
if session_memory:
|
|
print(f"Session context: {json.dumps(session_memory)}")
|
|
|
|
print("-" * 40)
|
|
|
|
# Pass agent session state to enable resumption
|
|
result = asyncio.run(runner.run(run_context, session_state=agent_session_state))
|
|
|
|
status_str = "SUCCESS" if result.success else "FAILED"
|
|
print(f"\nStatus: {status_str}")
|
|
print(f"Steps executed: {result.steps_executed}")
|
|
print(f"Path: {' → '.join(result.path)}")
|
|
|
|
# Show clean output - prioritize meaningful keys
|
|
if result.output:
|
|
meaningful_keys = ["final_response", "response", "result", "answer", "output"]
|
|
shown = False
|
|
|
|
for key in meaningful_keys:
|
|
if key in result.output:
|
|
value = result.output[key]
|
|
if isinstance(value, str) and len(value) > 10:
|
|
print(f"\n{value}\n")
|
|
shown = True
|
|
break
|
|
|
|
if not shown:
|
|
print("\nOutput:")
|
|
for key, value in result.output.items():
|
|
if not key.startswith("_"):
|
|
val_str = str(value)[:200]
|
|
print(f" {key}: {val_str}")
|
|
|
|
if result.error:
|
|
print(f"\nError: {result.error}")
|
|
|
|
if result.total_tokens > 0:
|
|
print(f"\nTokens used: {result.total_tokens}")
|
|
print(f"Latency: {result.total_latency_ms}ms")
|
|
|
|
# Update agent session state if paused
|
|
if result.paused_at:
|
|
agent_session_state = result.session_state
|
|
print(f"⏸ Agent paused at: {result.paused_at}")
|
|
print(" Next input will resume from this point")
|
|
else:
|
|
# Execution completed (not paused), clear session state
|
|
agent_session_state = None
|
|
|
|
# Update session memory with outputs from this run
|
|
# This allows follow-up inputs to reference previous context
|
|
if result.output:
|
|
for key, value in result.output.items():
|
|
# Don't store internal keys or very large values
|
|
if not key.startswith("_") and len(str(value)) < 5000:
|
|
session_memory[key] = value
|
|
|
|
# Track conversation history
|
|
conversation_history.append(
|
|
{
|
|
"input": context,
|
|
"output": result.output if result.output else {},
|
|
"status": "success" if result.success else "failed",
|
|
"paused_at": result.paused_at,
|
|
}
|
|
)
|
|
|
|
print()
|
|
|
|
runner.cleanup()
|
|
return 0
|
|
|
|
|
|
def _get_framework_agents_dir() -> Path:
|
|
"""Resolve the framework agents directory relative to this file."""
|
|
return Path(__file__).resolve().parent.parent / "agents"
|
|
|
|
|
|
def _launch_agent_tui(
|
|
agent_path: str | Path,
|
|
model: str | None = None,
|
|
) -> int:
|
|
"""Load an agent and launch the TUI. Shared by cmd_tui and cmd_code."""
|
|
from framework.credentials.models import CredentialError
|
|
from framework.runner import AgentRunner
|
|
from framework.tui.app import AdenTUI
|
|
|
|
async def run_with_tui():
|
|
# AgentRunner handles credential setup interactively when stdin is a TTY.
|
|
try:
|
|
runner = AgentRunner.load(
|
|
agent_path,
|
|
model=model,
|
|
)
|
|
except CredentialError as e:
|
|
print(f"\n{e}", file=sys.stderr)
|
|
return
|
|
except Exception as e:
|
|
print(f"Error loading agent: {e}")
|
|
return
|
|
|
|
if runner._agent_runtime is None:
|
|
try:
|
|
runner._setup()
|
|
except CredentialError as e:
|
|
print(f"\n{e}", file=sys.stderr)
|
|
return
|
|
|
|
if runner._agent_runtime and not runner._agent_runtime.is_running:
|
|
await runner._agent_runtime.start()
|
|
|
|
app = AdenTUI(runner._agent_runtime)
|
|
try:
|
|
await app.run_async()
|
|
except Exception as e:
|
|
import traceback
|
|
|
|
traceback.print_exc()
|
|
print(f"TUI error: {e}")
|
|
|
|
await runner.cleanup_async()
|
|
|
|
asyncio.run(run_with_tui())
|
|
print("TUI session ended.")
|
|
return 0
|
|
|
|
|
|
def cmd_tui(args: argparse.Namespace) -> int:
|
|
"""Launch the interactive TUI dashboard with in-app agent picker."""
|
|
import logging
|
|
|
|
logging.basicConfig(level=logging.WARNING, format="%(message)s")
|
|
|
|
from framework.tui.app import AdenTUI
|
|
|
|
async def run_tui():
|
|
app = AdenTUI(
|
|
model=args.model,
|
|
)
|
|
await app.run_async()
|
|
|
|
asyncio.run(run_tui())
|
|
print("TUI session ended.")
|
|
return 0
|
|
|
|
|
|
def cmd_code(args: argparse.Namespace) -> int:
|
|
"""Launch Hive Coder with multi-graph support.
|
|
|
|
Unlike ``_launch_agent_tui``, this sets up graph lifecycle tools and
|
|
assigns ``graph_id="hive_coder"`` so the coder can load, supervise,
|
|
and restart secondary agent graphs within the same session.
|
|
"""
|
|
import logging
|
|
|
|
logging.basicConfig(level=logging.WARNING, format="%(message)s")
|
|
|
|
framework_agents_dir = _get_framework_agents_dir()
|
|
hive_coder_path = framework_agents_dir / "hive_coder"
|
|
|
|
if not (hive_coder_path / "agent.py").exists():
|
|
print("Error: Hive Coder agent not found.", file=sys.stderr)
|
|
return 1
|
|
|
|
# Ensure framework agents dir is on sys.path for import
|
|
fa_str = str(framework_agents_dir)
|
|
if fa_str not in sys.path:
|
|
sys.path.insert(0, fa_str)
|
|
|
|
from framework.credentials.models import CredentialError
|
|
from framework.runner import AgentRunner
|
|
from framework.tools.session_graph_tools import register_graph_tools
|
|
from framework.tui.app import AdenTUI
|
|
|
|
async def run_with_tui():
|
|
try:
|
|
runner = AgentRunner.load(hive_coder_path, model=args.model)
|
|
except CredentialError as e:
|
|
print(f"\n{e}", file=sys.stderr)
|
|
return
|
|
except Exception as e:
|
|
print(f"Error loading agent: {e}")
|
|
return
|
|
|
|
if runner._agent_runtime is None:
|
|
try:
|
|
runner._setup()
|
|
except CredentialError as e:
|
|
print(f"\n{e}", file=sys.stderr)
|
|
return
|
|
|
|
runtime = runner._agent_runtime
|
|
|
|
# -- Multi-graph setup --
|
|
# Tag the primary graph so events carry graph_id="hive_coder"
|
|
runtime._graph_id = "hive_coder"
|
|
runtime._active_graph_id = "hive_coder"
|
|
|
|
# Register graph lifecycle tools (load_agent, unload_agent, etc.)
|
|
register_graph_tools(runner._tool_registry, runtime)
|
|
|
|
# Refresh tool schemas AND executor so streams see the new tools.
|
|
# The executor closure references the registry dict by ref, but
|
|
# refreshing both is robust against any copy-on-read behavior.
|
|
runtime._tools = list(runner._tool_registry.get_tools().values())
|
|
runtime._tool_executor = runner._tool_registry.get_executor()
|
|
|
|
if not runtime.is_running:
|
|
await runtime.start()
|
|
|
|
app = AdenTUI(runtime)
|
|
try:
|
|
await app.run_async()
|
|
except Exception as e:
|
|
import traceback
|
|
|
|
traceback.print_exc()
|
|
print(f"TUI error: {e}")
|
|
|
|
await runner.cleanup_async()
|
|
|
|
asyncio.run(run_with_tui())
|
|
print("TUI session ended.")
|
|
return 0
|
|
|
|
|
|
def _extract_python_agent_metadata(agent_path: Path) -> tuple[str, str]:
|
|
"""Extract name and description from a Python-based agent's config.py.
|
|
|
|
Uses AST parsing to safely extract values without executing code.
|
|
Returns (name, description) tuple, with fallbacks if parsing fails.
|
|
"""
|
|
import ast
|
|
|
|
config_path = agent_path / "config.py"
|
|
fallback_name = agent_path.name.replace("_", " ").title()
|
|
fallback_desc = "(Python-based agent)"
|
|
|
|
if not config_path.exists():
|
|
return fallback_name, fallback_desc
|
|
|
|
try:
|
|
with open(config_path, encoding="utf-8") as f:
|
|
tree = ast.parse(f.read())
|
|
|
|
# Find AgentMetadata class definition
|
|
for node in ast.walk(tree):
|
|
if isinstance(node, ast.ClassDef) and node.name == "AgentMetadata":
|
|
name = fallback_name
|
|
desc = fallback_desc
|
|
|
|
# Extract default values from class body
|
|
for item in node.body:
|
|
if isinstance(item, ast.AnnAssign) and isinstance(item.target, ast.Name):
|
|
field_name = item.target.id
|
|
if item.value:
|
|
# Handle simple string constants
|
|
if isinstance(item.value, ast.Constant):
|
|
if field_name == "name":
|
|
name = item.value.value
|
|
elif field_name == "description":
|
|
desc = item.value.value
|
|
# Handle parenthesized multi-line strings (concatenated)
|
|
elif isinstance(item.value, ast.JoinedStr):
|
|
# f-strings - skip, use fallback
|
|
pass
|
|
elif isinstance(item.value, ast.BinOp):
|
|
# String concatenation with + - try to evaluate
|
|
try:
|
|
result = _eval_string_binop(item.value)
|
|
if result and field_name == "name":
|
|
name = result
|
|
elif result and field_name == "description":
|
|
desc = result
|
|
except Exception:
|
|
pass
|
|
|
|
return name, desc
|
|
|
|
return fallback_name, fallback_desc
|
|
except Exception:
|
|
return fallback_name, fallback_desc
|
|
|
|
|
|
def _eval_string_binop(node) -> str | None:
|
|
"""Recursively evaluate a BinOp of string constants."""
|
|
import ast
|
|
|
|
if isinstance(node, ast.Constant) and isinstance(node.value, str):
|
|
return node.value
|
|
elif isinstance(node, ast.BinOp) and isinstance(node.op, ast.Add):
|
|
left = _eval_string_binop(node.left)
|
|
right = _eval_string_binop(node.right)
|
|
if left is not None and right is not None:
|
|
return left + right
|
|
return None
|
|
|
|
|
|
def _is_valid_agent_dir(path: Path) -> bool:
|
|
"""Check if a directory contains a valid agent (agent.json or agent.py)."""
|
|
if not path.is_dir():
|
|
return False
|
|
return (path / "agent.json").exists() or (path / "agent.py").exists()
|
|
|
|
|
|
def _has_agents(directory: Path) -> bool:
|
|
"""Check if a directory contains any valid agents (folders with agent.json or agent.py)."""
|
|
if not directory.exists():
|
|
return False
|
|
return any(_is_valid_agent_dir(p) for p in directory.iterdir())
|
|
|
|
|
|
def _getch() -> str:
|
|
"""Read a single character from stdin without waiting for Enter."""
|
|
try:
|
|
if sys.platform == "win32":
|
|
import msvcrt
|
|
|
|
ch = msvcrt.getch()
|
|
return ch.decode("utf-8", errors="ignore")
|
|
else:
|
|
import termios
|
|
import tty
|
|
|
|
fd = sys.stdin.fileno()
|
|
old_settings = termios.tcgetattr(fd)
|
|
try:
|
|
tty.setraw(fd)
|
|
ch = sys.stdin.read(1)
|
|
finally:
|
|
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
|
|
return ch
|
|
except Exception:
|
|
return ""
|
|
|
|
|
|
def _read_key() -> str:
|
|
"""Read a key, handling arrow key escape sequences."""
|
|
ch = _getch()
|
|
if ch == "\x1b": # Escape sequence start
|
|
ch2 = _getch()
|
|
if ch2 == "[":
|
|
ch3 = _getch()
|
|
if ch3 == "C": # Right arrow
|
|
return "RIGHT"
|
|
elif ch3 == "D": # Left arrow
|
|
return "LEFT"
|
|
return ch
|
|
|
|
|
|
def _select_agent(agents_dir: Path) -> str | None:
|
|
"""Let user select an agent from available agents with pagination."""
|
|
AGENTS_PER_PAGE = 10
|
|
|
|
if not agents_dir.exists():
|
|
print(f"Directory not found: {agents_dir}", file=sys.stderr)
|
|
# fixes issue #696, creates an exports folder if it does not exist
|
|
agents_dir.mkdir(parents=True, exist_ok=True)
|
|
print(f"Created directory: {agents_dir}", file=sys.stderr)
|
|
# return None
|
|
|
|
agents = []
|
|
for path in agents_dir.iterdir():
|
|
if _is_valid_agent_dir(path):
|
|
agents.append(path)
|
|
agents.sort(key=lambda p: p.name)
|
|
|
|
if not agents:
|
|
print(f"No agents found in {agents_dir}", file=sys.stderr)
|
|
return None
|
|
|
|
# Pagination setup
|
|
page = 0
|
|
total_pages = (len(agents) + AGENTS_PER_PAGE - 1) // AGENTS_PER_PAGE
|
|
|
|
while True:
|
|
start_idx = page * AGENTS_PER_PAGE
|
|
end_idx = min(start_idx + AGENTS_PER_PAGE, len(agents))
|
|
page_agents = agents[start_idx:end_idx]
|
|
|
|
# Show page header with indicator
|
|
if total_pages > 1:
|
|
print(f"\nAvailable agents in {agents_dir} (Page {page + 1}/{total_pages}):\n")
|
|
else:
|
|
print(f"\nAvailable agents in {agents_dir}:\n")
|
|
|
|
# Display agents for current page (with global numbering)
|
|
for i, agent_path in enumerate(page_agents, start_idx + 1):
|
|
try:
|
|
name, desc = _extract_python_agent_metadata(agent_path)
|
|
desc = desc[:50] + "..." if len(desc) > 50 else desc
|
|
print(f" {i}. {name}")
|
|
print(f" {desc}")
|
|
except Exception as e:
|
|
print(f" {i}. {agent_path.name} (error: {e})")
|
|
|
|
# Build navigation options
|
|
nav_options = []
|
|
if total_pages > 1:
|
|
nav_options.append("←/→ or p/n=navigate")
|
|
nav_options.append("q=quit")
|
|
|
|
print()
|
|
if total_pages > 1:
|
|
print(f" [{', '.join(nav_options)}]")
|
|
print()
|
|
|
|
# Show prompt
|
|
print("Select agent (number), use arrows to navigate, or q to quit: ", end="", flush=True)
|
|
|
|
try:
|
|
key = _read_key()
|
|
|
|
if key == "RIGHT" and page < total_pages - 1:
|
|
page += 1
|
|
print() # Newline before redrawing
|
|
elif key == "LEFT" and page > 0:
|
|
page -= 1
|
|
print()
|
|
elif key == "q":
|
|
print()
|
|
return None
|
|
elif key in ("n", ">") and page < total_pages - 1:
|
|
page += 1
|
|
print()
|
|
elif key in ("p", "<") and page > 0:
|
|
page -= 1
|
|
print()
|
|
elif key.isdigit():
|
|
# Build number with support for backspace
|
|
buffer = key
|
|
print(key, end="", flush=True)
|
|
|
|
while True:
|
|
ch = _getch()
|
|
if ch in ("\r", "\n"):
|
|
# Enter pressed - submit
|
|
print()
|
|
break
|
|
elif ch in ("\x7f", "\x08"):
|
|
# Backspace (DEL or BS)
|
|
if buffer:
|
|
buffer = buffer[:-1]
|
|
# Erase character: move back, print space, move back
|
|
print("\b \b", end="", flush=True)
|
|
elif ch.isdigit():
|
|
buffer += ch
|
|
print(ch, end="", flush=True)
|
|
elif ch == "\x1b":
|
|
# Escape - cancel input
|
|
print()
|
|
buffer = ""
|
|
break
|
|
elif ch == "\x03":
|
|
# Ctrl+C
|
|
print()
|
|
return None
|
|
# Ignore other characters
|
|
|
|
if buffer:
|
|
try:
|
|
idx = int(buffer) - 1
|
|
if 0 <= idx < len(agents):
|
|
return str(agents[idx])
|
|
print("Invalid selection")
|
|
except ValueError:
|
|
print("Invalid input")
|
|
elif key == "\r" or key == "\n":
|
|
print() # Just pressed enter, redraw
|
|
else:
|
|
print()
|
|
print("Invalid input")
|
|
except (EOFError, KeyboardInterrupt):
|
|
print()
|
|
return None
|
|
|
|
|
|
def _interactive_multi(agents_dir: Path) -> int:
|
|
"""Interactive multi-agent mode with orchestrator."""
|
|
from framework.runner import AgentOrchestrator
|
|
|
|
if not agents_dir.exists():
|
|
print(f"Directory not found: {agents_dir}", file=sys.stderr)
|
|
return 1
|
|
|
|
orchestrator = AgentOrchestrator()
|
|
agent_count = 0
|
|
|
|
# Register all agents
|
|
for path in agents_dir.iterdir():
|
|
if _is_valid_agent_dir(path):
|
|
try:
|
|
orchestrator.register(path.name, path)
|
|
agent_count += 1
|
|
except Exception as e:
|
|
print(f"Warning: Failed to register {path.name}: {e}")
|
|
|
|
if agent_count == 0:
|
|
print(f"No agents found in {agents_dir}", file=sys.stderr)
|
|
return 1
|
|
|
|
print(f"\n{'=' * 60}")
|
|
print("Multi-Agent Interactive Mode")
|
|
print(f"Registered {agent_count} agents")
|
|
print(f"{'=' * 60}")
|
|
print("\nCommands:")
|
|
print(" /agents - List registered agents")
|
|
print(" /quit - Exit")
|
|
print(" {...} - JSON input to dispatch")
|
|
print()
|
|
|
|
while True:
|
|
try:
|
|
user_input = input(">>> ").strip()
|
|
except (EOFError, KeyboardInterrupt):
|
|
print("\nExiting...")
|
|
break
|
|
|
|
if not user_input:
|
|
continue
|
|
|
|
if user_input == "/quit":
|
|
break
|
|
|
|
if user_input == "/agents":
|
|
print("\nRegistered agents:")
|
|
for agent in orchestrator.list_agents():
|
|
print(f" - {agent['name']}: {agent['description'][:60]}...")
|
|
print()
|
|
continue
|
|
|
|
# Parse intent if provided
|
|
intent = None
|
|
if user_input.startswith("/intent "):
|
|
parts = user_input.split(" ", 2)
|
|
if len(parts) >= 3:
|
|
intent = parts[1]
|
|
user_input = parts[2]
|
|
|
|
# Try to parse as JSON
|
|
try:
|
|
context = json.loads(user_input)
|
|
except json.JSONDecodeError:
|
|
print("Error: Invalid JSON input. Use {...} format.")
|
|
continue
|
|
|
|
print(f"\nDispatching: {json.dumps(context)}")
|
|
if intent:
|
|
print(f"Intent: {intent}")
|
|
print("-" * 40)
|
|
|
|
result = asyncio.run(orchestrator.dispatch(context, intent=intent))
|
|
|
|
print(f"\nSuccess: {result.success}")
|
|
print(f"Handled by: {', '.join(result.handled_by) or 'none'}")
|
|
|
|
if result.error:
|
|
print(f"Error: {result.error}")
|
|
|
|
if result.results:
|
|
print("\nResults by agent:")
|
|
for agent_name, data in result.results.items():
|
|
print(f"\n {agent_name}:")
|
|
status = data.get("status", "unknown")
|
|
print(f" Status: {status}")
|
|
if "results" in data:
|
|
results_preview = json.dumps(data["results"], default=str)
|
|
if len(results_preview) > 150:
|
|
results_preview = results_preview[:150] + "..."
|
|
print(f" Results: {results_preview}")
|
|
|
|
print(f"\nMessage trace: {len(result.messages)} messages")
|
|
print()
|
|
|
|
orchestrator.cleanup()
|
|
return 0
|
|
|
|
|
|
def cmd_sessions_list(args: argparse.Namespace) -> int:
|
|
"""List agent sessions."""
|
|
print("⚠ Sessions list command not yet implemented")
|
|
print("This will be available once checkpoint infrastructure is complete.")
|
|
print(f"\nAgent: {args.agent_path}")
|
|
print(f"Status filter: {args.status}")
|
|
print(f"Has checkpoints: {args.has_checkpoints}")
|
|
return 1
|
|
|
|
|
|
def cmd_sessions_show(args: argparse.Namespace) -> int:
|
|
"""Show detailed session information."""
|
|
print("⚠ Session show command not yet implemented")
|
|
print("This will be available once checkpoint infrastructure is complete.")
|
|
print(f"\nAgent: {args.agent_path}")
|
|
print(f"Session: {args.session_id}")
|
|
return 1
|
|
|
|
|
|
def cmd_sessions_checkpoints(args: argparse.Namespace) -> int:
|
|
"""List checkpoints for a session."""
|
|
print("⚠ Session checkpoints command not yet implemented")
|
|
print("This will be available once checkpoint infrastructure is complete.")
|
|
print(f"\nAgent: {args.agent_path}")
|
|
print(f"Session: {args.session_id}")
|
|
return 1
|
|
|
|
|
|
def cmd_pause(args: argparse.Namespace) -> int:
|
|
"""Pause a running session."""
|
|
print("⚠ Pause command not yet implemented")
|
|
print("This will be available once executor pause integration is complete.")
|
|
print(f"\nAgent: {args.agent_path}")
|
|
print(f"Session: {args.session_id}")
|
|
return 1
|
|
|
|
|
|
def cmd_resume(args: argparse.Namespace) -> int:
|
|
"""Resume a session from checkpoint."""
|
|
print("⚠ Resume command not yet implemented")
|
|
print("This will be available once checkpoint resume integration is complete.")
|
|
print(f"\nAgent: {args.agent_path}")
|
|
print(f"Session: {args.session_id}")
|
|
if args.checkpoint:
|
|
print(f"Checkpoint: {args.checkpoint}")
|
|
if args.tui:
|
|
print("Mode: TUI")
|
|
return 1
|
|
|
|
|
|
def cmd_setup_credentials(args: argparse.Namespace) -> int:
|
|
"""Interactive credential setup for an agent."""
|
|
from framework.credentials.setup import CredentialSetupSession
|
|
|
|
agent_path = getattr(args, "agent_path", None)
|
|
|
|
if agent_path:
|
|
# Setup credentials for a specific agent
|
|
session = CredentialSetupSession.from_agent_path(agent_path)
|
|
else:
|
|
# No agent specified - show usage
|
|
print("Usage: hive setup-credentials <agent_path>")
|
|
print()
|
|
print("Examples:")
|
|
print(" hive setup-credentials exports/my-agent")
|
|
print(" hive setup-credentials examples/templates/deep_research_agent")
|
|
return 1
|
|
|
|
result = session.run_interactive()
|
|
return 0 if result.success else 1
|
|
|
|
|
|
def _open_browser(url: str) -> None:
|
|
"""Open URL in the default browser (best-effort, non-blocking)."""
|
|
import subprocess
|
|
import sys
|
|
|
|
try:
|
|
if sys.platform == "darwin":
|
|
subprocess.Popen(
|
|
["open", url],
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
encoding="utf-8",
|
|
)
|
|
elif sys.platform == "linux":
|
|
subprocess.Popen(
|
|
["xdg-open", url],
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
encoding="utf-8",
|
|
)
|
|
except Exception:
|
|
pass # Best-effort — don't crash if browser can't open
|
|
|
|
|
|
def _build_frontend() -> bool:
|
|
"""Build the frontend if source is newer than dist. Returns True if dist exists."""
|
|
import subprocess
|
|
|
|
# Find the frontend directory relative to this file or cwd
|
|
candidates = [
|
|
Path("core/frontend"),
|
|
Path(__file__).resolve().parent.parent.parent / "frontend",
|
|
]
|
|
frontend_dir: Path | None = None
|
|
for c in candidates:
|
|
if (c / "package.json").is_file():
|
|
frontend_dir = c.resolve()
|
|
break
|
|
|
|
if frontend_dir is None:
|
|
return False
|
|
|
|
dist_dir = frontend_dir / "dist"
|
|
src_dir = frontend_dir / "src"
|
|
|
|
# Skip build if dist is up-to-date (newest src file older than dist index.html)
|
|
index_html = dist_dir / "index.html"
|
|
if index_html.exists() and src_dir.is_dir():
|
|
dist_mtime = index_html.stat().st_mtime
|
|
needs_build = False
|
|
for f in src_dir.rglob("*"):
|
|
if f.is_file() and f.stat().st_mtime > dist_mtime:
|
|
needs_build = True
|
|
break
|
|
if not needs_build:
|
|
return True
|
|
|
|
# Need to build
|
|
print("Building frontend...")
|
|
try:
|
|
# Ensure deps are installed
|
|
subprocess.run(
|
|
["npm", "install", "--no-fund", "--no-audit"],
|
|
encoding="utf-8",
|
|
cwd=frontend_dir,
|
|
check=True,
|
|
capture_output=True,
|
|
)
|
|
subprocess.run(
|
|
["npm", "run", "build"],
|
|
encoding="utf-8",
|
|
cwd=frontend_dir,
|
|
check=True,
|
|
capture_output=True,
|
|
)
|
|
print("Frontend built.")
|
|
return True
|
|
except FileNotFoundError:
|
|
print("Node.js not found — skipping frontend build.")
|
|
return dist_dir.is_dir()
|
|
except subprocess.CalledProcessError as exc:
|
|
stderr = exc.stderr.decode(errors="replace") if exc.stderr else ""
|
|
print(f"Frontend build failed: {stderr[:500]}")
|
|
return dist_dir.is_dir()
|
|
|
|
|
|
def cmd_serve(args: argparse.Namespace) -> int:
|
|
"""Start the HTTP API server."""
|
|
import logging
|
|
|
|
from aiohttp import web
|
|
|
|
_build_frontend()
|
|
|
|
from framework.server.app import create_app
|
|
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
|
)
|
|
|
|
model = getattr(args, "model", None)
|
|
app = create_app(model=model)
|
|
|
|
async def run_server():
|
|
manager = app["manager"]
|
|
|
|
# Preload agents specified via --agent
|
|
for agent_path in args.agent:
|
|
try:
|
|
session = await manager.create_session_with_worker(agent_path, model=model)
|
|
info = session.worker_info
|
|
name = info.name if info else session.worker_id
|
|
print(f"Loaded agent: {session.worker_id} ({name})")
|
|
except Exception as e:
|
|
print(f"Error loading {agent_path}: {e}")
|
|
|
|
# Start server using AppRunner/TCPSite (same pattern as webhook_server.py)
|
|
runner = web.AppRunner(app, access_log=None)
|
|
await runner.setup()
|
|
site = web.TCPSite(runner, args.host, args.port)
|
|
await site.start()
|
|
|
|
# Check if frontend is being served
|
|
dist_candidates = [
|
|
Path("frontend/dist"),
|
|
Path("core/frontend/dist"),
|
|
]
|
|
has_frontend = any((c / "index.html").exists() for c in dist_candidates if c.is_dir())
|
|
dashboard_url = f"http://{args.host}:{args.port}"
|
|
|
|
print()
|
|
print(f"Hive API server running on {dashboard_url}")
|
|
if has_frontend:
|
|
print(f"Dashboard: {dashboard_url}")
|
|
print(f"Health: {dashboard_url}/api/health")
|
|
print(f"Agents loaded: {sum(1 for s in manager.list_sessions() if s.worker_runtime)}")
|
|
print()
|
|
print("Press Ctrl+C to stop")
|
|
|
|
# Auto-open browser if --open flag is set and frontend exists
|
|
if getattr(args, "open", False) and has_frontend:
|
|
_open_browser(dashboard_url)
|
|
|
|
# Run forever until interrupted
|
|
try:
|
|
await asyncio.Event().wait()
|
|
except asyncio.CancelledError:
|
|
pass
|
|
finally:
|
|
await manager.shutdown_all()
|
|
await runner.cleanup()
|
|
|
|
try:
|
|
asyncio.run(run_server())
|
|
except KeyboardInterrupt:
|
|
print("\nServer stopped.")
|
|
|
|
return 0
|