This repository has been archived on 2026-03-24. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
Timmy-time-dashboard/src/timmy/agents/loader.py
Kimi Agent 67497133fd fix: word-boundary routing + debug route command (#31)
- Replace substring matching with word-boundary regex in route_request()
- "fix the bug" now correctly routes to coder
- Multi-word patterns match if all words appear (any order)
- Add "timmy route" CLI command for debugging routing
- Add route_request_with_match() for pattern visibility
- Expand routing keywords in agents.yaml
- 22 new routing tests, all passing
2026-03-14 19:21:30 -04:00

250 lines
7.8 KiB
Python

"""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
import re
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 _matches_pattern(pattern: str, message: str) -> bool:
"""Check if a pattern matches using word-boundary matching.
For single-word patterns, uses \b word boundaries.
For multi-word patterns, all words must appear as whole words (in any order).
"""
pattern_lower = pattern.lower()
message_lower = message.lower()
words = pattern_lower.split()
for word in words:
# Use word boundary regex to match whole words only
if not re.search(rf"\b{re.escape(word)}\b", message_lower):
return False
return True
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", {})
for agent_id, keywords in patterns.items():
for keyword in keywords:
if _matches_pattern(keyword, user_message):
logger.debug("Routed to %s (matched: %r)", agent_id, keyword)
return agent_id
return None
def route_request_with_match(user_message: str) -> tuple[str | None, str | None]:
"""Route a user request and return both the agent and the matched pattern.
Returns a tuple of (agent_id, matched_pattern). If no match, returns (None, None).
"""
routing = get_routing_config()
if routing.get("method") != "pattern":
return None, None
patterns = routing.get("patterns", {})
for agent_id, keywords in patterns.items():
for keyword in keywords:
if _matches_pattern(keyword, user_message):
return agent_id, keyword
return None, 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)