xml-pipeline/examples/console/console.py
dullfig e653d63bc1 Rename agentserver to xml_pipeline, add console example
OSS restructuring for open-core model:
- Rename package from agentserver/ to xml_pipeline/
- Update all imports (44 Python files, 31 docs/configs)
- Update pyproject.toml for OSS distribution (v0.3.0)
- Move prompt_toolkit from core to optional [console] extra
- Remove auth/server/lsp from core optional deps (-> Nextra)

New console example in examples/console/:
- Self-contained demo with handlers and config
- Uses prompt_toolkit (optional, falls back to input())
- No password auth, no TUI, no LSP — just the basics
- Shows how to use xml-pipeline as a library

Import changes:
- from agentserver.* -> from xml_pipeline.*
- CLI entry points updated: xml_pipeline.cli:main

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 21:41:19 -08:00

291 lines
9.6 KiB
Python

"""
console.py — Simple interactive console for xml-pipeline.
This is a minimal, copy-friendly implementation that shows how to:
- Send messages to listeners via the message pump
- Display responses
- Handle basic commands
No password auth, no TUI split-screen, no LSP — just the essentials.
Uses prompt_toolkit if available, falls back to basic input().
Copy this file and modify for your own use case.
"""
from __future__ import annotations
import asyncio
import sys
import uuid
from typing import TYPE_CHECKING, Optional
# Optional: prompt_toolkit for better terminal experience
try:
from prompt_toolkit import PromptSession
from prompt_toolkit.history import InMemoryHistory
from prompt_toolkit.patch_stdout import patch_stdout
PROMPT_TOOLKIT = True
except ImportError:
PROMPT_TOOLKIT = False
if TYPE_CHECKING:
from xml_pipeline.message_bus.stream_pump import StreamPump
# ============================================================================
# Global console registry (for handlers to find us)
# ============================================================================
_active_console: Optional["Console"] = None
def get_active_console() -> Optional["Console"]:
"""Get the currently active console instance."""
return _active_console
def set_active_console(console: Optional["Console"]) -> None:
"""Set the active console instance."""
global _active_console
_active_console = console
# ============================================================================
# ANSI Colors (simple, no dependencies)
# ============================================================================
class Colors:
RESET = "\033[0m"
BOLD = "\033[1m"
DIM = "\033[2m"
RED = "\033[31m"
GREEN = "\033[32m"
YELLOW = "\033[33m"
CYAN = "\033[36m"
def cprint(text: str, color: str = "") -> None:
"""Print with optional ANSI color."""
if color:
print(f"{color}{text}{Colors.RESET}")
else:
print(text)
# ============================================================================
# Console
# ============================================================================
class Console:
"""
Simple interactive console for xml-pipeline.
Usage:
pump = await bootstrap("organism.yaml")
console = Console(pump)
await console.run()
"""
def __init__(self, pump: StreamPump):
self.pump = pump
self.running = False
self._session: Optional[PromptSession] = None
async def run(self) -> None:
"""Main console loop."""
set_active_console(self)
self.running = True
self._print_banner()
# Initialize prompt session if available
if PROMPT_TOOLKIT:
self._session = PromptSession(history=InMemoryHistory())
try:
while self.running:
try:
line = await self._read_input("> ")
if line:
await self._handle_input(line.strip())
except EOFError:
cprint("\nGoodbye!", Colors.YELLOW)
break
except KeyboardInterrupt:
continue
finally:
set_active_console(None)
async def _read_input(self, prompt: str) -> str:
"""Read a line of input."""
if PROMPT_TOOLKIT and self._session:
with patch_stdout():
return await self._session.prompt_async(prompt)
else:
# Fallback: blocking input in executor
loop = asyncio.get_event_loop()
print(prompt, end="", flush=True)
line = await loop.run_in_executor(None, sys.stdin.readline)
return line.strip() if line else ""
async def _handle_input(self, line: str) -> None:
"""Route input to appropriate handler."""
if line.startswith("/"):
await self._handle_command(line)
elif line.startswith("@"):
await self._handle_message(line)
else:
cprint("Use @listener message or /command", Colors.DIM)
cprint("Type /help for available commands", Colors.DIM)
# ------------------------------------------------------------------
# Commands
# ------------------------------------------------------------------
async def _handle_command(self, line: str) -> None:
"""Handle /command."""
parts = line[1:].split(None, 1)
cmd = parts[0].lower() if parts else ""
args = parts[1] if len(parts) > 1 else ""
commands = {
"help": self._cmd_help,
"h": self._cmd_help,
"listeners": self._cmd_listeners,
"ls": self._cmd_listeners,
"status": self._cmd_status,
"quit": self._cmd_quit,
"q": self._cmd_quit,
"exit": self._cmd_quit,
}
handler = commands.get(cmd)
if handler:
await handler(args)
else:
cprint(f"Unknown command: /{cmd}", Colors.RED)
cprint("Type /help for available commands", Colors.DIM)
async def _cmd_help(self, args: str) -> None:
"""Show help."""
cprint("\nCommands:", Colors.CYAN)
cprint(" /help, /h Show this help", Colors.DIM)
cprint(" /listeners, /ls List registered listeners", Colors.DIM)
cprint(" /status Show organism status", Colors.DIM)
cprint(" /quit, /q Exit", Colors.DIM)
cprint("")
cprint("Messages:", Colors.CYAN)
cprint(" @listener text Send message to listener", Colors.DIM)
cprint("")
cprint("Examples:", Colors.CYAN)
cprint(" @greeter Alice Greet Alice", Colors.DIM)
cprint(" @echo Hello! Echo back 'Hello!'", Colors.DIM)
cprint("")
async def _cmd_listeners(self, args: str) -> None:
"""List registered listeners."""
cprint("\nListeners:", Colors.CYAN)
for name, listener in sorted(self.pump.listeners.items()):
desc = listener.description or "(no description)"
cprint(f" {name:20} {desc}", Colors.DIM)
cprint("")
async def _cmd_status(self, args: str) -> None:
"""Show organism status."""
cprint(f"\nOrganism: {self.pump.config.name}", Colors.CYAN)
cprint(f"Listeners: {len(self.pump.listeners)}", Colors.DIM)
cprint(f"Running: {self.pump._running}", Colors.DIM)
cprint("")
async def _cmd_quit(self, args: str) -> None:
"""Exit the console."""
cprint("Shutting down...", Colors.YELLOW)
self.running = False
# ------------------------------------------------------------------
# Message Sending
# ------------------------------------------------------------------
async def _handle_message(self, line: str) -> None:
"""Handle @listener message."""
parts = line[1:].split(None, 1)
if not parts:
cprint("Usage: @listener message", Colors.DIM)
return
target = parts[0].lower()
message = parts[1] if len(parts) > 1 else ""
# Check if listener exists
if target not in self.pump.listeners:
cprint(f"Unknown listener: {target}", Colors.RED)
cprint("Use /listeners to see available listeners", Colors.DIM)
return
# Create payload
listener = self.pump.listeners[target]
payload = self._create_payload(listener, message)
if payload is None:
cprint(f"Cannot create payload for {target}", Colors.RED)
return
cprint(f"[sending to {target}]", Colors.DIM)
# Create thread and inject
thread_id = str(uuid.uuid4())
envelope = self.pump._wrap_in_envelope(
payload=payload,
from_id="console",
to_id=target,
thread_id=thread_id,
)
await self.pump.inject(envelope, thread_id=thread_id, from_id="console")
def _create_payload(self, listener, message: str):
"""Create payload instance from message text."""
payload_class = listener.payload_class
# Try common field patterns
if hasattr(payload_class, "__dataclass_fields__"):
fields = list(payload_class.__dataclass_fields__.keys())
if len(fields) == 1:
return payload_class(**{fields[0]: message})
elif "name" in fields:
return payload_class(name=message)
elif "text" in fields:
return payload_class(text=message)
elif "message" in fields:
return payload_class(message=message)
# Fallback
try:
return payload_class()
except Exception:
return None
# ------------------------------------------------------------------
# Output (called by handlers)
# ------------------------------------------------------------------
def display_response(self, source: str, text: str) -> None:
"""Display a response from a handler."""
cprint(f"[{source}] {text}", Colors.CYAN)
# ------------------------------------------------------------------
# UI
# ------------------------------------------------------------------
def _print_banner(self) -> None:
"""Print startup banner."""
print()
cprint("=" * 50, Colors.CYAN)
cprint(" xml-pipeline console", Colors.CYAN)
cprint("=" * 50, Colors.CYAN)
print()
cprint(f"Organism: {self.pump.config.name}", Colors.GREEN)
cprint(f"Listeners: {len(self.pump.listeners)}", Colors.DIM)
cprint("Type /help for commands", Colors.DIM)
print()