Add /configure command with full-screen editor

- AgentConfigStore: Per-agent YAML configs in ~/.xml-pipeline/agents/
- Full-screen editor using prompt_toolkit with YAML highlighting
- /configure: Edit organism.yaml (swarm wiring)
- /configure @agent: Edit agent config (prompt, model, temperature)

Agent configs separate behavior (prompt, model) from wiring (peers, routing).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
dullfig 2026-01-17 21:17:43 -08:00
parent 0796e45412
commit 0fb35da5dd
4 changed files with 591 additions and 7 deletions

View file

@ -0,0 +1,23 @@
"""
Configuration management for xml-pipeline.
Handles:
- Agent configs (~/.xml-pipeline/agents/*.yaml)
- Organism config (organism.yaml)
"""
from .agents import (
AgentConfig,
AgentConfigStore,
get_agent_config_store,
CONFIG_DIR,
AGENTS_DIR,
)
__all__ = [
"AgentConfig",
"AgentConfigStore",
"get_agent_config_store",
"CONFIG_DIR",
"AGENTS_DIR",
]

View file

@ -0,0 +1,243 @@
"""
Agent configuration management.
Each agent has its own YAML config file in ~/.xml-pipeline/agents/
containing behavior settings (prompt, model, temperature, etc.)
The main organism.yaml defines wiring (listeners, peers, routing).
Agent YAML files define behavior.
"""
from dataclasses import dataclass, field, asdict
from pathlib import Path
from typing import Optional, Dict, Any, List
import yaml
CONFIG_DIR = Path.home() / ".xml-pipeline"
AGENTS_DIR = CONFIG_DIR / "agents"
@dataclass
class AgentConfig:
"""
Configuration for an individual agent.
Stored in ~/.xml-pipeline/agents/{name}.yaml
"""
name: str
# System prompt for the LLM
prompt: str = ""
# Model selection
model: str = "default" # "default", "claude-sonnet", "claude-opus", "gpt-4", etc.
# Generation parameters
temperature: float = 0.7
max_tokens: int = 4096
# Behavior flags
verbose: bool = False # Log detailed reasoning
confirm_actions: bool = False # Ask before tool calls
# Tool permissions (if empty, uses defaults from wiring)
allowed_tools: List[str] = field(default_factory=list)
blocked_tools: List[str] = field(default_factory=list)
# Custom metadata
metadata: Dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> Dict[str, Any]:
"""Convert to dict for YAML serialization."""
d = asdict(self)
# Remove name from dict (it's the filename)
del d["name"]
return d
@classmethod
def from_dict(cls, name: str, data: Dict[str, Any]) -> "AgentConfig":
"""Create from dict (loaded from YAML)."""
return cls(
name=name,
prompt=data.get("prompt", ""),
model=data.get("model", "default"),
temperature=data.get("temperature", 0.7),
max_tokens=data.get("max_tokens", 4096),
verbose=data.get("verbose", False),
confirm_actions=data.get("confirm_actions", False),
allowed_tools=data.get("allowed_tools", []),
blocked_tools=data.get("blocked_tools", []),
metadata=data.get("metadata", {}),
)
def to_yaml(self) -> str:
"""Serialize to YAML string."""
return yaml.dump(
self.to_dict(),
default_flow_style=False,
sort_keys=False,
allow_unicode=True,
)
@classmethod
def from_yaml(cls, name: str, yaml_str: str) -> "AgentConfig":
"""Parse from YAML string."""
data = yaml.safe_load(yaml_str) or {}
return cls.from_dict(name, data)
class AgentConfigStore:
"""
Manages agent configuration files.
Usage:
store = AgentConfigStore()
# Load or create config
config = store.get("greeter")
# Modify and save
config.prompt = "You are a friendly greeter."
store.save(config)
# Get path for editing
path = store.path_for("greeter")
"""
def __init__(self, agents_dir: Path = AGENTS_DIR):
self.agents_dir = agents_dir
self._ensure_dir()
def _ensure_dir(self) -> None:
"""Create agents directory if needed."""
self.agents_dir.mkdir(parents=True, exist_ok=True)
def path_for(self, name: str) -> Path:
"""Get path to agent's config file."""
return self.agents_dir / f"{name}.yaml"
def exists(self, name: str) -> bool:
"""Check if agent config exists."""
return self.path_for(name).exists()
def get(self, name: str) -> AgentConfig:
"""
Load agent config, creating default if not exists.
"""
path = self.path_for(name)
if path.exists():
with open(path) as f:
data = yaml.safe_load(f) or {}
return AgentConfig.from_dict(name, data)
# Return default config (not saved yet)
return AgentConfig(name=name)
def save(self, config: AgentConfig) -> Path:
"""
Save agent config to file.
Returns path to saved file.
"""
path = self.path_for(config.name)
with open(path, "w") as f:
yaml.dump(
config.to_dict(),
f,
default_flow_style=False,
sort_keys=False,
allow_unicode=True,
)
return path
def save_yaml(self, name: str, yaml_content: str) -> Path:
"""
Save raw YAML content for an agent.
Used when saving from editor.
"""
path = self.path_for(name)
# Validate YAML before saving
yaml.safe_load(yaml_content) # Raises on invalid YAML
with open(path, "w") as f:
f.write(yaml_content)
return path
def load_yaml(self, name: str) -> str:
"""
Load raw YAML content for editing.
Returns default template if file doesn't exist.
"""
path = self.path_for(name)
if path.exists():
with open(path) as f:
return f.read()
# Return default template
return self._default_template(name)
def _default_template(self, name: str) -> str:
"""Generate default YAML template for new agent."""
return f"""# Agent configuration for: {name}
# This file defines behavior settings for the agent.
# The wiring (peers, routing) is in organism.yaml.
# System prompt - instructions for the LLM
prompt: |
You are {name}, an AI assistant.
Respond helpfully and concisely.
# Model selection: "default", "claude-sonnet", "claude-opus", "gpt-4", etc.
model: default
# Generation parameters
temperature: 0.7
max_tokens: 4096
# Behavior flags
verbose: false # Log detailed reasoning
confirm_actions: false # Ask before tool calls
# Tool permissions (empty = use defaults from wiring)
allowed_tools: []
blocked_tools: []
# Custom metadata (available to handler)
metadata: {{}}
"""
def list_agents(self) -> List[str]:
"""List all configured agents."""
return [
p.stem for p in self.agents_dir.glob("*.yaml")
]
def delete(self, name: str) -> bool:
"""Delete agent config file."""
path = self.path_for(name)
if path.exists():
path.unlink()
return True
return False
# Global instance
_store: Optional[AgentConfigStore] = None
def get_agent_config_store() -> AgentConfigStore:
"""Get the global agent config store."""
global _store
if _store is None:
_store = AgentConfigStore()
return _store

View file

@ -10,6 +10,7 @@ import asyncio
import getpass import getpass
import json import json
import sys import sys
from pathlib import Path
from typing import Optional from typing import Optional
try: try:
@ -26,11 +27,16 @@ try:
except ImportError: except ImportError:
PROMPT_TOOLKIT_AVAILABLE = False PROMPT_TOOLKIT_AVAILABLE = False
from ..config import get_agent_config_store, CONFIG_DIR
DEFAULT_HOST = "127.0.0.1" DEFAULT_HOST = "127.0.0.1"
DEFAULT_PORT = 8765 DEFAULT_PORT = 8765
MAX_LOGIN_ATTEMPTS = 3 MAX_LOGIN_ATTEMPTS = 3
# Default organism config path
DEFAULT_ORGANISM_CONFIG = Path("config/organism.yaml")
class ConsoleClient: class ConsoleClient:
""" """
@ -135,6 +141,8 @@ Available commands:
/status - Show server status /status - Show server status
/listeners - List available targets /listeners - List available targets
/targets - Alias for /listeners /targets - Alias for /listeners
/configure - Edit organism.yaml (swarm wiring)
/configure @agent - Edit agent config (prompt, model, etc.)
/quit - Disconnect and exit /quit - Disconnect and exit
Send messages: Send messages:
@ -172,6 +180,23 @@ Send messages:
print(f" - {name}") print(f" - {name}")
else: else:
print("No targets available (pipeline not running)") print("No targets available (pipeline not running)")
elif line == "/configure":
# Edit organism.yaml
await self._configure_organism()
elif line.startswith("/configure @"):
# Edit agent config: /configure @agent
agent_name = line[12:].strip()
if agent_name:
await self._configure_agent(agent_name)
else:
print("Usage: /configure @agent_name")
elif line.startswith("/configure "):
# Also support /configure agent without @
agent_name = line[11:].strip()
if agent_name:
await self._configure_agent(agent_name)
else:
print("Usage: /configure @agent_name")
elif line.startswith("/"): elif line.startswith("/"):
print(f"Unknown command: {line}") print(f"Unknown command: {line}")
elif line.startswith("@"): elif line.startswith("@"):
@ -190,6 +215,72 @@ Send messages:
return True return True
async def _configure_organism(self):
"""Open organism.yaml in editor."""
from .editor import edit_text, PROMPT_TOOLKIT_AVAILABLE as EDITOR_AVAILABLE
# Find organism.yaml
organism_path = DEFAULT_ORGANISM_CONFIG
if not organism_path.exists():
# Try absolute path in config dir
organism_path = CONFIG_DIR / "organism.yaml"
if not organism_path.exists():
print(f"organism.yaml not found at {organism_path}")
print("Create one or specify path in configuration.")
return
# Load content
content = organism_path.read_text()
if EDITOR_AVAILABLE:
# Use built-in editor
edited, saved = await asyncio.get_event_loop().run_in_executor(
None,
lambda: edit_text(content, title=f"organism.yaml")
)
if saved and edited is not None:
organism_path.write_text(edited)
print(f"Saved {organism_path}")
print("Note: Restart server to apply changes.")
else:
print("Cancelled.")
else:
print(f"Edit manually: {organism_path}")
async def _configure_agent(self, agent_name: str):
"""Open agent config in editor."""
from .editor import edit_text, PROMPT_TOOLKIT_AVAILABLE as EDITOR_AVAILABLE
store = get_agent_config_store()
# Load or create config content
content = store.load_yaml(agent_name)
config_path = store.path_for(agent_name)
if EDITOR_AVAILABLE:
# Use built-in editor
edited, saved = await asyncio.get_event_loop().run_in_executor(
None,
lambda: edit_text(content, title=f"Agent: {agent_name}")
)
if saved and edited is not None:
try:
store.save_yaml(agent_name, edited)
print(f"Saved {config_path}")
except Exception as e:
print(f"Error saving: {e}")
else:
print("Cancelled.")
else:
# Fallback: show path
if not config_path.exists():
# Create default
store.save_yaml(agent_name, content)
print(f"Edit manually: {config_path}")
async def run(self): async def run(self):
"""Main client loop.""" """Main client loop."""
if not AIOHTTP_AVAILABLE: if not AIOHTTP_AVAILABLE:

View file

@ -0,0 +1,227 @@
"""
Full-screen text editor using prompt_toolkit.
Provides a vim-like editing experience for configuration files.
"""
from typing import Optional, Tuple
try:
from prompt_toolkit import Application
from prompt_toolkit.buffer import Buffer
from prompt_toolkit.layout import Layout, HSplit, VSplit
from prompt_toolkit.layout.containers import Window, ConditionalContainer
from prompt_toolkit.layout.controls import BufferControl, FormattedTextControl
from prompt_toolkit.key_binding import KeyBindings
from prompt_toolkit.filters import Condition
from prompt_toolkit.styles import Style
from prompt_toolkit.lexers import PygmentsLexer
PROMPT_TOOLKIT_AVAILABLE = True
except ImportError:
PROMPT_TOOLKIT_AVAILABLE = False
try:
from pygments.lexers.data import YamlLexer
PYGMENTS_AVAILABLE = True
except ImportError:
PYGMENTS_AVAILABLE = False
def edit_text(
initial_text: str,
title: str = "Editor",
syntax: str = "yaml",
) -> Tuple[Optional[str], bool]:
"""
Open full-screen editor for text.
Args:
initial_text: Text to edit
title: Title shown in header
syntax: Syntax highlighting ("yaml", "text")
Returns:
(edited_text, saved) - edited_text is None if cancelled
"""
if not PROMPT_TOOLKIT_AVAILABLE:
print("Error: prompt_toolkit not installed")
return None, False
# State
result = {"text": None, "saved": False}
# Create buffer with initial text
buffer = Buffer(
multiline=True,
name="editor",
)
buffer.text = initial_text
# Key bindings
kb = KeyBindings()
@kb.add("c-s") # Ctrl+S to save
def save(event):
result["text"] = buffer.text
result["saved"] = True
event.app.exit()
@kb.add("c-q") # Ctrl+Q to quit without saving
def quit_nosave(event):
result["text"] = None
result["saved"] = False
event.app.exit()
@kb.add("escape") # Escape to quit
def escape(event):
result["text"] = None
result["saved"] = False
event.app.exit()
# Syntax highlighting
lexer = None
if PYGMENTS_AVAILABLE and syntax == "yaml":
lexer = PygmentsLexer(YamlLexer)
# Layout
header = Window(
height=1,
content=FormattedTextControl(
lambda: [
("class:header", f" {title} "),
("class:header.key", " Ctrl+S"),
("class:header", "=Save "),
("class:header.key", " Ctrl+Q"),
("class:header", "=Quit "),
]
),
style="class:header",
)
editor_window = Window(
content=BufferControl(
buffer=buffer,
lexer=lexer,
),
)
# Status bar showing cursor position
def get_status():
row = buffer.document.cursor_position_row + 1
col = buffer.document.cursor_position_col + 1
lines = len(buffer.text.split("\n"))
return [
("class:status", f" Line {row}/{lines}, Col {col} "),
]
status_bar = Window(
height=1,
content=FormattedTextControl(get_status),
style="class:status",
)
layout = Layout(
HSplit([
header,
editor_window,
status_bar,
])
)
# Styles
style = Style.from_dict({
"header": "bg:#005f87 #ffffff",
"header.key": "bg:#005f87 #ffff00 bold",
"status": "bg:#444444 #ffffff",
})
# Create and run application
app = Application(
layout=layout,
key_bindings=kb,
style=style,
full_screen=True,
mouse_support=True,
)
app.run()
return result["text"], result["saved"]
def edit_file(filepath: str, title: Optional[str] = None) -> bool:
"""
Edit a file in the full-screen editor.
Args:
filepath: Path to file
title: Optional title (defaults to filename)
Returns:
True if saved, False if cancelled
"""
from pathlib import Path
path = Path(filepath)
title = title or path.name
# Load existing content or empty
if path.exists():
initial_text = path.read_text()
else:
initial_text = ""
# Edit
edited_text, saved = edit_text(initial_text, title=title, syntax="yaml")
# Save if requested
if saved and edited_text is not None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(edited_text)
return True
return False
# Fallback: use system editor via subprocess
def edit_with_system_editor(filepath: str) -> bool:
"""
Edit file using system's default editor ($EDITOR or fallback).
Returns True if file was modified.
"""
import os
import subprocess
from pathlib import Path
path = Path(filepath)
# Get editor from environment
editor = os.environ.get("EDITOR", os.environ.get("VISUAL", ""))
if not editor:
# Fallback based on platform
import platform
if platform.system() == "Windows":
editor = "notepad"
else:
editor = "nano" # Most likely available
# Get modification time before edit
mtime_before = path.stat().st_mtime if path.exists() else None
# Open editor
try:
subprocess.run([editor, str(path)], check=True)
except subprocess.CalledProcessError:
return False
except FileNotFoundError:
print(f"Editor not found: {editor}")
return False
# Check if modified
if path.exists():
mtime_after = path.stat().st_mtime
return mtime_before is None or mtime_after > mtime_before
return False