"""Dynamic agent registry — the bridge between the dashboard and the pipeline. Built-in personas (``config/personas.py``) are code defaults. ``state/agents.json`` holds dashboard edits: partial overrides for built-ins (budgets, toggles, prompts) and full definitions for custom agents. ``load_personas()`` merges the two and is what the nightly run, the executor, and the committee iterate — NOT the ``PERSONAS`` constant. Fail-open: a missing/corrupt agents.json or an invalid agent entry never blocks a run; the built-ins (and every valid entry) still load. """ from __future__ import annotations import json from pathlib import Path from loguru import logger from config.personas import PERSONAS, PERSONAS_BY_ID, Persona from config.settings import get_settings BUILTIN_IDS = set(PERSONAS_BY_ID) # Baseline for custom agents so a partial definition still validates. display_name and # system_prompt have no sensible default — the dashboard form requires them. _CUSTOM_BASE: dict = { "max_position_pct": 0.08, "target_pick_count": 4, "default_stop_pct": 0.07, "default_take_pct": 0.15, "preferred_horizon": "swing", "tool_emphasis": [], "enabled": True, "time_budget_min": None, "min_research_min": None, "options_quota": 1, "risk_style": "balanced", } def registry_path() -> Path: return get_settings().state_dir / "agents.json" def load_overrides() -> dict[str, dict]: """Raw {agent_id: fields} from agents.json. {} if absent or unreadable.""" path = registry_path() if not path.exists(): return {} try: data = json.loads(path.read_text()) agents = data.get("agents", {}) return {str(k): v for k, v in agents.items() if isinstance(v, dict)} except Exception as e: logger.warning("agents.json unreadable ({}); using built-in personas only", e) return {} def load_personas(include_disabled: bool = False) -> list[Persona]: """Effective agent list: built-ins (with overrides applied) + valid custom agents. Order: built-ins in code order, then custom agents sorted by id. Disabled agents are excluded unless ``include_disabled`` (the dashboard shows everything). """ overrides = load_overrides() personas: list[Persona] = [] for builtin in PERSONAS: fields = builtin.model_dump() ov = overrides.get(builtin.id, {}) fields.update({k: v for k, v in ov.items() if k != "id"}) try: personas.append(Persona(**fields)) except Exception as e: logger.warning("bad overrides for {} ({}); using built-in defaults", builtin.id, e) personas.append(builtin) for agent_id in sorted(set(overrides) - BUILTIN_IDS): fields = {**_CUSTOM_BASE, **overrides[agent_id], "id": agent_id} try: personas.append(Persona(**fields)) except Exception as e: logger.warning("skipping invalid custom agent {}: {}", agent_id, e) if include_disabled: return personas return [p for p in personas if p.enabled] def upsert_agent(agent_id: str, fields: dict) -> None: """Merge ``fields`` into an agent's entry and persist. Creates custom agents. For built-ins only the diff vs code defaults is stored, so unchanged fields keep tracking future code updates. """ agent_id = agent_id.strip().lower().replace(" ", "_").replace("-", "_") if not agent_id: raise ValueError("agent id must be non-empty") overrides = load_overrides() entry = {**overrides.get(agent_id, {}), **{k: v for k, v in fields.items() if k != "id"}} builtin = PERSONAS_BY_ID.get(agent_id) if builtin is not None: defaults = builtin.model_dump() entry = {k: v for k, v in entry.items() if defaults.get(k) != v} if not builtin: # validate custom agents up front so the dashboard surfaces mistakes immediately Persona(**{**_CUSTOM_BASE, **entry, "id": agent_id}) overrides[agent_id] = entry _write(overrides) def delete_agent(agent_id: str) -> None: """Remove a custom agent, or reset a built-in back to its code defaults.""" overrides = load_overrides() if agent_id in overrides: del overrides[agent_id] _write(overrides) def _write(overrides: dict[str, dict]) -> None: path = registry_path() path.parent.mkdir(parents=True, exist_ok=True) tmp = path.with_suffix(".json.tmp") tmp.write_text(json.dumps({"agents": overrides}, indent=2)) tmp.replace(path)