forked from Rockachopa/Timmy-time-dashboard
test: update delegation tests for YAML-driven agent IDs
Old hardcoded IDs (seer, forge, echo, helm, quill) replaced with YAML-defined IDs (orchestrator, researcher, coder, writer, memory, experimenter). Added test that old names are explicitly rejected.
This commit is contained in:
212
src/timmy/agents/loader.py
Normal file
212
src/timmy/agents/loader.py
Normal file
@@ -0,0 +1,212 @@
|
||||
"""YAML-driven agent factory.
|
||||
|
||||
Reads config/agents.yaml and builds agent instances from a single seed
|
||||
class (SubAgent). All agent differentiation lives in YAML — no Python
|
||||
changes needed to add, remove, or reconfigure agents.
|
||||
|
||||
Usage:
|
||||
from timmy.agents.loader import load_agents, get_agent, list_agents
|
||||
from timmy.agents.loader import get_routing_config, route_request
|
||||
|
||||
agents = load_agents() # dict of agent_id -> SubAgent
|
||||
forge = get_agent("coder") # single agent by id
|
||||
target = route_request("fix bug") # pattern-based routing
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
from config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Module-level cache
|
||||
_agents: dict[str, Any] | None = None
|
||||
_config: dict[str, Any] | None = None
|
||||
|
||||
# Default config path (relative to repo root)
|
||||
_CONFIG_FILENAME = "config/agents.yaml"
|
||||
|
||||
|
||||
def _find_config_path() -> Path:
|
||||
"""Locate agents.yaml relative to the repo root."""
|
||||
repo_root = Path(settings.repo_root)
|
||||
config_path = repo_root / _CONFIG_FILENAME
|
||||
if not config_path.exists():
|
||||
raise FileNotFoundError(
|
||||
f"Agent config not found: {config_path}\nCreate {_CONFIG_FILENAME} in your repo root."
|
||||
)
|
||||
return config_path
|
||||
|
||||
|
||||
def _load_config(force_reload: bool = False) -> dict[str, Any]:
|
||||
"""Load and cache the agents.yaml config."""
|
||||
global _config
|
||||
if _config is not None and not force_reload:
|
||||
return _config
|
||||
|
||||
config_path = _find_config_path()
|
||||
with open(config_path) as f:
|
||||
_config = yaml.safe_load(f)
|
||||
|
||||
logger.info("Loaded agent config from %s", config_path)
|
||||
return _config
|
||||
|
||||
|
||||
def _resolve_model(agent_model: str | None, defaults: dict) -> str:
|
||||
"""Resolve agent model, falling back to defaults then settings."""
|
||||
if agent_model:
|
||||
return agent_model
|
||||
default_model = defaults.get("model")
|
||||
if default_model:
|
||||
return default_model
|
||||
return settings.ollama_model
|
||||
|
||||
|
||||
def _resolve_prompt_tier(agent_tier: str | None, defaults: dict) -> str:
|
||||
"""Resolve prompt tier, falling back to defaults."""
|
||||
return agent_tier or defaults.get("prompt_tier", "lite")
|
||||
|
||||
|
||||
def _build_system_prompt(agent_cfg: dict, prompt_tier: str) -> str:
|
||||
"""Build the full system prompt for an agent.
|
||||
|
||||
Combines the agent's custom prompt with the appropriate base prompt
|
||||
(full or lite) from the prompts module.
|
||||
"""
|
||||
from timmy.prompts import get_system_prompt
|
||||
|
||||
# Get base prompt for the tier
|
||||
tools_enabled = prompt_tier == "full"
|
||||
base_prompt = get_system_prompt(tools_enabled=tools_enabled)
|
||||
|
||||
# Prepend the agent's custom prompt
|
||||
custom_prompt = agent_cfg.get("prompt", "").strip()
|
||||
if custom_prompt:
|
||||
return f"{custom_prompt}\n\n{base_prompt}"
|
||||
|
||||
return base_prompt
|
||||
|
||||
|
||||
def load_agents(force_reload: bool = False) -> dict[str, Any]:
|
||||
"""Load all agents from YAML config.
|
||||
|
||||
Returns a dict of agent_id -> SubAgent instances.
|
||||
Agents are cached after first load; pass force_reload=True to re-read.
|
||||
"""
|
||||
global _agents
|
||||
if _agents is not None and not force_reload:
|
||||
return _agents
|
||||
|
||||
from timmy.agents.base import SubAgent
|
||||
|
||||
config = _load_config(force_reload=force_reload)
|
||||
defaults = config.get("defaults", {})
|
||||
agents_cfg = config.get("agents", {})
|
||||
|
||||
_agents = {}
|
||||
|
||||
for agent_id, agent_cfg in agents_cfg.items():
|
||||
model = _resolve_model(agent_cfg.get("model"), defaults)
|
||||
prompt_tier = _resolve_prompt_tier(agent_cfg.get("prompt_tier"), defaults)
|
||||
system_prompt = _build_system_prompt(agent_cfg, prompt_tier)
|
||||
max_history = agent_cfg.get("max_history", defaults.get("max_history", 10))
|
||||
tools = agent_cfg.get("tools", defaults.get("tools", []))
|
||||
|
||||
agent = SubAgent(
|
||||
agent_id=agent_id,
|
||||
name=agent_cfg.get("name", agent_id.title()),
|
||||
role=agent_cfg.get("role", "general"),
|
||||
system_prompt=system_prompt,
|
||||
tools=tools,
|
||||
model=model,
|
||||
max_history=max_history,
|
||||
)
|
||||
|
||||
_agents[agent_id] = agent
|
||||
logger.info(
|
||||
"Loaded agent: %s (model=%s, tools=%d, tier=%s)",
|
||||
agent_id,
|
||||
model,
|
||||
len(tools),
|
||||
prompt_tier,
|
||||
)
|
||||
|
||||
logger.info("Total agents loaded: %d", len(_agents))
|
||||
return _agents
|
||||
|
||||
|
||||
def get_agent(agent_id: str) -> Any:
|
||||
"""Get a single agent by ID. Loads config if not already loaded."""
|
||||
agents = load_agents()
|
||||
agent = agents.get(agent_id)
|
||||
if agent is None:
|
||||
available = ", ".join(sorted(agents.keys()))
|
||||
raise KeyError(f"Unknown agent: {agent_id!r}. Available: {available}")
|
||||
return agent
|
||||
|
||||
|
||||
def list_agents() -> list[dict[str, Any]]:
|
||||
"""List all agents with their metadata (for tools_intro, delegation, etc.)."""
|
||||
config = _load_config()
|
||||
defaults = config.get("defaults", {})
|
||||
agents_cfg = config.get("agents", {})
|
||||
|
||||
result = []
|
||||
for agent_id, agent_cfg in agents_cfg.items():
|
||||
result.append(
|
||||
{
|
||||
"id": agent_id,
|
||||
"name": agent_cfg.get("name", agent_id.title()),
|
||||
"role": agent_cfg.get("role", "general"),
|
||||
"model": _resolve_model(agent_cfg.get("model"), defaults),
|
||||
"tools": agent_cfg.get("tools", defaults.get("tools", [])),
|
||||
"status": "available",
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
# ── Routing ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def get_routing_config() -> dict[str, Any]:
|
||||
"""Get the routing configuration."""
|
||||
config = _load_config()
|
||||
return config.get("routing", {"method": "pattern", "patterns": {}})
|
||||
|
||||
|
||||
def route_request(user_message: str) -> str | None:
|
||||
"""Route a user request to an agent using pattern matching.
|
||||
|
||||
Returns the agent_id of the best match, or None if no pattern matches
|
||||
(meaning the orchestrator should handle it directly).
|
||||
"""
|
||||
routing = get_routing_config()
|
||||
|
||||
if routing.get("method") != "pattern":
|
||||
return None
|
||||
|
||||
patterns = routing.get("patterns", {})
|
||||
message_lower = user_message.lower()
|
||||
|
||||
for agent_id, keywords in patterns.items():
|
||||
for keyword in keywords:
|
||||
if keyword.lower() in message_lower:
|
||||
logger.debug("Routed to %s (matched: %r)", agent_id, keyword)
|
||||
return agent_id
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def reload_agents() -> dict[str, Any]:
|
||||
"""Force reload agents from YAML. Call after editing agents.yaml."""
|
||||
global _agents, _config
|
||||
_agents = None
|
||||
_config = None
|
||||
return load_agents(force_reload=True)
|
||||
Reference in New Issue
Block a user