xml-pipeline/xml_pipeline/config/loader.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

216 lines
5.9 KiB
Python

"""
Configuration loader for xml-pipeline.
Loads and validates organism.yaml configuration files.
"""
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
import yaml
class ConfigError(Exception):
"""Configuration validation error."""
pass
@dataclass
class OrganismMeta:
"""Organism metadata."""
name: str
version: str = "0.1.0"
description: str = ""
@dataclass
class LLMBackendConfig:
"""LLM backend configuration."""
name: str
provider: str # xai, anthropic, openai, ollama
model: str
api_key_env: str | None = None
base_url: str | None = None
priority: int = 0
@dataclass
class ListenerConfig:
"""Listener configuration."""
name: str
description: str = ""
# Type flags
agent: bool = False
tool: bool = False
gateway: bool = False
# Handler (for tools)
handler: str | None = None
payload_class: str | None = None
# Agent config
prompt: str | None = None
model: str | None = None
# Routing
peers: list[str] = field(default_factory=list)
# Tool permissions (for agents)
allowed_tools: list[str] = field(default_factory=list)
blocked_tools: list[str] = field(default_factory=list)
@dataclass
class ServerConfig:
"""WebSocket server configuration (optional)."""
enabled: bool = False
host: str = "127.0.0.1"
port: int = 8765
@dataclass
class AuthConfig:
"""Authentication configuration (optional)."""
enabled: bool = False
totp_secret_env: str = "ORGANISM_TOTP_SECRET"
@dataclass
class OrganismConfig:
"""Complete organism configuration."""
organism: OrganismMeta
listeners: list[ListenerConfig] = field(default_factory=list)
llm_backends: list[LLMBackendConfig] = field(default_factory=list)
server: ServerConfig | None = None
auth: AuthConfig | None = None
def load_config(path: Path) -> OrganismConfig:
"""Load and validate organism configuration from YAML file."""
with open(path) as f:
raw = yaml.safe_load(f)
if not isinstance(raw, dict):
raise ConfigError(f"Config must be a YAML mapping, got {type(raw)}")
# Parse organism metadata
org_raw = raw.get("organism", {})
if not org_raw.get("name"):
raise ConfigError("organism.name is required")
organism = OrganismMeta(
name=org_raw["name"],
version=org_raw.get("version", "0.1.0"),
description=org_raw.get("description", ""),
)
# Parse LLM backends
llm_backends = []
for backend_raw in raw.get("llm_backends", []):
if not backend_raw.get("name"):
raise ConfigError("llm_backends[].name is required")
if not backend_raw.get("provider"):
raise ConfigError(f"llm_backends[{backend_raw['name']}].provider is required")
llm_backends.append(
LLMBackendConfig(
name=backend_raw["name"],
provider=backend_raw["provider"],
model=backend_raw.get("model", ""),
api_key_env=backend_raw.get("api_key_env"),
base_url=backend_raw.get("base_url"),
priority=backend_raw.get("priority", 0),
)
)
# Parse listeners
listeners = []
for listener_raw in raw.get("listeners", []):
if not listener_raw.get("name"):
raise ConfigError("listeners[].name is required")
listeners.append(
ListenerConfig(
name=listener_raw["name"],
description=listener_raw.get("description", ""),
agent=listener_raw.get("agent", False),
tool=listener_raw.get("tool", False),
gateway=listener_raw.get("gateway", False),
handler=listener_raw.get("handler"),
payload_class=listener_raw.get("payload_class"),
prompt=listener_raw.get("prompt"),
model=listener_raw.get("model"),
peers=listener_raw.get("peers", []),
allowed_tools=listener_raw.get("allowed_tools", []),
blocked_tools=listener_raw.get("blocked_tools", []),
)
)
# Parse optional server config
server = None
if "server" in raw:
server_raw = raw["server"]
server = ServerConfig(
enabled=server_raw.get("enabled", True),
host=server_raw.get("host", "127.0.0.1"),
port=server_raw.get("port", 8765),
)
# Parse optional auth config
auth = None
if "auth" in raw:
auth_raw = raw["auth"]
auth = AuthConfig(
enabled=auth_raw.get("enabled", True),
totp_secret_env=auth_raw.get("totp_secret_env", "ORGANISM_TOTP_SECRET"),
)
return OrganismConfig(
organism=organism,
listeners=listeners,
llm_backends=llm_backends,
server=server,
auth=auth,
)
def validate_config(config: OrganismConfig) -> list[str]:
"""
Validate config for common issues.
Returns list of warning messages (empty if valid).
"""
warnings = []
# Check for at least one listener
if not config.listeners:
warnings.append("No listeners defined")
# Check for LLM backend if agents exist
agents = [l for l in config.listeners if l.agent]
if agents and not config.llm_backends:
warnings.append(
f"Config has {len(agents)} agent(s) but no llm_backends defined"
)
# Check peer references
listener_names = {l.name for l in config.listeners}
for listener in config.listeners:
for peer in listener.peers:
# Peer can be "listener_name" or "listener_name.payload_type"
peer_name = peer.split(".")[0]
if peer_name not in listener_names:
warnings.append(
f"Listener '{listener.name}' references unknown peer '{peer_name}'"
)
return warnings