forked from Rockachopa/Timmy-time-dashboard
- 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
250 lines
7.8 KiB
Python
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)
|