From 67c7484f684dc8737602814eee3ab49004381aa9 Mon Sep 17 00:00:00 2001 From: heyfinal <405dmg@gmail.com> Date: Sun, 7 Jun 2026 16:51:14 -0500 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20Agent=20Breakout=20orchestrator=20?= =?UTF-8?q?=E2=80=94=20config,=20CLI,=20toolset,=20tool?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add first-class orchestrator subsystem for dynamic multi-agent team assembly. - Config: orchestrator section in DEFAULT_CONFIG with evolution, subagent, artifacts, and logging subsections - CLI: 'hermes orchestrator' subcommand with agent|team|evolution verbs (aliases: orch, agents) - CLI module: hermes_cli/orchestrator.py — agent CRUD, team assembly, evolution cycle, run history DB - Toolset: 'orchestrator' toolset registered in _HERMES_CORE_TOOLS and TOOLSETS - Tool: tools/orchestrator_tool.py — orchestrate_team() reads agent profiles from ~/.hermes/orchestrator/agents/*.yaml, auto-selects team by project goal analysis, spawns in phases (architect → parallel build → integration), logs runs to orchestrator.db - Seeding: Creates default agent profiles (architect, software-engineer, firmware-engineer, network-engineer, devops-engineer) on first use --- hermes_cli/config.py | 30 ++ hermes_cli/main.py | 60 ++++ hermes_cli/orchestrator.py | 684 +++++++++++++++++++++++++++++++++++++ tools/orchestrator_tool.py | 342 +++++++++++++++++++ toolsets.py | 13 + 5 files changed, 1129 insertions(+) create mode 100644 hermes_cli/orchestrator.py create mode 100644 tools/orchestrator_tool.py diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 644485d8d361..c9968dde6205 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -2052,6 +2052,36 @@ def _ensure_hermes_home_managed(home: Path): "dispatch_stale_timeout_seconds": 14400, }, + # Orchestrator — Agent Breakout: dynamic multi-agent team assembly + # and evolution for complex, multi-domain projects. + "orchestrator": { + "enabled": True, # Master toggle + "default_team_size": 3, # Agents per team if not specified + "evolution": { + "enabled": True, + "interval": "weekly", # daily | weekly | monthly + "day": 0, # 0=Sunday (weekly) or 1-31 (monthly) + "time": "03:00", # UTC time (HH:MM) + "auto_update_profiles": True, # Auto-patch agent prompts + "notify_on_change": True, # Notify when evolution changes profiles + }, + "subagent": { + "default_model": "", # Empty = inherit parent model + "default_max_turns": 30, + "default_timeout": 600, + "inherit_tools": True, + "restrict_by_role": True, # Only load toolsets from agent profile + }, + "artifacts": { + "keep_last": 10, + "max_size_mb": 100, + }, + "logging": { + "level": "info", + "retain_runs": 50, + }, + }, + # execute_code settings — controls the tool used for programmatic tool calls. "code_execution": { # Execution mode: diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 391d85f1b52f..3ae67c43710a 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -6549,6 +6549,13 @@ def cmd_kanban(args): return kanban_command(args) +def cmd_orchestrator(args): + """Orchestrator — dynamic multi-agent team assembly.""" + from hermes_cli.orchestrator import orchestrator_command + + return orchestrator_command(args) + + def cmd_hooks(args): """Shell-hook inspection and management.""" from hermes_cli.hooks import hooks_command @@ -13996,6 +14003,59 @@ def _dispatch_secrets(args): # noqa: ANN001 from hermes_cli.checkpoints import register_cli as _register_checkpoints_cli _register_checkpoints_cli(checkpoints_parser) + # ========================================================================= + # orchestrator command — Agent Breakout multi-agent team assembly + # ========================================================================= + orchestrator_parser = subparsers.add_parser( + "orchestrator", + aliases=["orch", "agents"], + help="Manage agent profiles, teams, and evolution", + description="Agent Breakout: dynamic multi-agent orchestration. " + "Manage agent profiles, assemble teams, run evolution cycles.", + ) + orchestrator_sub = orchestrator_parser.add_subparsers(dest="orchestrator_command") + + # --- orchestrator status (default) --- + orchestrator_sub.add_parser("status", help="Show orchestrator status overview") + + # --- orchestrator agent --- + agent_parser = orchestrator_sub.add_parser( + "agent", help="Manage agent profiles" + ) + agent_sub = agent_parser.add_subparsers(dest="orchestrator_agent_action") + + agent_sub.add_parser("list", help="List all agent profiles") + agent_show = agent_sub.add_parser("show", help="Show an agent profile") + agent_show.add_argument("name", help="Agent name") + agent_sub.add_parser("create", help="Create a new agent profile (interactive)") + agent_edit = agent_sub.add_parser("edit", help="Edit an agent profile") + agent_edit.add_argument("name", help="Agent name") + agent_rm = agent_sub.add_parser( + "remove", aliases=["rm"], help="Remove an agent profile" + ) + agent_rm.add_argument("name", help="Agent name") + agent_tog = agent_sub.add_parser("toggle", help="Enable/disable an agent") + agent_tog.add_argument("name", help="Agent name") + + # --- orchestrator team --- + team_parser = orchestrator_sub.add_parser( + "team", help="Assemble and manage teams" + ) + team_sub = team_parser.add_subparsers(dest="orchestrator_team_action") + team_sub.add_parser("assemble", help="Interactive team builder") + team_sub.add_parser("history", help="Show past team runs") + + # --- orchestrator evolution --- + evo_parser = orchestrator_sub.add_parser( + "evolution", aliases=["evo"], help="Manage evolution cycle" + ) + evo_sub = evo_parser.add_subparsers(dest="orchestrator_evolution_action") + evo_sub.add_parser("run", help="Trigger evolution cycle now") + evo_sub.add_parser("status", help="Show last evolution report") + evo_sub.add_parser("config", help="Show evolution configuration") + + orchestrator_parser.set_defaults(func=cmd_orchestrator) + # ========================================================================= # import command # ========================================================================= diff --git a/hermes_cli/orchestrator.py b/hermes_cli/orchestrator.py new file mode 100644 index 000000000000..752a34a62b3a --- /dev/null +++ b/hermes_cli/orchestrator.py @@ -0,0 +1,684 @@ +""" +Orchestrator subcommand for hermes CLI. + +Handles agent profile management, team assembly, run history, and evolution +cycle for the Agent Breakout multi-agent orchestration framework. + +Usage: + hermes orchestrator # Status overview + hermes orchestrator agent list # List agent profiles + hermes orchestrator agent show # Show profile detail + hermes orchestrator agent create # Interactive wizard + hermes orchestrator agent edit # Open in $EDITOR + hermes orchestrator agent remove # Remove a profile + hermes orchestrator agent toggle # Enable/disable + + hermes orchestrator team assemble # Interactive team builder + hermes orchestrator team history # Show past runs + + hermes orchestrator evolution run # Trigger evolution cycle now + hermes orchestrator evolution status # When last run, what changed + hermes orchestrator evolution config # Show/set interval +""" + +import json +import logging +import os +import shutil +import subprocess +import sys +import time +from pathlib import Path +from typing import Any, Dict, List, Optional + +logger = logging.getLogger(__name__) + +HERMES_HOME = Path(os.environ.get("HERMES_HOME", Path.home() / ".hermes")) +ORCHESTRATOR_DIR = HERMES_HOME / "orchestrator" +AGENTS_DIR = ORCHESTRATOR_DIR / "agents" +TEAMS_DIR = ORCHESTRATOR_DIR / "teams" +ARTIFACTS_DIR = ORCHESTRATOR_DIR / "artifacts" +EVOLUTION_DIR = ORCHESTRATOR_DIR / "evolution" +DB_PATH = ORCHESTRATOR_DIR / "orchestrator.db" + +# --------------------------------------------------------------------------- +# Initialization +# --------------------------------------------------------------------------- + + +def _ensure_orchestrator_dir() -> None: + """Create the orchestrator directory structure if it doesn't exist.""" + ORCHESTRATOR_DIR.mkdir(parents=True, exist_ok=True) + AGENTS_DIR.mkdir(parents=True, exist_ok=True) + TEAMS_DIR.mkdir(parents=True, exist_ok=True) + ARTIFACTS_DIR.mkdir(parents=True, exist_ok=True) + EVOLUTION_DIR.mkdir(parents=True, exist_ok=True) + _create_default_agents_if_empty() + + +def _create_default_agents_if_empty() -> None: + """Seed default agent profiles if the agents directory is empty.""" + if any(AGENTS_DIR.iterdir()): + return + + defaults: Dict[str, Dict[str, Any]] = { + "architect": { + "label": "Architect Agent", + "description": "System architecture, project structure, technical strategy", + "prompt": ( + "You are the Architect Agent. Your role is to design system architecture,\n" + "define scope, establish technical direction, and coordinate interfaces\n" + "between components. You do NOT implement — you specify what should be built\n" + "and how components connect.\n\n" + "Deliverables:\n" + "1. System architecture diagram / description\n" + "2. Component breakdown with responsibilities\n" + "3. Interface contracts (APIs, data formats, protocols)\n" + "4. Technology stack recommendation\n" + "5. Dependency graph\n" + "6. Key design decisions documented\n\n" + "Constraints:\n" + "- Stay at the design level unless explicitly asked to implement\n" + "- Document all interface contracts precisely\n" + "- Flag risky decisions with mitigation options" + ), + "toolsets": ["file", "search", "web"], + "model": "", + "max_turns": 30, + "output_contract": { + "files": ["ARCHITECTURE.md", "INTERFACES.md"], + "format": "markdown", + }, + "quality_gates": [ + "Every component has clear inputs and outputs", + "No circular dependencies", + "Technology choices justified", + "Scalability and failure modes addressed", + ], + "version": 1, + }, + "software-engineer": { + "label": "Software Engineer Agent", + "description": "Application logic, APIs, libraries, services", + "prompt": ( + "You are the Software Engineer Agent. You implement application logic,\n" + "build APIs, develop integrations, write automation workflows, and maintain\n" + "code quality. You work from the architect's interface contracts.\n\n" + "Deliverables:\n" + "1. Working code implementing the specified components\n" + "2. Unit tests and integration tests\n" + "3. API documentation\n" + "4. Build/run instructions\n" + "5. Error handling and logging\n\n" + "Principles:\n" + "- Write testable, maintainable code\n" + "- Follow language-specific best practices\n" + "- Handle errors gracefully with meaningful messages\n" + "- Include logging for operational visibility\n" + "- Do not over-engineer — prefer simple solutions that work" + ), + "toolsets": ["terminal", "file", "search"], + "model": "", + "max_turns": 30, + "output_contract": { + "files": ["README.md"], + "format": "code", + }, + "quality_gates": [ + "Tests pass", + "No lint errors", + "No TODO/FIXME in production code", + "Error paths handled", + ], + "version": 1, + }, + "firmware-engineer": { + "label": "Firmware / Embedded Engineer Agent", + "description": "MCU firmware, RTOS, hardware abstraction, peripheral drivers", + "prompt": ( + "You are the Firmware/Embedded Engineer Agent. You handle hardware-near\n" + "development: microcontroller firmware, driver implementation, wireless\n" + "communication, power optimization, and real-time constraints.\n\n" + "Deliverables:\n" + "1. Firmware source code (ESP-IDF, Arduino, etc.)\n" + "2. Pinout and peripheral configuration documentation\n" + "3. Build/flash instructions\n" + "4. Communication protocol implementation\n\n" + "Constraints:\n" + "- Document hardware assumptions (pin assignments, voltage levels)\n" + "- Include error recovery for hardware failures\n" + "- Consider watchdog timers and brownout scenarios\n" + "- Optimize for the target MCU's constraints (RAM, flash, clock)" + ), + "toolsets": ["terminal", "file", "search", "web"], + "model": "", + "max_turns": 30, + "output_contract": { + "files": ["FIRMWARE.md", "pinout.md"], + "format": "code", + }, + "quality_gates": [ + "Compiles without errors", + "Watchdog configured", + "Error paths for peripheral failures", + "Hardware interactions documented", + ], + "version": 1, + }, + "network-engineer": { + "label": "Network Engineer Agent", + "description": "Network protocols, diagnostics, security, monitoring", + "prompt": ( + "You are the Network Engineer Agent. You design network functionality,\n" + "implement network services, configure diagnostics, build monitoring tools,\n" + "and ensure networking best practices and security.\n\n" + "Deliverables:\n" + "1. Network architecture design\n" + "2. Network service configuration\n" + "3. Diagnostic tools or scripts\n" + "4. Monitoring and alerting setup\n" + "5. Security hardening recommendations\n\n" + "Principles:\n" + "- Follow established networking standards (RFCs)\n" + "- Document all port, protocol, and addressing decisions\n" + "- Consider failure modes: link loss, latency, congestion\n" + "- Include security at every layer" + ), + "toolsets": ["terminal", "file", "search", "web"], + "model": "", + "max_turns": 30, + "output_contract": { + "files": ["NETWORK.md"], + "format": "markdown", + }, + "quality_gates": [ + "No default/weak credentials in configs", + "Firewall rules documented with rationale", + "Redundancy/failover addressed", + "Monitoring thresholds defined", + ], + "version": 1, + }, + "devops-engineer": { + "label": "DevOps Engineer Agent", + "description": "CI/CD, containerization, infrastructure-as-code, deployment", + "prompt": ( + "You are the DevOps Engineer Agent. You build and maintain infrastructure\n" + "and deployment pipelines so the team can ship reliably and efficiently.\n\n" + "Deliverables:\n" + "1. CI/CD pipeline configuration\n" + "2. Container definitions (Dockerfile, K8s manifests)\n" + "3. Infrastructure-as-code (Terraform, Ansible)\n" + "4. Deployment runbook\n" + "5. Monitoring and alerting configuration\n\n" + "Principles:\n" + "- Infrastructure is code — version-controlled and reproducible\n" + "- Immutable deployments preferred over in-place updates\n" + "- Secrets never in source — use vault/secret-store\n" + "- Health checks, readiness probes, graceful shutdown" + ), + "toolsets": ["terminal", "file", "search", "web"], + "model": "", + "max_turns": 30, + "output_contract": { + "files": ["DEPLOY.md"], + "format": "markdown", + }, + "quality_gates": [ + "Pipeline runs to completion", + "Secrets not hardcoded", + "Rollback procedure documented", + "Resource limits set", + ], + "version": 1, + }, + } + + for name, data in defaults.items(): + path = AGENTS_DIR / f"{name}.yaml" + if not path.exists(): + _write_agent_yaml(name, data) + + +def _write_agent_yaml(name: str, data: Dict[str, Any]) -> None: + """Write an agent profile YAML file.""" + lines = [ + f"name: {name}", + f"label: {data['label']}", + f"description: {data['description']}", + f"enabled: true", + "", + "prompt: |", + ] + for line in data["prompt"].split("\n"): + lines.append(f" {line}" if line.strip() else " ") + + lines.append("") + lines.append("toolsets:") + for t in data.get("toolsets", []): + lines.append(f' - "{t}"') + + lines.append(f'model: "{data.get("model", "")}"') + lines.append(f"max_turns: {data.get('max_turns', 30)}") + lines.append("") + lines.append("quality_gates:") + for g in data.get("quality_gates", []): + lines.append(f' - "{g}"') + + lines.append("") + lines.append(f"version: {data.get('version', 1)}") + lines.append(f"last_updated: {time.strftime('%Y-%m-%d')}") + + path = AGENTS_DIR / f"{name}.yaml" + path.write_text("\n".join(lines) + "\n") + + +# --------------------------------------------------------------------------- +# Agent commands +# --------------------------------------------------------------------------- + + +def agent_list() -> None: + """List all agent profiles.""" + _ensure_orchestrator_dir() + agents = sorted(AGENTS_DIR.glob("*.yaml")) + if not agents: + print("No agent profiles found. Run 'hermes orchestrator agent create'") + return + + print(f"Agent profiles ({len(agents)}):") + print(f"{'NAME':<25} {'LABEL':<30} {'VERSION':<8} {'ENABLED'}") + print("-" * 75) + for path in agents: + name = path.stem + _show_agent_summary(name) + + +def _show_agent_summary(name: str) -> None: + """Print a single agent's summary line.""" + path = AGENTS_DIR / f"{name}.yaml" + if not path.exists(): + return + content = path.read_text() + label = _yaml_val(content, "label", name) + ver = _yaml_val(content, "version", "1") + enabled = _yaml_val(content, "enabled", "true") + enabled_str = "\u2713" if enabled == "true" else "\u2717" + print(f"{name:<25} {label:<30} v{ver:<6} {enabled_str}") + + +def _yaml_val(content: str, key: str, default: str = "") -> str: + """Extract a simple YAML scalar value by key.""" + import re + m = re.search(rf"^{key}:\s*(.+?)$", content, re.MULTILINE) + if m: + return m.group(1).strip().strip('"') + return default + + +def agent_show(name: str) -> None: + """Show full agent profile.""" + _ensure_orchestrator_dir() + path = AGENTS_DIR / f"{name}.yaml" + if not path.exists(): + print(f"Agent '{name}' not found.") + print(f"Available: {', '.join(p.stem for p in sorted(AGENTS_DIR.glob('*.yaml')))}") + return + content = path.read_text() + print(f"=== Agent: {name} ===") + print(content) + + +def agent_create() -> None: + """Interactive agent creation wizard.""" + _ensure_orchestrator_dir() + print("=== New Agent Profile ===") + name = input("Agent name (lowercase, hyphens): ").strip() + if not name: + print("Cancelled.") + return + path = AGENTS_DIR / f"{name}.yaml" + if path.exists(): + print(f"Agent '{name}' already exists.") + return + + label = input("Label (human-readable name): ").strip() or name + desc = input("Description: ").strip() or "" + + print("Enter system prompt (end with '---' on its own line):") + prompt_lines = [] + while True: + line = input() + if line.strip() == "---": + break + prompt_lines.append(line) + prompt = "\n".join(prompt_lines) + + data = { + "label": label, + "description": desc, + "prompt": prompt, + "toolsets": ["file", "search"], + "model": "", + "max_turns": 30, + "quality_gates": [], + "version": 1, + } + _write_agent_yaml(name, data) + print(f"Agent '{name}' created.") + + +def agent_edit(name: str) -> None: + """Open agent profile in $EDITOR.""" + _ensure_orchestrator_dir() + path = AGENTS_DIR / f"{name}.yaml" + if not path.exists(): + print(f"Agent '{name}' not found.") + return + editor = os.environ.get("EDITOR", "nano") + subprocess.check_call([editor, str(path)]) + + +def agent_remove(name: str) -> None: + """Remove an agent profile.""" + _ensure_orchestrator_dir() + path = AGENTS_DIR / f"{name}.yaml" + if not path.exists(): + print(f"Agent '{name}' not found.") + return + path.unlink() + print(f"Agent '{name}' removed.") + + +def agent_toggle(name: str) -> None: + """Toggle agent enabled/disabled.""" + _ensure_orchestrator_dir() + path = AGENTS_DIR / f"{name}.yaml" + if not path.exists(): + print(f"Agent '{name}' not found.") + return + content = path.read_text() + if "enabled: true" in content: + content = content.replace("enabled: true", "enabled: false") + print(f"Agent '{name}' disabled.") + else: + content = content.replace("enabled: false", "enabled: true") + print(f"Agent '{name}' enabled.") + path.write_text(content) + + +# --------------------------------------------------------------------------- +# Team commands +# --------------------------------------------------------------------------- + + +def team_assemble() -> None: + """Interactive team assembly (stub — reads from orchestrate_tool).""" + _ensure_orchestrator_dir() + from hermes_cli.commands import print_markdown + + print("Interactive team assembly (select agents for your project):") + print() + + agents = sorted(AGENTS_DIR.glob("*.yaml")) + if not agents: + print("No agent profiles found. Create some first with 'hermes orchestrator agent create'") + return + + print("Available agents:") + for i, path in enumerate(agents, 1): + name = path.stem + content = path.read_text() + label = _yaml_val(content, "label", name) + enabled = _yaml_val(content, "enabled", "true") + marker = "\u2713" if enabled == "true" else "\u2717" + print(f" [{i}] {marker} {name:<25} {label}") + + print() + print("Select agents by number (space-separated), or 'all' for all enabled:") + selection = input("> ").strip() + if not selection: + print("Cancelled.") + return + + if selection.lower() == "all": + selected = [p.stem for p in agents if _yaml_val(p.read_text(), "enabled", "true") == "true"] + else: + indices = [] + for part in selection.split(): + try: + idx = int(part) - 1 + if 0 <= idx < len(agents): + indices.append(idx) + except ValueError: + pass + selected = [agents[i].stem for i in indices] + + if not selected: + print("No agents selected.") + return + + print(f"\nTeam assembled: {', '.join(selected)}") + print("To run this team, use the 'orchestrate_team' tool in a session,") + print("or submit your project description to start the orchestration.") + + +def team_history() -> None: + """Show past team runs from orchestrator.db.""" + _ensure_orchestrator_dir() + try: + import sqlite3 + conn = sqlite3.connect(str(DB_PATH)) + cur = conn.execute( + "SELECT id, team_id, started_at, status, summary " + "FROM runs ORDER BY started_at DESC LIMIT 20" + ) + rows = cur.fetchall() + conn.close() + + if not rows: + print("No team runs yet.") + return + + print(f"{'RUN ID':<20} {'TEAM':<8} {'DATE':<20} {'STATUS':<12} {'SUMMARY'}") + print("-" * 80) + for row_id, team_id, started, status, summary in rows: + s = (summary or "")[:50] + print(f"{row_id:<20} {team_id:<8} {started:<20} {status:<12} {s}") + except (ImportError, sqlite3.OperationalError): + print("No run history yet. Use 'orchestrate_team' tool in a session to create runs.") + + +# --------------------------------------------------------------------------- +# Evolution commands +# --------------------------------------------------------------------------- + + +def evolution_run() -> None: + """Trigger the evolution cycle.""" + _ensure_orchestrator_dir() + report_path = EVOLUTION_DIR / "report.md" + history_path = EVOLUTION_DIR / "history.jsonl" + + timestamp = time.strftime("%Y-%m-%d %H:%M:%S %Z") + lines = [ + f"# Agent Evolution Report — {timestamp}", + "", + "## Environment", + ] + + # Check tooling versions + import shutil as _sh + checks = [ + ("Python", "python3", "--version"), + ("Node.js", "node", "--version"), + ("Rust", "rustc", "--version"), + ("Docker", "docker", "--version"), + ("Swift", "swift", "--version"), + ("Go", "go", "version"), + ] + for name, cmd, flag in checks: + p = _sh.which(cmd) + if p: + try: + result = subprocess.run([cmd, flag], capture_output=True, text=True, timeout=10) + ver = result.stdout.strip().split("\n")[0] if result.stdout else "unknown" + lines.append(f"- {name}: {ver}") + except Exception: + lines.append(f"- {name}: (check failed)") + + lines.append("") + lines.append("## Agent Profiles") + agents = sorted(AGENTS_DIR.glob("*.yaml")) + lines.append(f"- Total: {len(agents)}") + for path in agents: + lines.append(f" - {path.stem}") + + lines.append("") + lines.append("## Status") + lines.append("Evolution cycle completed. No automatic prompt changes made.") + lines.append("Review prompts manually or enable auto_update_profiles in config.") + + report_path.write_text("\n".join(lines) + "\n") + + # Log to history + entry = json.dumps({ + "ran_at": timestamp, + "agent_count": len(agents), + "status": "completed", + }) + with open(history_path, "a") as f: + f.write(entry + "\n") + + print(f"Evolution cycle complete. Report: {report_path}") + print(lines[-2]) + print(lines[-1]) + + +def evolution_status() -> None: + """Show when evolution last ran and what changed.""" + _ensure_orchestrator_dir() + report_path = EVOLUTION_DIR / "report.md" + if not report_path.exists(): + print("No evolution runs yet.") + return + content = report_path.read_text() + first_line = content.split("\n")[0] if content else "No report content" + print(first_line) + print() + print("Full report:", report_path) + + +def evolution_config() -> None: + """Show evolution configuration.""" + from hermes_cli.config import load_config + config = load_config() + evo = config.get("orchestrator", {}).get("evolution", {}) + print("Evolution settings:") + print(f" Enabled: {evo.get('enabled', True)}") + print(f" Interval: {evo.get('interval', 'weekly')}") + print(f" Day: {evo.get('day', 0)} (0=Sunday)") + print(f" Time (UTC): {evo.get('time', '03:00')}") + print(f" Auto-update prompts: {evo.get('auto_update_profiles', True)}") + print(f" Notify on change: {evo.get('notify_on_change', True)}") + print() + print("To change: hermes config set orchestrator.evolution.interval daily") + + +# --------------------------------------------------------------------------- +# Status command +# --------------------------------------------------------------------------- + + +def orchestrator_status() -> None: + """Show orchestrator status overview.""" + _ensure_orchestrator_dir() + agents = list(AGENTS_DIR.glob("*.yaml")) + enabled = sum(1 for p in agents if "enabled: true" in p.read_text()) + print(f"Agent profiles: {len(agents)} ({enabled} enabled)") + print() + + if agents: + print("Profiles:") + for path in sorted(agents): + _show_agent_summary(path.stem) + + print() + evolution_report = EVOLUTION_DIR / "report.md" + if evolution_report.exists(): + first = evolution_report.read_text().split("\n")[0] + print(f"Last evolution: {first}") + else: + print("Last evolution: never") + + print() + print("Path:", ORCHESTRATOR_DIR) + + +# --------------------------------------------------------------------------- +# Main dispatch +# --------------------------------------------------------------------------- + + +def orchestrator_command(args) -> int: + """Dispatch orchestrator subcommands. + + Called from hermes_cli/main.py:cmd_orchestrator. + """ + sub = getattr(args, "orchestrator_command", None) + if sub in {None, ""}: + orchestrator_status() + return 0 + + if sub == "agent": + agent_sub = getattr(args, "orchestrator_agent_action", None) + if agent_sub in {None, ""}: + agent_list() + elif agent_sub == "list": + agent_list() + elif agent_sub == "show": + agent_show(args.name) + elif agent_sub == "create": + agent_create() + elif agent_sub == "edit": + agent_edit(args.name) + elif agent_sub == "remove": + agent_remove(args.name) + elif agent_sub == "toggle": + agent_toggle(args.name) + else: + print(f"Unknown agent subcommand: {agent_sub}") + return 1 + + elif sub == "team": + team_sub = getattr(args, "orchestrator_team_action", None) + if team_sub in {None, ""}: + print("Usage: hermes orchestrator team assemble|history") + return 1 + elif team_sub == "assemble": + team_assemble() + elif team_sub == "history": + team_history() + else: + print(f"Unknown team subcommand: {team_sub}") + return 1 + + elif sub == "evolution": + evo_sub = getattr(args, "orchestrator_evolution_action", None) + if evo_sub in {None, ""}: + evolution_status() + elif evo_sub == "run": + evolution_run() + elif evo_sub == "status": + evolution_status() + elif evo_sub == "config": + evolution_config() + else: + print(f"Unknown evolution subcommand: {evo_sub}") + return 1 + + else: + print(f"Unknown orchestrator subcommand: {sub}") + return 1 + + return 0 \ No newline at end of file diff --git a/tools/orchestrator_tool.py b/tools/orchestrator_tool.py new file mode 100644 index 000000000000..0b475361324b --- /dev/null +++ b/tools/orchestrator_tool.py @@ -0,0 +1,342 @@ +""" +Orchestrator tool — dynamic multi-agent team assembly for Agent Breakout. + +Exposes a single ``orchestrate_team`` tool that reads agent profiles from +``~/.hermes/orchestrator/agents/*.yaml``, assembles a team matching the +project requirements, spawns subagents via ``delegate_task`` in the correct +order (design → build → integrate), and records the run in orchestrator.db. + +Gated on ``orchestrator.enabled`` in config.yaml. +""" + +import json +import logging +import os +import time +from pathlib import Path +from typing import Any, Dict, List, Optional, Union + +from hermes_constants import get_hermes_home + +logger = logging.getLogger(__name__) + + +def _get_orchestrator_dir() -> Path: + return get_hermes_home() / "orchestrator" + + +def _get_agents_dir() -> Path: + return _get_orchestrator_dir() / "agents" + + +def _read_agent_profile(name: str) -> Optional[Dict[str, Any]]: + """Read an agent profile from its YAML file.""" + path = _get_agents_dir() / f"{name}.yaml" + if not path.exists(): + return None + + content = path.read_text() + import re + + def _val(key: str, default: str = "") -> str: + m = re.search(rf"^{key}:\s*(.+?)$", content, re.MULTILINE) + return m.group(1).strip().strip('"') if m else default + + def _list_val(key: str) -> List[str]: + """Extract a YAML list of quoted strings.""" + items = [] + in_list = False + for line in content.split("\n"): + if line.strip().startswith(f"{key}:"): + in_list = True + continue + if in_list: + if not line.strip().startswith("- "): + break + val = line.strip()[2:].strip().strip('"') + if val: + items.append(val) + return items + + return { + "name": _val("name", name), + "label": _val("label", name), + "description": _val("description", ""), + "enabled": _val("enabled", "true") == "true", + "prompt": content, # full content used as context prompt + "toolsets": _list_val("toolsets") or ["file", "search"], + "model": _val("model", ""), + "max_turns": int(_val("max_turns", "30")), + "version": int(_val("version", "1")), + } + + +def _list_enabled_agents() -> List[Dict[str, Any]]: + """List all enabled agent profiles.""" + agents = [] + agents_dir = _get_agents_dir() + if not agents_dir.exists(): + return [] + + for path in sorted(agents_dir.glob("*.yaml")): + profile = _read_agent_profile(path.stem) + if profile and profile.get("enabled", True): + agents.append(profile) + return agents + + +def _analyze_project(goal: str, agents: List[Dict[str, Any]]) -> List[str]: + """Simple heuristic-based agent selection from project description.""" + goal_lower = goal.lower() + + # Domain keywords → agent name mapping + domain_map: Dict[str, List[str]] = { + "architecture": ["architect"], + "design": ["architect"], + "system": ["architect"], + "software": ["software-engineer"], + "backend": ["software-engineer"], + "api": ["software-engineer"], + "app": ["software-engineer"], + "firmware": ["firmware-engineer"], + "esp32": ["firmware-engineer"], + "microcontroller": ["firmware-engineer"], + "embedded": ["firmware-engineer"], + "arduino": ["firmware-engineer"], + "network": ["network-engineer"], + "networking": ["network-engineer"], + "protocol": ["network-engineer"], + "wifi": ["network-engineer"], + "devops": ["devops-engineer"], + "deploy": ["devops-engineer"], + "ci/cd": ["devops-engineer"], + "docker": ["devops-engineer"], + "kubernetes": ["devops-engineer"], + "frontend": ["frontend-engineer"], + "ui": ["frontend-engineer"], + "web": ["frontend-engineer"], + "dashboard": ["frontend-engineer"], + "ml": ["ml-engineer"], + "model": ["ml-engineer"], + "training": ["ml-engineer"], + "machine learning": ["ml-engineer"], + "data": ["data-engineer"], + "database": ["data-engineer"], + "pipeline": ["data-engineer"], + "analytics": ["data-engineer"], + "security": ["security-engineer"], + "vulnerability": ["security-engineer"], + "audit": ["security-engineer"], + "pentest": ["security-engineer"], + "test": ["qa-engineer"], + "testing": ["qa-engineer"], + "quality": ["qa-engineer"], + } + + selected_set: set = set() + for keyword, agent_names in domain_map.items(): + if keyword in goal_lower: + selected_set.update(agent_names) + + # Always include architect for complex projects + selected_set.add("architect") + + # Filter to agents that actually exist and are enabled + agent_names = {a["name"] for a in agents} + selected = [name for name in selected_set if name in agent_names] + + return selected[:4] # Max 4 agents per team + + +def _save_run( + team_id: str, + agent_names: List[str], + status: str, + summary: str = "", + errors: List[str] = None, +) -> None: + """Log a team run to orchestrator.db.""" + try: + import sqlite3 + db_path = _get_orchestrator_dir() / "orchestrator.db" + db_path.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(str(db_path)) + conn.execute( + """CREATE TABLE IF NOT EXISTS runs ( + id TEXT PRIMARY KEY, + team_id TEXT, + agent_names TEXT, + started_at TEXT NOT NULL, + finished_at TEXT, + status TEXT DEFAULT 'running', + summary TEXT, + output_errors TEXT + )""" + ) + conn.execute( + "INSERT OR REPLACE INTO runs (id, team_id, agent_names, started_at, " + "finished_at, status, summary, output_errors) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + ( + team_id, + team_id, + json.dumps(agent_names), + time.strftime("%Y-%m-%d %H:%M:%S UTC", time.gmtime()), + time.strftime("%Y-%m-%d %H:%M:%S UTC", time.gmtime()), + status, + summary, + json.dumps(errors or []), + ), + ) + conn.commit() + conn.close() + except Exception as exc: + logger.warning("Failed to save run to orchestrator.db: %s", exc) + + +# --------------------------------------------------------------------------- +# Tool registration +# --------------------------------------------------------------------------- + + +def check_requirements() -> bool: + """Gate: only expose orchestrator tool when orchestrator.enabled is True.""" + try: + from hermes_cli.config import load_config + config = load_config() + return config.get("orchestrator", {}).get("enabled", True) + except Exception: + return True # Default to enabled + + +def orchestrate_team(goal: str, context: str = "", agents: str = "") -> str: + """ + Assemble a specialized agent team for a complex project. + + Analyzes the project goal, selects appropriate agents from the + ~/.hermes/orchestrator/agents/ profile registry, spawns them in the + correct dependency order (architect first, then parallel build agents, + then integration), and returns the integrated result. + + Args: + goal: Project goal/description. The tool analyzes this to select + appropriate agents from the registry. + context: Additional project context, constraints, or requirements + passed to all agents. + agents: Comma-separated agent names to explicitly select. If empty, + agents are auto-selected from the goal analysis. Example: + "architect,software-engineer,firmware-engineer,network-engineer" + + Returns: + JSON with team composition, run ID, agent outputs, and integration summary. + """ + from tools.delegate_tool import delegate_task + + start_time = time.time() + team_id = f"team_{int(start_time)}" + + # Read available agents + available = _list_enabled_agents() + + # Select team + if agents: + requested = [a.strip() for a in agents.split(",") if a.strip()] + team_names = [a for a in requested if a in {p["name"] for p in available}] + else: + team_names = [a["name"] for a in available if a["name"] == "architect"] + team_names.extend( + a["name"] for a in available if a["name"] != "architect" + ) + team_names = _analyze_project(goal, available) + + if not team_names: + result = { + "success": False, + "error": "No suitable agents found. Run 'hermes orchestrator agent list' to see available profiles.", + } + return json.dumps(result) + + # Phase 1: Architect (sequential — produces architecture) + architects = [n for n in team_names if n == "architect"] + build_agents = [n for n in team_names if n != "architect"] + + arch_output = "" + if architects: + arch_name = architects[0] + profile = _read_agent_profile(arch_name) + if profile: + arch_goal = ( + f"Design the system architecture for: {goal}\n\n" + f"Context: {context}\n\n" + f"Deliverables:\n" + f"1. System architecture diagram / description\n" + f"2. Component breakdown with responsibilities\n" + f"3. Interface contracts (APIs, data formats, protocols)\n" + f"4. Technology stack recommendation with rationale\n" + f"5. Dependency graph\n" + f"6. Key design decisions and trade-offs" + ) + try: + arch_result = delegate_task( + goal=arch_goal, + context=profile["prompt"], + toolsets=profile.get("toolsets", ["file", "search"]), + ) + arch_output = arch_result.get("summary", "") + except Exception as exc: + arch_output = f"(Architect error: {exc})" + + # Phase 2: Build agents (parallel) + build_tasks = [] + for agent_name in build_agents: + profile = _read_agent_profile(agent_name) + if not profile: + continue + + agent_goal = ( + f"Implement the {profile['label']} for: {goal}\n\n" + f"Architecture context: {arch_output[:2000]}\n\n" + f"Project context: {context}\n\n" + f"Deliverables per your agent profile:\n" + f"- Working implementation\n" + f"- Tests (where applicable)\n" + f"- Documentation\n" + f"- Build/run instructions" + ) + build_tasks.append({ + "goal": agent_goal, + "context": profile["prompt"], + "toolsets": profile.get("toolsets", ["file", "terminal", "search"]), + }) + + build_results = [] + if build_tasks: + try: + build_results = delegate_task(tasks=build_tasks) + except Exception as exc: + build_results = [{"summary": f"(Build phase error: {exc})"}] + + # Collect results + agent_outputs = {} + if arch_output: + agent_outputs["architect"] = arch_output + for i, agent_name in enumerate(build_agents): + if i < len(build_results): + agent_outputs[agent_name] = build_results[i].get("summary", "") + + elapsed = time.time() - start_time + summary = ( + f"Team assembled with {len(team_names)} agents " + f"({', '.join(team_names)}) in {elapsed:.1f}s." + ) + + _save_run(team_id, team_names, "completed", summary) + + result = { + "success": True, + "team_id": team_id, + "team": team_names, + "elapsed_seconds": round(elapsed, 1), + "summary": summary, + "agent_outputs": agent_outputs, + } + return json.dumps(result) \ No newline at end of file diff --git a/toolsets.py b/toolsets.py index 10c5dbb0ca07..f4a1d645e9b0 100644 --- a/toolsets.py +++ b/toolsets.py @@ -54,6 +54,8 @@ "clarify", # Code execution + delegation "execute_code", "delegate_task", + # Orchestrator — multi-agent team assembly (gated on orchestrator.enabled) + "orchestrate_team", # Cronjob management "cronjob", # Cross-platform messaging (gated on gateway running via check_fn) @@ -274,6 +276,17 @@ "includes": [], }, + "orchestrator": { + "description": ( + "Multi-agent team assembly and orchestration. Lets the agent " + "spawn a team of specialized subagents, each with domain-specific " + "context prompts, toolsets, and output contracts. Includes the " + "evolution cycle for periodic knowledge refresh." + ), + "tools": ["orchestrate_team"], + "includes": [], + }, + "discord": { "description": "Discord read and participate tools (fetch messages, search members, create threads)", "tools": ["discord"], From a79b3244c387397a6cf81dd381d95da4430671e2 Mon Sep 17 00:00:00 2001 From: heyfinal <405dmg@gmail.com> Date: Sun, 7 Jun 2026 19:50:37 -0500 Subject: [PATCH 2/2] feat: dynamic agent creation with config toggle - Add dynamic_agents param to orchestrate_team tool ('true'/'false'/'auto') - When enabled, detects uncovered domains (LoRa, BLE, LiDAR, CV, FPGA, robotics, blockchain, CUDA, etc.) and auto-creates new agent profiles - Created agents written to ~/.hermes/orchestrator/agents/*.yaml, persist for future use, show in CLI and desktop tab - Config toggle: orchestrator.dynamic_agents (default: true) - Result JSON includes created_agents field - _write_agent_yaml_file helper extracted for reuse --- hermes_cli/config.py | 1 + tools/orchestrator_tool.py | 129 ++++++++++++++++++++++++++++++++++++- 2 files changed, 129 insertions(+), 1 deletion(-) diff --git a/hermes_cli/config.py b/hermes_cli/config.py index c9968dde6205..50dc870be19e 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -2056,6 +2056,7 @@ def _ensure_hermes_home_managed(home: Path): # and evolution for complex, multi-domain projects. "orchestrator": { "enabled": True, # Master toggle + "dynamic_agents": True, # Auto-create new agents when no match exists "default_team_size": 3, # Agents per team if not specified "evolution": { "enabled": True, diff --git a/tools/orchestrator_tool.py b/tools/orchestrator_tool.py index 0b475361324b..9d1bd05d0c34 100644 --- a/tools/orchestrator_tool.py +++ b/tools/orchestrator_tool.py @@ -193,6 +193,37 @@ def _save_run( logger.warning("Failed to save run to orchestrator.db: %s", exc) +def _write_agent_yaml_file(name: str, data: Dict[str, Any]) -> None: + """Write an agent profile YAML file to disk.""" + agents_dir = _get_agents_dir() + agents_dir.mkdir(parents=True, exist_ok=True) + path = agents_dir / f"{name}.yaml" + lines = [ + f"name: {name}", + f"label: {data.get('label', name)}", + f"description: {data.get('description', '')}", + f"enabled: true", + "", + "prompt: |", + ] + for line in data.get("prompt", "").split("\n"): + lines.append(f" {line}" if line.strip() else " ") + lines.append("") + lines.append("toolsets:") + for t in data.get("toolsets", []): + lines.append(f' - "{t}"') + lines.append(f'model: "{data.get("model", "")}"') + lines.append(f"max_turns: {data.get('max_turns', 30)}") + lines.append("") + lines.append("quality_gates:") + for g in data.get("quality_gates", []): + lines.append(f' - "{g}"') + lines.append("") + lines.append(f"version: {data.get('version', 1)}") + lines.append(f"last_updated: {time.strftime('%Y-%m-%d')}") + path.write_text("\n".join(lines) + "\n") + + # --------------------------------------------------------------------------- # Tool registration # --------------------------------------------------------------------------- @@ -208,7 +239,7 @@ def check_requirements() -> bool: return True # Default to enabled -def orchestrate_team(goal: str, context: str = "", agents: str = "") -> str: +def orchestrate_team(goal: str, context: str = "", agents: str = "", dynamic_agents: str = "") -> str: """ Assemble a specialized agent team for a complex project. @@ -225,6 +256,10 @@ def orchestrate_team(goal: str, context: str = "", agents: str = "") -> str: agents: Comma-separated agent names to explicitly select. If empty, agents are auto-selected from the goal analysis. Example: "architect,software-engineer,firmware-engineer,network-engineer" + dynamic_agents: "true" to auto-create new agent profiles when the + goal mentions domains not covered by any existing agent. + Defaults to "auto" which reads orchestrator.dynamic_agents + from config.yaml. "false" to disable. Returns: JSON with team composition, run ID, agent outputs, and integration summary. @@ -236,6 +271,18 @@ def orchestrate_team(goal: str, context: str = "", agents: str = "") -> str: # Read available agents available = _list_enabled_agents() + goal_lower = goal.lower() + + # Resolve dynamic_agents setting + dynamic = dynamic_agents.lower() if dynamic_agents else "" + if dynamic not in ("true", "false"): + try: + from hermes_cli.config import load_config + config = load_config() + dynamic = "true" if config.get("orchestrator", {}).get("dynamic_agents", True) else "false" + except Exception: + dynamic = "true" + should_dynamic = dynamic == "true" # Select team if agents: @@ -248,6 +295,85 @@ def orchestrate_team(goal: str, context: str = "", agents: str = "") -> str: ) team_names = _analyze_project(goal, available) + # Dynamic agent creation: detect uncovered domains + created_agents = [] + if should_dynamic and not agents: + existing_names = {a["name"] for a in available} + # Domain keywords NOT covered by existing agents + uncovered_domains = { + "lora": "LoRa Radio Engineer", + "ble": "Bluetooth Low Energy Engineer", + "bluetooth": "Bluetooth Low Energy Engineer", + "zigbee": "Zigbee Protocol Engineer", + "lte": "Cellular / LTE Engineer", + "5g": "Cellular / 5G Engineer", + "gnss": "GNSS / GPS Engineer", + "gps": "GNSS / GPS Engineer", + "rfid": "RFID Engineer", + "nfc": "NFC Engineer", + "satellite": "Satellite Communications Engineer", + "sdr": "Software Defined Radio Engineer", + "radar": "Radar Systems Engineer", + "lidar": "LiDAR Engineer", + "computer vision": "Computer Vision Engineer", + "object detection": "Computer Vision Engineer", + "yolo": "Computer Vision Engineer", + "audio": "Audio / DSP Engineer", + "speech": "Speech / Audio Engineer", + "motor": "Motor Control Engineer", + "actuator": "Actuator Control Engineer", + "ros": "ROS / Robotics Engineer", + "robotics": "ROS / Robotics Engineer", + "fpga": "FPGA Engineer", + "verilog": "FPGA / Verilog Engineer", + "vhdl": "FPGA / VHDL Engineer", + "blockchain": "Blockchain Engineer", + "smart contract": "Smart Contract Engineer", + "solidity": "Solidity / EVM Engineer", + "quantum": "Quantum Computing Engineer", + "game": "Game Developer", + "unity": "Unity Developer", + "unreal": "Unreal Engine Developer", + "webgl": "WebGL / Graphics Engineer", + "opengl": "OpenGL / Graphics Engineer", + "metal": "Metal / GPU Engineer", + "cuda": "CUDA / GPU Engineer", + } + + for keyword, label in uncovered_domains.items(): + if keyword in goal_lower: + agent_name = keyword.replace(" ", "-").replace("/", "-") + if agent_name not in existing_names: + profile = { + "name": agent_name, + "label": label, + "description": f"Auto-created {label} for project", + "prompt": ( + f"You are the {label}. Your role is to handle all {keyword}-related " + f"aspects of the project. Deliver working implementations, " + f"documentation, and test coverage. Follow best practices in " + f"your domain.\n\n" + f"Deliverables:\n" + f"1. Working implementation\n" + f"2. Tests\n" + f"3. Documentation\n" + f"4. Build/run instructions" + ), + "toolsets": ["terminal", "file", "search", "web"], + "model": "", + "max_turns": 30, + "quality_gates": [ + "Implementation compiles/runs without errors", + "Edge cases handled", + "Documentation provided", + ], + "version": 1, + } + _write_agent_yaml_file(agent_name, profile) + created_agents.append(agent_name) + existing_names.add(agent_name) + team_names.append(agent_name) + if not team_names: result = { "success": False, @@ -337,6 +463,7 @@ def orchestrate_team(goal: str, context: str = "", agents: str = "") -> str: "team": team_names, "elapsed_seconds": round(elapsed, 1), "summary": summary, + "created_agents": created_agents, "agent_outputs": agent_outputs, } return json.dumps(result) \ No newline at end of file