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:
190
config/agents.yaml
Normal file
190
config/agents.yaml
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
# ── Agent Definitions ───────────────────────────────────────────────────────
|
||||||
|
#
|
||||||
|
# All agent differentiation lives here. The Python runtime reads this file
|
||||||
|
# and builds identical agent instances from a single seed class (SubAgent).
|
||||||
|
#
|
||||||
|
# To add a new agent: copy any block, change the values, restart.
|
||||||
|
# To remove an agent: delete or comment out its block.
|
||||||
|
# To change a model: update the model field. No code changes needed.
|
||||||
|
#
|
||||||
|
# Fields:
|
||||||
|
# name Display name
|
||||||
|
# role Functional role (used for routing and tool delegation)
|
||||||
|
# model Ollama model ID (null = use defaults.model)
|
||||||
|
# tools List of tool names this agent can access
|
||||||
|
# prompt System prompt — what makes this agent unique
|
||||||
|
# prompt_tier "full" (tool-capable models) or "lite" (small models)
|
||||||
|
# max_history Number of conversation turns to keep in context
|
||||||
|
# context_window Max context length (null = model default)
|
||||||
|
#
|
||||||
|
# ── Defaults ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
defaults:
|
||||||
|
model: qwen3.5:latest
|
||||||
|
prompt_tier: lite
|
||||||
|
max_history: 10
|
||||||
|
tools: []
|
||||||
|
context_window: null
|
||||||
|
|
||||||
|
# ── Routing ─────────────────────────────────────────────────────────────────
|
||||||
|
#
|
||||||
|
# Pattern-based routing replaces the old Helm LLM routing.
|
||||||
|
# Each agent lists keyword patterns that trigger delegation to it.
|
||||||
|
# First match wins. If nothing matches, the orchestrator handles it.
|
||||||
|
|
||||||
|
routing:
|
||||||
|
method: pattern # "pattern" (keyword matching) or "llm" (model-based)
|
||||||
|
patterns:
|
||||||
|
researcher:
|
||||||
|
- search
|
||||||
|
- research
|
||||||
|
- find out
|
||||||
|
- look up
|
||||||
|
- what is
|
||||||
|
- who is
|
||||||
|
- news about
|
||||||
|
- latest on
|
||||||
|
coder:
|
||||||
|
- code
|
||||||
|
- implement
|
||||||
|
- debug
|
||||||
|
- fix bug
|
||||||
|
- write function
|
||||||
|
- refactor
|
||||||
|
- test
|
||||||
|
- programming
|
||||||
|
- python
|
||||||
|
- javascript
|
||||||
|
writer:
|
||||||
|
- write
|
||||||
|
- draft
|
||||||
|
- document
|
||||||
|
- summarize
|
||||||
|
- blog post
|
||||||
|
- readme
|
||||||
|
- changelog
|
||||||
|
memory:
|
||||||
|
- remember
|
||||||
|
- recall
|
||||||
|
- we discussed
|
||||||
|
- we talked about
|
||||||
|
- what did i say
|
||||||
|
- remind me
|
||||||
|
- have we
|
||||||
|
experimenter:
|
||||||
|
- experiment
|
||||||
|
- train
|
||||||
|
- fine-tune
|
||||||
|
- benchmark
|
||||||
|
- evaluate model
|
||||||
|
- run trial
|
||||||
|
|
||||||
|
# ── Agents ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
agents:
|
||||||
|
orchestrator:
|
||||||
|
name: Timmy
|
||||||
|
role: orchestrator
|
||||||
|
model: qwen3:30b
|
||||||
|
prompt_tier: full
|
||||||
|
max_history: 20
|
||||||
|
tools:
|
||||||
|
- web_search
|
||||||
|
- read_file
|
||||||
|
- write_file
|
||||||
|
- python
|
||||||
|
- memory_search
|
||||||
|
- memory_write
|
||||||
|
- system_status
|
||||||
|
- shell
|
||||||
|
prompt: |
|
||||||
|
You are Timmy, a sovereign local AI orchestrator.
|
||||||
|
|
||||||
|
You are the primary interface between the user and the agent swarm.
|
||||||
|
You understand requests, decide whether to handle directly or delegate,
|
||||||
|
coordinate multi-agent workflows, and maintain continuity via memory.
|
||||||
|
|
||||||
|
Hard Rules:
|
||||||
|
1. NEVER fabricate tool output. Call the tool and wait for real results.
|
||||||
|
2. If a tool returns an error, report the exact error.
|
||||||
|
3. If you don't know something, say so. Then use a tool. Don't guess.
|
||||||
|
4. When corrected, use memory_write to save the correction immediately.
|
||||||
|
|
||||||
|
researcher:
|
||||||
|
name: Seer
|
||||||
|
role: research
|
||||||
|
model: qwen3:30b
|
||||||
|
prompt_tier: full
|
||||||
|
max_history: 10
|
||||||
|
tools:
|
||||||
|
- web_search
|
||||||
|
- read_file
|
||||||
|
- memory_search
|
||||||
|
prompt: |
|
||||||
|
You are Seer, a research and information gathering specialist.
|
||||||
|
Find, evaluate, and synthesize information from external sources.
|
||||||
|
Be thorough, skeptical, concise, and cite sources.
|
||||||
|
|
||||||
|
coder:
|
||||||
|
name: Forge
|
||||||
|
role: code
|
||||||
|
model: qwen3:30b
|
||||||
|
prompt_tier: full
|
||||||
|
max_history: 15
|
||||||
|
tools:
|
||||||
|
- python
|
||||||
|
- write_file
|
||||||
|
- read_file
|
||||||
|
- shell
|
||||||
|
prompt: |
|
||||||
|
You are Forge, a code generation and tool building specialist.
|
||||||
|
Write clean code, be safe, explain your work, and test mentally.
|
||||||
|
Follow existing patterns in the codebase. Never break tests.
|
||||||
|
|
||||||
|
writer:
|
||||||
|
name: Quill
|
||||||
|
role: writing
|
||||||
|
model: null # uses defaults.model
|
||||||
|
prompt_tier: lite
|
||||||
|
max_history: 10
|
||||||
|
tools:
|
||||||
|
- write_file
|
||||||
|
- read_file
|
||||||
|
- memory_search
|
||||||
|
prompt: |
|
||||||
|
You are Quill, a writing and content generation specialist.
|
||||||
|
Write clearly, know your audience, be concise, use formatting.
|
||||||
|
|
||||||
|
memory:
|
||||||
|
name: Echo
|
||||||
|
role: memory
|
||||||
|
model: null # uses defaults.model
|
||||||
|
prompt_tier: lite
|
||||||
|
max_history: 10
|
||||||
|
tools:
|
||||||
|
- memory_search
|
||||||
|
- read_file
|
||||||
|
- write_file
|
||||||
|
prompt: |
|
||||||
|
You are Echo, a memory and context management specialist.
|
||||||
|
Remember, retrieve, and synthesize information from the past.
|
||||||
|
Be accurate, relevant, concise, and acknowledge uncertainty.
|
||||||
|
|
||||||
|
experimenter:
|
||||||
|
name: Lab
|
||||||
|
role: experiment
|
||||||
|
model: qwen3:30b
|
||||||
|
prompt_tier: full
|
||||||
|
max_history: 10
|
||||||
|
tools:
|
||||||
|
- run_experiment
|
||||||
|
- prepare_experiment
|
||||||
|
- shell
|
||||||
|
- python
|
||||||
|
- read_file
|
||||||
|
- write_file
|
||||||
|
prompt: |
|
||||||
|
You are Lab, an autonomous ML experimentation specialist.
|
||||||
|
You run time-boxed training experiments, evaluate metrics,
|
||||||
|
modify training code to improve results, and iterate.
|
||||||
|
Always report the metric delta. Never exceed the time budget.
|
||||||
@@ -1,11 +1,43 @@
|
|||||||
"""Agents package — Timmy orchestrator and configurable sub-agents."""
|
"""Agents package — YAML-driven agent factory.
|
||||||
|
|
||||||
|
All agent definitions live in config/agents.yaml.
|
||||||
|
The loader reads YAML and builds SubAgent instances from a single seed class.
|
||||||
|
"""
|
||||||
|
|
||||||
from timmy.agents.base import BaseAgent, SubAgent
|
from timmy.agents.base import BaseAgent, SubAgent
|
||||||
from timmy.agents.timmy import TimmyOrchestrator, create_timmy_swarm
|
from timmy.agents.loader import (
|
||||||
|
get_agent,
|
||||||
|
list_agents,
|
||||||
|
load_agents,
|
||||||
|
reload_agents,
|
||||||
|
route_request,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Backwards compat — old code that imported create_timmy_swarm
|
||||||
|
# now gets the YAML-driven equivalent.
|
||||||
|
|
||||||
|
|
||||||
|
def create_timmy_swarm():
|
||||||
|
"""Load all agents from YAML config.
|
||||||
|
|
||||||
|
Backwards-compatible wrapper for code that called create_timmy_swarm().
|
||||||
|
Returns the orchestrator agent (or first agent if no orchestrator defined).
|
||||||
|
"""
|
||||||
|
agents = load_agents()
|
||||||
|
return agents.get("orchestrator", next(iter(agents.values())))
|
||||||
|
|
||||||
|
|
||||||
|
# Also alias TimmyOrchestrator for old imports
|
||||||
|
TimmyOrchestrator = SubAgent
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"BaseAgent",
|
"BaseAgent",
|
||||||
"SubAgent",
|
"SubAgent",
|
||||||
"TimmyOrchestrator",
|
"TimmyOrchestrator",
|
||||||
"create_timmy_swarm",
|
"create_timmy_swarm",
|
||||||
|
"get_agent",
|
||||||
|
"list_agents",
|
||||||
|
"load_agents",
|
||||||
|
"reload_agents",
|
||||||
|
"route_request",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ BaseAgent provides:
|
|||||||
- Memory integration
|
- Memory integration
|
||||||
- Structured logging
|
- Structured logging
|
||||||
|
|
||||||
SubAgent is the concrete implementation used for all persona-based agents
|
SubAgent is the single seed class for ALL agents. Differentiation
|
||||||
(replacing the individual Helm/Echo/Seer/Forge/Quill classes).
|
comes entirely from config (agents.yaml), not from Python subclasses.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
@@ -29,7 +29,7 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
class BaseAgent(ABC):
|
class BaseAgent(ABC):
|
||||||
"""Base class for all sub-agents."""
|
"""Base class for all agents."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -38,36 +38,47 @@ class BaseAgent(ABC):
|
|||||||
role: str,
|
role: str,
|
||||||
system_prompt: str,
|
system_prompt: str,
|
||||||
tools: list[str] | None = None,
|
tools: list[str] | None = None,
|
||||||
|
model: str | None = None,
|
||||||
|
max_history: int = 10,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.agent_id = agent_id
|
self.agent_id = agent_id
|
||||||
self.name = name
|
self.name = name
|
||||||
self.role = role
|
self.role = role
|
||||||
self.tools = tools or []
|
self.tools = tools or []
|
||||||
|
self.model = model or settings.ollama_model
|
||||||
|
self.max_history = max_history
|
||||||
|
|
||||||
# Create Agno agent
|
# Create Agno agent
|
||||||
|
self.system_prompt = system_prompt
|
||||||
self.agent = self._create_agent(system_prompt)
|
self.agent = self._create_agent(system_prompt)
|
||||||
|
|
||||||
# Event bus for communication
|
# Event bus for communication
|
||||||
self.event_bus: EventBus | None = None
|
self.event_bus: EventBus | None = None
|
||||||
|
|
||||||
logger.info("%s agent initialized (id: %s)", name, agent_id)
|
logger.info(
|
||||||
|
"%s agent initialized (id: %s, model: %s)",
|
||||||
|
name,
|
||||||
|
agent_id,
|
||||||
|
self.model,
|
||||||
|
)
|
||||||
|
|
||||||
def _create_agent(self, system_prompt: str) -> Agent:
|
def _create_agent(self, system_prompt: str) -> Agent:
|
||||||
"""Create the underlying Agno agent."""
|
"""Create the underlying Agno agent with per-agent model."""
|
||||||
# Get tools from registry
|
# Get tools from registry
|
||||||
tool_instances = []
|
tool_instances = []
|
||||||
for tool_name in self.tools:
|
if tool_registry is not None:
|
||||||
handler = tool_registry.get_handler(tool_name)
|
for tool_name in self.tools:
|
||||||
if handler:
|
handler = tool_registry.get_handler(tool_name)
|
||||||
tool_instances.append(handler)
|
if handler:
|
||||||
|
tool_instances.append(handler)
|
||||||
|
|
||||||
return Agent(
|
return Agent(
|
||||||
name=self.name,
|
name=self.name,
|
||||||
model=Ollama(id=settings.ollama_model, host=settings.ollama_url, timeout=300),
|
model=Ollama(id=self.model, host=settings.ollama_url, timeout=300),
|
||||||
description=system_prompt,
|
description=system_prompt,
|
||||||
tools=tool_instances if tool_instances else None,
|
tools=tool_instances if tool_instances else None,
|
||||||
add_history_to_context=True,
|
add_history_to_context=True,
|
||||||
num_history_runs=10,
|
num_history_runs=self.max_history,
|
||||||
markdown=True,
|
markdown=True,
|
||||||
telemetry=settings.telemetry_enabled,
|
telemetry=settings.telemetry_enabled,
|
||||||
)
|
)
|
||||||
@@ -134,16 +145,18 @@ class BaseAgent(ABC):
|
|||||||
"agent_id": self.agent_id,
|
"agent_id": self.agent_id,
|
||||||
"name": self.name,
|
"name": self.name,
|
||||||
"role": self.role,
|
"role": self.role,
|
||||||
|
"model": self.model,
|
||||||
"status": "ready",
|
"status": "ready",
|
||||||
"tools": self.tools,
|
"tools": self.tools,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class SubAgent(BaseAgent):
|
class SubAgent(BaseAgent):
|
||||||
"""Concrete agent configured by persona data (prompt + tools).
|
"""Concrete agent — the single seed class for all agents.
|
||||||
|
|
||||||
Replaces the individual agent classes (Helm, Echo, Seer, Forge, Quill)
|
Every agent in the system is an instance of SubAgent, differentiated
|
||||||
which all shared the same structure and differed only by config.
|
only by the config values passed in from agents.yaml. No subclassing
|
||||||
|
needed — add new agents by editing YAML, not Python.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
@@ -153,6 +166,8 @@ class SubAgent(BaseAgent):
|
|||||||
role: str,
|
role: str,
|
||||||
system_prompt: str,
|
system_prompt: str,
|
||||||
tools: list[str] | None = None,
|
tools: list[str] | None = None,
|
||||||
|
model: str | None = None,
|
||||||
|
max_history: int = 10,
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__(
|
super().__init__(
|
||||||
agent_id=agent_id,
|
agent_id=agent_id,
|
||||||
@@ -160,6 +175,8 @@ class SubAgent(BaseAgent):
|
|||||||
role=role,
|
role=role,
|
||||||
system_prompt=system_prompt,
|
system_prompt=system_prompt,
|
||||||
tools=tools,
|
tools=tools,
|
||||||
|
model=model,
|
||||||
|
max_history=max_history,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def execute_task(self, task_id: str, description: str, context: dict) -> Any:
|
async def execute_task(self, task_id: str, description: str, context: dict) -> Any:
|
||||||
|
|||||||
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)
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
"""Timmy's delegation tools — submit tasks and list agents.
|
"""Timmy's delegation tools — submit tasks and list agents.
|
||||||
|
|
||||||
Delegation uses the orchestrator's sub-agent system. The old swarm
|
Reads agent roster from agents.yaml via the loader module.
|
||||||
task-queue was removed; delegation now records intent and returns the
|
No hardcoded agent lists.
|
||||||
target agent information.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
@@ -10,15 +9,6 @@ from typing import Any
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Agents available in the current orchestrator architecture
|
|
||||||
_VALID_AGENTS: dict[str, str] = {
|
|
||||||
"seer": "research",
|
|
||||||
"forge": "code",
|
|
||||||
"echo": "memory",
|
|
||||||
"helm": "routing",
|
|
||||||
"quill": "writing",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def delegate_task(
|
def delegate_task(
|
||||||
agent_name: str, task_description: str, priority: str = "normal"
|
agent_name: str, task_description: str, priority: str = "normal"
|
||||||
@@ -26,19 +16,24 @@ def delegate_task(
|
|||||||
"""Record a delegation intent to another agent.
|
"""Record a delegation intent to another agent.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
agent_name: Name of the agent to delegate to
|
agent_name: Name or ID of the agent to delegate to
|
||||||
task_description: What you want the agent to do
|
task_description: What you want the agent to do
|
||||||
priority: Task priority - "low", "normal", "high"
|
priority: Task priority - "low", "normal", "high"
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict with agent, status, and message
|
Dict with agent, status, and message
|
||||||
"""
|
"""
|
||||||
|
from timmy.agents.loader import list_agents
|
||||||
|
|
||||||
agent_name = agent_name.lower().strip()
|
agent_name = agent_name.lower().strip()
|
||||||
|
|
||||||
if agent_name not in _VALID_AGENTS:
|
# Build valid agents map from YAML config
|
||||||
|
available = {a["id"]: a["role"] for a in list_agents()}
|
||||||
|
|
||||||
|
if agent_name not in available:
|
||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"error": f"Unknown agent: {agent_name}. Valid agents: {', '.join(sorted(_VALID_AGENTS))}",
|
"error": f"Unknown agent: {agent_name}. Valid agents: {', '.join(sorted(available))}",
|
||||||
"task_id": None,
|
"task_id": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,32 +49,35 @@ def delegate_task(
|
|||||||
"success": True,
|
"success": True,
|
||||||
"task_id": None,
|
"task_id": None,
|
||||||
"agent": agent_name,
|
"agent": agent_name,
|
||||||
"role": _VALID_AGENTS[agent_name],
|
"role": available[agent_name],
|
||||||
"status": "noted",
|
"status": "noted",
|
||||||
"message": f"Delegation to {agent_name} ({_VALID_AGENTS[agent_name]}): {task_description[:100]}",
|
"message": f"Delegation to {agent_name} ({available[agent_name]}): {task_description[:100]}",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def list_swarm_agents() -> dict[str, Any]:
|
def list_swarm_agents() -> dict[str, Any]:
|
||||||
"""List all available sub-agents and their roles.
|
"""List all available sub-agents and their roles.
|
||||||
|
|
||||||
|
Reads from agents.yaml — no hardcoded roster.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict with agent list
|
Dict with agent list
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
from timmy.agents.timmy import _PERSONAS
|
from timmy.agents.loader import list_agents
|
||||||
|
|
||||||
|
agents = list_agents()
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"agents": [
|
"agents": [
|
||||||
{
|
{
|
||||||
"name": p["name"],
|
"name": a["name"],
|
||||||
"id": p["agent_id"],
|
"id": a["id"],
|
||||||
"role": p.get("role", ""),
|
"role": a["role"],
|
||||||
"status": "available",
|
"status": a.get("status", "available"),
|
||||||
"capabilities": ", ".join(p.get("tools", [])),
|
"capabilities": ", ".join(a.get("tools", [])),
|
||||||
}
|
}
|
||||||
for p in _PERSONAS
|
for a in agents
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -219,25 +219,26 @@ def get_task_queue_status() -> dict[str, Any]:
|
|||||||
|
|
||||||
|
|
||||||
def get_agent_roster() -> dict[str, Any]:
|
def get_agent_roster() -> dict[str, Any]:
|
||||||
"""Get the agent roster from the orchestrator's sub-agent definitions.
|
"""Get the agent roster from agents.yaml config.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict with agent list and summary.
|
Dict with agent list and summary.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
from timmy.agents.timmy import _PERSONAS
|
from timmy.agents.loader import list_agents
|
||||||
|
|
||||||
roster = []
|
agents = list_agents()
|
||||||
for persona in _PERSONAS:
|
roster = [
|
||||||
roster.append(
|
{
|
||||||
{
|
"id": a["id"],
|
||||||
"id": persona["agent_id"],
|
"name": a["name"],
|
||||||
"name": persona["name"],
|
"status": a.get("status", "available"),
|
||||||
"status": "available",
|
"capabilities": ", ".join(a.get("tools", [])),
|
||||||
"capabilities": ", ".join(persona.get("tools", [])),
|
"role": a.get("role", ""),
|
||||||
"role": persona.get("role", ""),
|
"model": a.get("model", ""),
|
||||||
}
|
}
|
||||||
)
|
for a in agents
|
||||||
|
]
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"agents": roster,
|
"agents": roster,
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
"""Tests for timmy.tools_delegation — delegate_task and list_swarm_agents."""
|
"""Tests for timmy.tools_delegation — delegate_task and list_swarm_agents.
|
||||||
|
|
||||||
|
Agent IDs are now defined in config/agents.yaml, not hardcoded Python.
|
||||||
|
Tests reference the YAML-defined IDs: orchestrator, researcher, coder,
|
||||||
|
writer, memory, experimenter.
|
||||||
|
"""
|
||||||
|
|
||||||
from timmy.tools_delegation import delegate_task, list_swarm_agents
|
from timmy.tools_delegation import delegate_task, list_swarm_agents
|
||||||
|
|
||||||
@@ -11,33 +16,37 @@ class TestDelegateTask:
|
|||||||
assert result["task_id"] is None
|
assert result["task_id"] is None
|
||||||
|
|
||||||
def test_valid_agent_names_normalised(self):
|
def test_valid_agent_names_normalised(self):
|
||||||
# Should still fail at import (no swarm module), but agent name is accepted
|
# Agent IDs are lowercased; whitespace should be stripped
|
||||||
result = delegate_task(" Seer ", "think about it")
|
result = delegate_task(" Researcher ", "think about it")
|
||||||
# The swarm import will fail, so success=False but error is about import, not agent name
|
|
||||||
assert "Unknown agent" not in result.get("error", "")
|
assert "Unknown agent" not in result.get("error", "")
|
||||||
|
|
||||||
def test_invalid_priority_defaults_to_normal(self):
|
def test_invalid_priority_defaults_to_normal(self):
|
||||||
# Even with bad priority, delegate_task should not crash
|
# Even with bad priority, delegate_task should not crash
|
||||||
result = delegate_task("forge", "build", priority="ultra")
|
result = delegate_task("coder", "build", priority="ultra")
|
||||||
assert isinstance(result, dict)
|
assert isinstance(result, dict)
|
||||||
|
|
||||||
def test_all_valid_agents_accepted(self):
|
def test_all_valid_agents_accepted(self):
|
||||||
valid_agents = ["seer", "forge", "echo", "helm", "quill"]
|
# These IDs match config/agents.yaml
|
||||||
|
valid_agents = ["orchestrator", "researcher", "coder", "writer", "memory", "experimenter"]
|
||||||
for agent in valid_agents:
|
for agent in valid_agents:
|
||||||
result = delegate_task(agent, "test task")
|
result = delegate_task(agent, "test task")
|
||||||
assert "Unknown agent" not in result.get("error", ""), f"{agent} rejected"
|
assert "Unknown agent" not in result.get("error", ""), f"{agent} rejected"
|
||||||
|
|
||||||
def test_mace_no_longer_valid(self):
|
def test_old_agent_names_no_longer_valid(self):
|
||||||
result = delegate_task("mace", "run security scan")
|
# Old hardcoded names should not work anymore
|
||||||
assert result["success"] is False
|
for old_name in ["seer", "forge", "echo", "helm", "quill", "mace"]:
|
||||||
assert "Unknown agent" in result["error"]
|
result = delegate_task(old_name, "test")
|
||||||
|
assert result["success"] is False
|
||||||
|
assert "Unknown agent" in result["error"]
|
||||||
|
|
||||||
|
|
||||||
class TestListSwarmAgents:
|
class TestListSwarmAgents:
|
||||||
def test_returns_agents_from_personas(self):
|
def test_returns_agents_from_yaml(self):
|
||||||
result = list_swarm_agents()
|
result = list_swarm_agents()
|
||||||
assert result["success"] is True
|
assert result["success"] is True
|
||||||
assert len(result["agents"]) > 0
|
assert len(result["agents"]) > 0
|
||||||
agent_names = [a["name"] for a in result["agents"]]
|
agent_names = [a["name"] for a in result["agents"]]
|
||||||
|
# These names come from config/agents.yaml
|
||||||
assert "Seer" in agent_names
|
assert "Seer" in agent_names
|
||||||
assert "Forge" in agent_names
|
assert "Forge" in agent_names
|
||||||
|
assert "Timmy" in agent_names
|
||||||
|
|||||||
Reference in New Issue
Block a user