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:
parent
0796e45412
commit
0fb35da5dd
4 changed files with 591 additions and 7 deletions
|
|
@ -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",
|
||||
]
|
||||
243
agentserver/config/agents.py
Normal file
243
agentserver/config/agents.py
Normal 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
|
||||
|
|
@ -10,6 +10,7 @@ import asyncio
|
|||
import getpass
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
try:
|
||||
|
|
@ -26,11 +27,16 @@ try:
|
|||
except ImportError:
|
||||
PROMPT_TOOLKIT_AVAILABLE = False
|
||||
|
||||
from ..config import get_agent_config_store, CONFIG_DIR
|
||||
|
||||
|
||||
DEFAULT_HOST = "127.0.0.1"
|
||||
DEFAULT_PORT = 8765
|
||||
MAX_LOGIN_ATTEMPTS = 3
|
||||
|
||||
# Default organism config path
|
||||
DEFAULT_ORGANISM_CONFIG = Path("config/organism.yaml")
|
||||
|
||||
|
||||
class ConsoleClient:
|
||||
"""
|
||||
|
|
@ -131,15 +137,17 @@ class ConsoleClient:
|
|||
"""Print available commands."""
|
||||
print("""
|
||||
Available commands:
|
||||
/help - Show this help
|
||||
/status - Show server status
|
||||
/listeners - List available targets
|
||||
/targets - Alias for /listeners
|
||||
/quit - Disconnect and exit
|
||||
/help - Show this help
|
||||
/status - Show server status
|
||||
/listeners - List available targets
|
||||
/targets - Alias for /listeners
|
||||
/configure - Edit organism.yaml (swarm wiring)
|
||||
/configure @agent - Edit agent config (prompt, model, etc.)
|
||||
/quit - Disconnect and exit
|
||||
|
||||
Send messages:
|
||||
@target message - Send message to a target listener
|
||||
Example: @greeter Hello there!
|
||||
@target message - Send message to a target listener
|
||||
Example: @greeter Hello there!
|
||||
""")
|
||||
|
||||
async def handle_command(self, line: str) -> bool:
|
||||
|
|
@ -172,6 +180,23 @@ Send messages:
|
|||
print(f" - {name}")
|
||||
else:
|
||||
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("/"):
|
||||
print(f"Unknown command: {line}")
|
||||
elif line.startswith("@"):
|
||||
|
|
@ -190,6 +215,72 @@ Send messages:
|
|||
|
||||
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):
|
||||
"""Main client loop."""
|
||||
if not AIOHTTP_AVAILABLE:
|
||||
|
|
|
|||
227
agentserver/console/editor.py
Normal file
227
agentserver/console/editor.py
Normal 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
|
||||
Loading…
Reference in a new issue