Files
hive/core/framework/runner/cli.py
T
2026-04-03 20:31:14 -07:00

1535 lines
52 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(
"--debug",
action="store_true",
help="Show all debug-level logs",
)
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)
# 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(
"--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)
# 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.add_argument("--verbose", "-v", action="store_true", help="Enable INFO log level")
serve_parser.add_argument("--debug", action="store_true", help="Enable DEBUG log level")
serve_parser.set_defaults(func=cmd_serve)
# open command (serve + auto-open browser)
open_parser = subparsers.add_parser(
"open",
help="Start HTTP server and open dashboard in browser",
description="Shortcut for 'hive serve --open'. "
"Starts the HTTP server and opens the dashboard.",
)
open_parser.add_argument(
"--host",
type=str,
default="127.0.0.1",
help="Host to bind (default: 127.0.0.1)",
)
open_parser.add_argument(
"--port",
"-p",
type=int,
default=8787,
help="Port to listen on (default: 8787)",
)
open_parser.add_argument(
"--agent",
"-a",
type=str,
action="append",
default=[],
help="Agent path to preload (repeatable)",
)
open_parser.add_argument(
"--model",
"-m",
type=str,
default=None,
help="LLM model for preloaded agents",
)
open_parser.add_argument("--verbose", "-v", action="store_true", help="Enable INFO log level")
open_parser.add_argument("--debug", action="store_true", help="Enable DEBUG log level")
open_parser.set_defaults(func=cmd_open)
def _load_resume_state(
agent_path: str, session_id: str, checkpoint_id: str | None = None
) -> dict | None:
"""Load checkpoint state for headless resume.
All resumes require a checkpoint. If ``checkpoint_id`` is not provided
the latest checkpoint is auto-discovered.
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 no checkpoint 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
# Auto-discover latest checkpoint when not specified
if not checkpoint_id:
cp_dir = session_dir / "checkpoints"
if cp_dir.exists():
checkpoints = sorted(
cp_dir.glob("*.json"),
key=lambda p: p.stat().st_mtime,
reverse=True,
)
if checkpoints:
checkpoint_id = checkpoints[0].stem
if not checkpoint_id:
return None
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,
"resume_from_checkpoint": checkpoint_id,
"run_id": cp_data.get("run_id") or None,
"data_buffer": cp_data.get("data_buffer", 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": cp_data.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."""
from framework.credentials.models import CredentialError
from framework.observability import configure_logging
from framework.runner import AgentRunner
# Set logging level (quiet by default for cleaner output)
if args.quiet:
configure_logging(level="ERROR")
elif getattr(args, "debug", False):
configure_logging(level="DEBUG")
elif getattr(args, "verbose", False):
configure_logging(level="INFO")
else:
configure_logging(level="WARNING")
# 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
# Validate --output path before execution begins (fail fast, before agent loads)
if args.output:
import os
output_parent = Path(args.output).parent
if not output_parent.exists():
print(
f"Error: output directory does not exist: {output_parent}/",
file=sys.stderr,
)
return 1
if not os.access(output_parent, os.W_OK):
print(
f"Error: output directory is not writable: {output_parent}/",
file=sys.stderr,
)
return 1
# Standard execution
# 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 _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:
"""Convert natural language input to JSON based on agent's input schema.
Maps user input to the primary input field. For follow-up inputs,
appends to the existing value.
"""
main_field = input_keys[0] if input_keys else "objective"
if session_context:
existing_value = session_context.get(main_field, "")
if existing_value:
return {main_field: f"{existing_value}\n\n{user_input}"}
return {main_field: user_input}
def cmd_shell(args: argparse.Namespace) -> int:
"""Start an interactive agent session."""
from framework.credentials.models import CredentialError
from framework.observability import configure_logging
from framework.runner import AgentRunner
configure_logging(level="INFO")
agents_dir = Path(args.agents_dir)
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 _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 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 _find_chrome_bin() -> str | None:
"""Return the path to a Chrome/Chromium binary, or None if not found."""
import shutil
for candidate in (
"google-chrome",
"google-chrome-stable",
"chromium",
"chromium-browser",
"microsoft-edge",
"microsoft-edge-stable",
):
if shutil.which(candidate):
return candidate
mac_paths = [
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
Path.home() / "Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
]
for p in mac_paths:
if Path(p).exists():
return str(p)
return None
def _open_browser(url: str) -> None:
"""Open URL in the browser (best-effort, non-blocking)."""
import subprocess
chrome = _find_chrome_bin()
try:
if chrome:
subprocess.Popen(
[chrome, url],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
return
except Exception:
pass
# Fallback: open with system default browser
try:
if sys.platform == "darwin":
subprocess.Popen(
["open", url],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
encoding="utf-8",
)
elif sys.platform == "win32":
subprocess.Popen(
["cmd", "/c", "start", "", url],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
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 _ping_hive_gateway_availability(from_source: str) -> None:
"""Ping Hive gateway availability for lightweight reachability logging."""
from urllib import error, parse, request
base_url = "https://api.adenhq.com/v1/gateway/availability"
query = parse.urlencode({"from": from_source})
url = f"{base_url}?{query}"
try:
with request.urlopen(url, timeout=5) as response:
response.read()
except (error.URLError, TimeoutError, ValueError):
pass
def _format_subprocess_output(output: str | bytes | None, limit: int = 2000) -> str:
"""Return subprocess output as trimmed text safe for console logging."""
if not output:
return ""
if isinstance(output, bytes):
text = output.decode(errors="replace")
else:
text = output
text = text.strip()
if len(text) <= limit:
return text
return text[-limit:]
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...")
npm_cmd = "npm.cmd" if sys.platform == "win32" else "npm"
try:
# Incremental tsc caches can drift across branch changes and block builds.
for cache_file in frontend_dir.glob("tsconfig*.tsbuildinfo"):
cache_file.unlink(missing_ok=True)
# Ensure deps are installed
subprocess.run(
[npm_cmd, "install", "--no-fund", "--no-audit"],
encoding="utf-8",
errors="replace",
cwd=frontend_dir,
check=True,
capture_output=True,
)
subprocess.run(
[npm_cmd, "run", "build"],
encoding="utf-8",
errors="replace",
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:
stdout = _format_subprocess_output(exc.stdout)
stderr = _format_subprocess_output(exc.stderr)
cmd = " ".join(exc.cmd) if isinstance(exc.cmd, (list, tuple)) else str(exc.cmd)
details = "\n".join(part for part in [stdout, stderr] if part).strip()
if details:
print(f"Frontend build failed while running {cmd}:\n{details}")
else:
print(f"Frontend build failed while running {cmd} (exit {exc.returncode}).")
return dist_dir.is_dir()
def cmd_serve(args: argparse.Namespace) -> int:
"""Start the HTTP API server."""
from aiohttp import web
_build_frontend()
from framework.observability import configure_logging
from framework.server.app import create_app
if getattr(args, "debug", False):
configure_logging(level="DEBUG")
else:
configure_logging(level="INFO")
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_graph(agent_path, model=model)
info = session.worker_info
name = info.name if info else session.graph_id
print(f"Loaded agent: {session.graph_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.graph_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
def cmd_open(args: argparse.Namespace) -> int:
"""Start the HTTP API server and open the dashboard in the browser."""
_ping_hive_gateway_availability("hive-open")
args.open = True
return cmd_serve(args)