Implements the AgentServer API from docs/agentserver_api_spec.md: REST API (/api/v1): - Organism info and config endpoints - Agent listing, details, config, schema - Thread and message history with filtering - Control endpoints (inject, pause, resume, kill, stop) WebSocket: - /ws: Main control channel with state snapshot + real-time events - /ws/messages: Dedicated message stream with filtering Infrastructure: - Pydantic models with camelCase serialization - ServerState bridges StreamPump to API - Pump event hooks for real-time updates - CLI 'serve' command: xml-pipeline serve [config] --port 8080 35 new tests for models, state, REST, and WebSocket. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
253 lines
8.3 KiB
Python
253 lines
8.3 KiB
Python
"""
|
|
xml-pipeline CLI entry point.
|
|
|
|
Usage:
|
|
xml-pipeline run [config.yaml] Run an organism
|
|
xml-pipeline serve [config.yaml] Run organism with API server
|
|
xml-pipeline init [name] Create new organism config
|
|
xml-pipeline check [config.yaml] Validate config without running
|
|
xml-pipeline version Show version info
|
|
xml-pipeline keygen [-o path] Generate Ed25519 identity keypair
|
|
"""
|
|
|
|
import argparse
|
|
import asyncio
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
|
|
def cmd_run(args: argparse.Namespace) -> int:
|
|
"""Run an organism from config."""
|
|
from xml_pipeline.config.loader import load_config
|
|
from xml_pipeline.message_bus import bootstrap
|
|
|
|
config_path = Path(args.config)
|
|
if not config_path.exists():
|
|
print(f"Error: Config file not found: {config_path}", file=sys.stderr)
|
|
return 1
|
|
|
|
try:
|
|
config = load_config(config_path)
|
|
asyncio.run(bootstrap(config))
|
|
return 0
|
|
except KeyboardInterrupt:
|
|
print("\nShutdown requested.")
|
|
return 0
|
|
except Exception as e:
|
|
print(f"Error: {e}", file=sys.stderr)
|
|
return 1
|
|
|
|
|
|
def cmd_serve(args: argparse.Namespace) -> int:
|
|
"""Run an organism with the AgentServer API."""
|
|
try:
|
|
import uvicorn
|
|
except ImportError:
|
|
print("Error: uvicorn not installed.", file=sys.stderr)
|
|
print("Install with: pip install xml-pipeline[server]", file=sys.stderr)
|
|
return 1
|
|
|
|
from xml_pipeline.message_bus import bootstrap
|
|
|
|
config_path = Path(args.config)
|
|
if not config_path.exists():
|
|
print(f"Error: Config file not found: {config_path}", file=sys.stderr)
|
|
return 1
|
|
|
|
async def run_with_server():
|
|
"""Bootstrap pump and run with server."""
|
|
from xml_pipeline.server import create_app
|
|
|
|
# Bootstrap the pump
|
|
pump = await bootstrap(str(config_path))
|
|
|
|
# Create FastAPI app
|
|
app = create_app(pump)
|
|
|
|
# Run uvicorn
|
|
config = uvicorn.Config(
|
|
app,
|
|
host=args.host,
|
|
port=args.port,
|
|
log_level="info",
|
|
)
|
|
server = uvicorn.Server(config)
|
|
|
|
# Run pump and server concurrently
|
|
pump_task = asyncio.create_task(pump.run())
|
|
|
|
try:
|
|
await server.serve()
|
|
finally:
|
|
await pump.shutdown()
|
|
pump_task.cancel()
|
|
try:
|
|
await pump_task
|
|
except asyncio.CancelledError:
|
|
pass
|
|
|
|
try:
|
|
print(f"Starting AgentServer on http://{args.host}:{args.port}")
|
|
print(f" API docs: http://{args.host}:{args.port}/docs")
|
|
print(f" WebSocket: ws://{args.host}:{args.port}/ws")
|
|
asyncio.run(run_with_server())
|
|
return 0
|
|
except KeyboardInterrupt:
|
|
print("\nShutdown requested.")
|
|
return 0
|
|
except Exception as e:
|
|
print(f"Error: {e}", file=sys.stderr)
|
|
return 1
|
|
|
|
|
|
def cmd_init(args: argparse.Namespace) -> int:
|
|
"""Initialize a new organism config."""
|
|
from xml_pipeline.config.template import create_organism_template
|
|
|
|
name = args.name or "my-organism"
|
|
output = Path(args.output or f"{name}.yaml")
|
|
|
|
if output.exists() and not args.force:
|
|
print(f"Error: {output} already exists. Use --force to overwrite.", file=sys.stderr)
|
|
return 1
|
|
|
|
template = create_organism_template(name)
|
|
output.write_text(template)
|
|
print(f"Created {output}")
|
|
print(f"\nNext steps:")
|
|
print(f" 1. Edit {output} to configure your agents")
|
|
print(f" 2. Set your LLM API key: export XAI_API_KEY=...")
|
|
print(f" 3. Run: xml-pipeline run {output}")
|
|
return 0
|
|
|
|
|
|
def cmd_check(args: argparse.Namespace) -> int:
|
|
"""Validate config without running."""
|
|
from xml_pipeline.config.loader import load_config, ConfigError
|
|
|
|
config_path = Path(args.config)
|
|
if not config_path.exists():
|
|
print(f"Error: Config file not found: {config_path}", file=sys.stderr)
|
|
return 1
|
|
|
|
try:
|
|
config = load_config(config_path)
|
|
print(f"Config valid: {config.organism.name}")
|
|
print(f" Listeners: {len(config.listeners)}")
|
|
print(f" LLM backends: {len(config.llm_backends)}")
|
|
|
|
# Check optional features
|
|
from xml_pipeline.config.features import check_features
|
|
features = check_features(config)
|
|
if features.missing:
|
|
print(f"\nOptional features needed:")
|
|
for feature, reason in features.missing.items():
|
|
print(f" pip install xml-pipeline[{feature}] # {reason}")
|
|
|
|
return 0
|
|
except ConfigError as e:
|
|
print(f"Config error: {e}", file=sys.stderr)
|
|
return 1
|
|
|
|
|
|
def cmd_version(args: argparse.Namespace) -> int:
|
|
"""Show version and feature info."""
|
|
from xml_pipeline import __version__
|
|
from xml_pipeline.config.features import get_available_features
|
|
|
|
print(f"xml-pipeline {__version__}")
|
|
print()
|
|
print("Installed features:")
|
|
for feature, available in get_available_features().items():
|
|
status = "yes" if available else "no"
|
|
print(f" {feature}: {status}")
|
|
return 0
|
|
|
|
|
|
def cmd_keygen(args: argparse.Namespace) -> int:
|
|
"""Generate Ed25519 identity keypair."""
|
|
from xml_pipeline.crypto import generate_identity
|
|
|
|
output = Path(args.output)
|
|
|
|
# Prevent overwrite unless forced
|
|
if output.exists() and not args.force:
|
|
print(f"Error: {output} already exists. Use --force to overwrite.", file=sys.stderr)
|
|
return 1
|
|
|
|
public_path = output.with_suffix(".pub")
|
|
if public_path.exists() and not args.force:
|
|
print(f"Error: {public_path} already exists. Use --force to overwrite.", file=sys.stderr)
|
|
return 1
|
|
|
|
try:
|
|
identity = generate_identity()
|
|
identity.save(output, public_path)
|
|
|
|
print(f"Generated Ed25519 identity keypair:")
|
|
print(f" Private key: {output}")
|
|
print(f" Public key: {public_path}")
|
|
print()
|
|
print(f"Add to organism.yaml:")
|
|
print(f" organism:")
|
|
print(f" identity: \"{output}\"")
|
|
print()
|
|
print("IMPORTANT: Keep the private key secure. Never commit it to version control.")
|
|
return 0
|
|
except Exception as e:
|
|
print(f"Error generating keys: {e}", file=sys.stderr)
|
|
return 1
|
|
|
|
|
|
def main() -> int:
|
|
"""CLI entry point."""
|
|
parser = argparse.ArgumentParser(
|
|
prog="xml-pipeline",
|
|
description="Tamper-proof nervous system for multi-agent organisms",
|
|
)
|
|
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
|
|
# run
|
|
run_parser = subparsers.add_parser("run", help="Run an organism")
|
|
run_parser.add_argument("config", nargs="?", default="organism.yaml", help="Config file")
|
|
run_parser.set_defaults(func=cmd_run)
|
|
|
|
# serve
|
|
serve_parser = subparsers.add_parser("serve", help="Run organism with API server")
|
|
serve_parser.add_argument("config", nargs="?", default="organism.yaml", help="Config file")
|
|
serve_parser.add_argument("--host", default="0.0.0.0", help="Host to bind (default: 0.0.0.0)")
|
|
serve_parser.add_argument("--port", "-p", type=int, default=8080, help="Port to listen on (default: 8080)")
|
|
serve_parser.set_defaults(func=cmd_serve)
|
|
|
|
# init
|
|
init_parser = subparsers.add_parser("init", help="Create new organism config")
|
|
init_parser.add_argument("name", nargs="?", help="Organism name")
|
|
init_parser.add_argument("-o", "--output", help="Output file path")
|
|
init_parser.add_argument("-f", "--force", action="store_true", help="Overwrite existing")
|
|
init_parser.set_defaults(func=cmd_init)
|
|
|
|
# check
|
|
check_parser = subparsers.add_parser("check", help="Validate config")
|
|
check_parser.add_argument("config", nargs="?", default="organism.yaml", help="Config file")
|
|
check_parser.set_defaults(func=cmd_check)
|
|
|
|
# version
|
|
version_parser = subparsers.add_parser("version", help="Show version info")
|
|
version_parser.set_defaults(func=cmd_version)
|
|
|
|
# keygen
|
|
keygen_parser = subparsers.add_parser("keygen", help="Generate Ed25519 identity keypair")
|
|
keygen_parser.add_argument(
|
|
"-o", "--output",
|
|
default="identity.key",
|
|
help="Output path for private key (default: identity.key)",
|
|
)
|
|
keygen_parser.add_argument("-f", "--force", action="store_true", help="Overwrite existing")
|
|
keygen_parser.set_defaults(func=cmd_keygen)
|
|
|
|
args = parser.parse_args()
|
|
return args.func(args)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|