diff --git a/agentserver/config/__init__.py b/agentserver/config/__init__.py index e69de29..71d9c59 100644 --- a/agentserver/config/__init__.py +++ b/agentserver/config/__init__.py @@ -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", +] diff --git a/agentserver/config/agents.py b/agentserver/config/agents.py new file mode 100644 index 0000000..25a89a9 --- /dev/null +++ b/agentserver/config/agents.py @@ -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 diff --git a/agentserver/console/client.py b/agentserver/console/client.py index 2cdd151..65cbfc0 100644 --- a/agentserver/console/client.py +++ b/agentserver/console/client.py @@ -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: diff --git a/agentserver/console/editor.py b/agentserver/console/editor.py new file mode 100644 index 0000000..6b9261b --- /dev/null +++ b/agentserver/console/editor.py @@ -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