Selector de agentes

This commit is contained in:
Jordan Diaz
2026-04-07 10:57:40 +00:00
parent 38ac9cecdc
commit c1a29bbbf8
30 changed files with 760 additions and 357 deletions

View File

@@ -1,6 +1,3 @@
from .planner import PlannerAgent
from .coder import CoderAgent
from .collector import CollectorAgent
from .reviewer import ReviewerAgent
from .base import BaseAgent
__all__ = ["PlannerAgent", "CoderAgent", "CollectorAgent", "ReviewerAgent"]
__all__ = ["BaseAgent"]

View File

@@ -96,9 +96,7 @@ class BaseAgent:
):
if chunk.delta:
full_text += chunk.delta
# Only emit deltas for user-facing agents (coder, collector)
# Planner/reviewer output is internal
if self.profile.role not in ("planner", "reviewer"):
if self.profile.stream_deltas:
await self.sse.emit(
EventType.AGENT_DELTA,
{

View File

@@ -1,178 +0,0 @@
"""Coder agent — executes implementation steps using tools."""
from __future__ import annotations
from ...models.agent import AgentProfile, AgentRole
from .base import BaseAgent
CODER_SYSTEM_PROMPT = """Eres el asistente de desarrollo de Acai CMS. Ayudas al usuario con su web: crear módulos, editar contenido, explorar páginas, gestionar datos, y responder preguntas.
# Acai CMS — Project Instructions
This is an Acai CMS website project. Follow these instructions when working with the codebase.
## Environment
- The site runs in Docker, typically at **http://localhost:8080**
- You can make HTTP requests to test pages, APIs, or form submissions
- If you need to inspect the live site, use browser tools (Playwright MCP) or HTTP requests to localhost:8080
## Project Structure
```
.
├── template/estandar/
│ ├── modulos/ # Builder modules (visual components)
│ │ └── <module-id>/
│ │ ├── index-base.tpl # Twig template (source — EDIT THIS)
│ │ ├── style.css # Module styles
│ │ └── script.js # Module JavaScript
│ │ ├── index.tpl # Compiled (auto-generated, do NOT edit)
│ │ ├── index-twig.tpl # Compiled (auto-generated, do NOT edit)
│ │ └── builder.json # Compiled builder vars (auto-generated, do NOT edit)
│ ├── css/ # Global CSS
│ └── js/ # Global JavaScript
├── hooks/ # PHP hooks (server-side logic)
├── cms/
│ ├── data/schema/ # Database table schemas (JSON)
│ ├── lib/plugins/ # CMS plugins
│ └── uploads/ # Uploaded media files
├── .acai # Project config (domain, tokens, DB credentials)
├── .docker/
│ ├── .env # Docker environment (DB credentials)
│ ├── docker-compose.yml
│ ├── tunnel-url.txt # Public tunnel URL (if active)
│ └── bore-db-url.txt # Database tunnel URL (if active)
└── database.sql # Database dump
```
## Key Concepts
### Modules (`template/estandar/modulos/`)
Visual components that the site builder uses. Each module is a self-contained unit with its own template (Twig + Acai attributes), CSS, and JS. Modules are placed on pages via the drag-and-drop builder. The editable file is always `index-base.tpl`.
- Include other modules: `<module_id :param1="value1"></module_id>`
- Each module instance gets a unique `section_id` variable for anchors/scoping
- Use `interno` variable to detect CMS editor mode vs public view
See [docs/modular-system.md](docs/modular-system.md) for detailed rules.
### Pages
Every record with an `enlace` field is a page. Pages are either **Builder** (modular) or **Standard**:
- **Builder**: `controlador` = `cms/lib/plugins/builder_saas/controlador.php` — content via modules
- **Standard**: `controlador` = `cms/lib/plugins/builder_saas/controlador_tabla.php` — content in record fields
**Critical**: Never change `enlace` or `controlador` of existing pages unless explicitly asked.
See [docs/pages-and-records.md](docs/pages-and-records.md) for full details.
### General Sections
Database-backed templates (headers, footers, record views) that use the `thisrecord` variable to access record fields. They use the same Twig + Acai attribute engine as modules.
- Upload fields return arrays: `thisrecord.image[0].urlPath`
- Foreign keys use `_num` suffix: `category_num`
See [docs/modular-system.md](docs/modular-system.md) for details.
### Hooks (`hooks/`)
PHP files that execute server-side logic. Triggered by:
- Twig filter: `'hooks/module_id/' | hook({param: value})`
- HTML tag: `<hook result="var" endpoint="/hooks/module_id/" :param="value"></hook>`
- JavaScript: `CmsApi.hook('/hooks/module_id/', {param: value}, callback)`
- Form action: via `c-form` attribute
There are two valid hook locations:
- Global hooks in `hooks/hooks.<hook-id>.php` for reusable/shared server-side logic
- Module-specific hooks in `template/estandar/modulos/<module-id>/hook.php` for logic owned by a single module
How to reference them:
- Global hook `hooks/hooks.calcular_precio.php` -> endpoint `/hooks/calcular_precio/`
- Module hook `template/estandar/modulos/hero_banner/hook.php` -> endpoint `/hooks/hero_banner/`
- Module hook `template/estandar/modulos/buscadorapartados_hjd8s/hook.php` -> endpoint `/hooks/buscadorapartados_hjd8s/`
Rule of thumb:
- If the logic is only used by one module, prefer that module's `hook.php`
- If the logic will be reused by several modules/pages, create a global hook in `hooks/`
- Return arrays from hooks; do not use `echo json_encode(...)` or `exit`
See [docs/hooks-and-api.md](docs/hooks-and-api.md) for usage.
**Important:** Table names in CmsApi/Twig do NOT use the `cms_` prefix. The primary key is always `num`, never `id`.
## Acai Core (web-base)
The project workspace contains only the **customization layer** (modules, hooks, schemas, uploads). The CMS core (routing, rendering engine, admin panel, APIs) lives in a separate directory called **web-base** that is mounted as a Docker volume.
The web-base path can be obtained via: `GET http://localhost:9090/api/web-base-path`
Do NOT modify web-base files — they are shared across all projects.
## Critical Rules
1. **Before working with any area (hooks, modules, templates, CSS/JS, etc.), read the corresponding documentation in `docs/` first.** Do not guess or assume — always consult the docs before taking action.
2. **NEVER use `mkdir` to create directories.** Instead, use `acai-write` to create the first file inside the directory — this creates parent directories automatically. For example, to create a new module, directly write the `index-base.tpl` file.
3. Only edit `index-base.tpl` in modules — `index.tpl`, `index-twig.tpl`, and `builder.json` are auto-generated
4. Editing or creating any `index-base.tpl` through `acai-write` or `acai-line-replace` triggers automatic compilation. `compile_module` is only for manual recovery when you need to force a recompile without changing the file.
5. `script.js` and `style.css` are static files — do NOT use Twig syntax inside them. Pass dynamic values from `index-base.tpl` via `data-*` attributes.
6. Use Twig **filters** (with `|`), never Twig functions
7. Table names without `cms_` prefix everywhere
8. Primary key is `num`, never `id`
9. Upload fields are arrays — access with `[0].urlPath`
10. Tailwind CSS as primary styling, custom CSS scoped with BEM when needed
11. Twig concatenation uses `~` operator: `'value=' ~ variable`
12. `enlace` (link) fields already include slashes — **NEVER modify an existing enlace** unless explicitly asked
13. **NEVER modify the `controlador` field** of existing records — it defines whether a page is Builder or Standard
14. All CmsApi/Twig variables and field names should be extracted from the schemas in `cms/data/schema/<nombre_de_tabla>.ini.php` before use. Do not guess variable names or field types.
15. NEVER make up a field or table name. Always check the schema files in `cms/data/schema/` to confirm field names and types before using them.
## MCP Tools
This project has MCP tools for managing modules, records, media, and more. **Before starting any task, consult the tools reference for the correct workflow.**
See [docs/mcp-tools-reference.md](docs/mcp-tools-reference.md) for the complete list of available tools and step-by-step workflows.
Key workflows:
- **Create module**: Read [docs/module-creation-guide.md](docs/module-creation-guide.md) first → create files with `acai-write` / refine with `acai-line-replace` → automatic compile on `index-base.tpl` → `add_module_to_record` (returns sectionId) → `set_module_config_vars` (returns uploadFields) → images via uploadFields. Use `compile_module` only if you need a manual recompile without editing the file.
- **Edit module**: `acai-view` → `acai-line-replace` (or `acai-write` for full rewrites) → automatic compile on `index-base.tpl`
- **Add images**: use `uploadFields` from `set_module_config_vars` response → `upload_record_image`
- **Generate images**: `generate_image` → `upload_record_image` with returned URL
## Documentation
- [docs/modular-system.md](docs/modular-system.md) — Modules, general sections, global variables
- [docs/builder-fields.md](docs/builder-fields.md) — Builder field types, Acai attributes, c-form, components
- [docs/twig-filters.md](docs/twig-filters.md) — Twig filters reference (get, hook, module, queryDB, etc.)
- [docs/hooks-and-api.md](docs/hooks-and-api.md) — PHP hooks, CmsApi, CocoDB, record creation
- [docs/css-js-conventions.md](docs/css-js-conventions.md) — CSS/JS/Vue 3, Tailwind, BEM, native components
- [docs/quick-reference.md](docs/quick-reference.md) — Cheat sheet: domain rules, field types, filters
- [docs/production-patterns.md](docs/production-patterns.md) — Real production patterns (header, zigzag, FAQ, forms)
- [docs/vue-builder-rules.md](docs/vue-builder-rules.md) — CMS-VUE rules (tabs, colorpicker, components)
- [docs/vue-builder-examples.md](docs/vue-builder-examples.md) — Vue builder examples (Banner Slideshow, etc.)
- [docs/pages-and-records.md](docs/pages-and-records.md) — Page types (Builder vs Standard), sections, visibility, critical rules
- [docs/module-creation-guide.md](docs/module-creation-guide.md) — Module creation workflow, style reference, field types
- [docs/mcp-tools-reference.md](docs/mcp-tools-reference.md) — MCP tools reference, available tools, workflows
"""
def create_coder_profile() -> AgentProfile:
return AgentProfile(
role=AgentRole.CODER,
name="coder",
system_prompt=CODER_SYSTEM_PROMPT,
allowed_tools=[], # All tools allowed
temperature=0.2,
max_tokens=4096,
context_sections=[
"immutable_rules",
"project_profile",
"knowledge_base",
"task_state",
],
)
class CoderAgent(BaseAgent):
"""Executes implementation steps."""
pass

View File

@@ -1,46 +0,0 @@
"""Collector agent — gathers context and information."""
from __future__ import annotations
from ...models.agent import AgentProfile, AgentRole
from .base import BaseAgent
COLLECTOR_SYSTEM_PROMPT = """Eres un Agente Recolector de Contexto. Tu rol es recopilar información necesaria para una tarea.
## Instrucciones
- Lee archivos, busca en el código, explora documentación.
- Produce un resumen estructurado de lo que encontraste.
- Extrae hechos clave, restricciones y dependencias.
- NO modifiques nada — solo observa y reporta.
- Responde SIEMPRE en español.
## Formato de salida
Produce un resumen estructurado:
1. Archivos relevantes y sus propósitos
2. Patrones o convenciones encontrados
3. Dependencias o restricciones
4. Recomendaciones para el paso de implementación
"""
def create_collector_profile() -> AgentProfile:
return AgentProfile(
role=AgentRole.COLLECTOR,
name="collector",
system_prompt=COLLECTOR_SYSTEM_PROMPT,
allowed_tools=[], # All tools allowed (read-only preferred)
temperature=0.1,
max_tokens=2048,
context_sections=[
"immutable_rules",
"project_profile",
"knowledge_base",
"task_state",
"working_context",
],
)
class CollectorAgent(BaseAgent):
"""Gathers context and information for tasks."""
pass

View File

@@ -1,129 +0,0 @@
"""Planner agent — decomposes objectives into executable plans."""
from __future__ import annotations
import json
import logging
from typing import Any
from ...models.agent import AgentProfile, AgentRole
from ...models.session import SessionState, TaskStep, TaskStatus
from .base import BaseAgent
logger = logging.getLogger(__name__)
PLANNER_SYSTEM_PROMPT = """Eres un Agente Planificador de Acai CMS. Tu rol es analizar el mensaje del usuario y decidir cómo responder.
## Instrucciones
- PRIMERO revisa el historial de conversación para entender el contexto.
- Si el mensaje es conversacional (saludos, preguntas sobre la conversación, datos personales mencionados antes, preguntas generales que NO requieren consultar la base de datos ni herramientas) → devuelve una respuesta directa usando la información del historial.
- SOLO genera un plan de ejecución cuando el usuario pide explícitamente una acción sobre su web: crear módulos, editar contenido, explorar páginas, consultar tablas de la base de datos, etc.
- "¿Cómo me llamo?" es conversacional (responde del historial), NO es una consulta a la base de datos.
- Responde SIEMPRE en español.
## Formato de salida
### Para respuestas directas (saludos, preguntas simples):
{
"direct_response": "Tu respuesta aquí. Sé amable y conciso."
}
### Para tareas que requieren herramientas:
{
"plan": [
{"description": "descripción del paso", "agent_role": "coder|collector|reviewer"},
...
],
"constraints": ["restricciones o notas importantes"],
"facts": ["hechos establecidos del análisis"]
}
## REGLAS CRÍTICAS para planes:
- Máximo 2-3 steps. Un coder competente puede hacer múltiples acciones en un solo step.
- Para CREAR UN MÓDULO: 1 step coder (crea archivos + añade a página + configura vars). NO necesita steps separados para cada acción.
- Para EXPLORAR: 1 step coder (consulta tablas + lista módulos).
- Para EDITAR CONTENIDO: 1 step coder (lee + modifica).
- SOLO añade un step reviewer si la tarea es compleja (crear módulo con hook, editar múltiples páginas).
- NUNCA generes steps redundantes (ej: "crear módulo" + "añadir a página" + "configurar vars" son UN SOLO step).
Devuelve SOLO el objeto JSON, sin comentarios fuera."""
def create_planner_profile() -> AgentProfile:
return AgentProfile(
role=AgentRole.PLANNER,
name="planner",
system_prompt=PLANNER_SYSTEM_PROMPT,
allowed_tools=[], # Planner doesn't use tools
temperature=0.2,
max_tokens=2048,
context_sections=[
"immutable_rules",
"project_profile",
"knowledge_base",
"task_state",
"artifact_memory",
],
)
class PlannerAgent(BaseAgent):
"""Generates execution plans from objectives."""
async def plan(self, session: SessionState) -> tuple[list[TaskStep] | str, dict[str, int]]:
"""Generate a plan or a direct response.
Returns:
(steps, usage) if plan needed
(direct_response_string, usage) if no plan needed
"""
result = await self.execute(session, max_steps=1)
usage = result.get("usage", {"input_tokens": 0, "output_tokens": 0})
content = result["content"].strip()
# Parse the JSON from the model output
try:
json_str = content
if "```" in content:
start = content.find("{")
end = content.rfind("}") + 1
if start >= 0 and end > start:
json_str = content[start:end]
parsed = json.loads(json_str)
# Check for direct response (no plan needed)
if "direct_response" in parsed:
return parsed["direct_response"], usage
# Build plan steps
steps: list[TaskStep] = []
for item in parsed.get("plan", []):
steps.append(
TaskStep(
description=item.get("description", ""),
agent_role=item.get("agent_role", "coder"),
status=TaskStatus.PENDING,
)
)
if session.current_task:
session.current_task.constraints.extend(
parsed.get("constraints", [])
)
session.current_task.facts_extracted.extend(
parsed.get("facts", [])
)
return steps, usage
except (json.JSONDecodeError, KeyError) as e:
logger.warning("Failed to parse planner output: %s", e)
return [
TaskStep(
description=session.current_task.objective
if session.current_task
else "Execute task",
agent_role="coder",
)
], usage

View File

@@ -1,47 +0,0 @@
"""Reviewer agent — validates outputs and provides feedback."""
from __future__ import annotations
from ...models.agent import AgentProfile, AgentRole
from .base import BaseAgent
REVIEWER_SYSTEM_PROMPT = """Eres un Agente Revisor. Tu rol es validar el trabajo realizado por otros agentes.
## Instrucciones
- Revisa los artefactos producidos en esta sesión.
- Verifica corrección, completitud y calidad.
- Identifica problemas, bugs o piezas faltantes.
- Proporciona retroalimentación accionable.
- Responde SIEMPRE en español.
## Formato de salida
Produce una revisión estructurada:
1. **Estado**: APROBADO | NECESITA_CAMBIOS | RECHAZADO
2. **Problemas**: Lista de problemas encontrados
3. **Sugerencias**: Mejoras a considerar
4. **Hechos**: Nuevos hechos establecidos durante la revisión
"""
def create_reviewer_profile() -> AgentProfile:
return AgentProfile(
role=AgentRole.REVIEWER,
name="reviewer",
system_prompt=REVIEWER_SYSTEM_PROMPT,
allowed_tools=[], # All tools allowed
temperature=0.1,
max_tokens=2048,
context_sections=[
"immutable_rules",
"project_profile",
"knowledge_base",
"task_state",
"artifact_memory",
"working_context",
],
)
class ReviewerAgent(BaseAgent):
"""Reviews and validates work products."""
pass

View File

@@ -1,7 +1,7 @@
"""Orchestrator Engine — single-agent execution.
Flow: message → coder agent (with tools) → response
No planner, no reviewer. The coder decides what to do.
Flow: message → selected agent (with tools) → response
The agent is determined by the session's agent_id via AgentRegistry.
"""
from __future__ import annotations
@@ -15,16 +15,16 @@ from ..config import settings
from ..context.engine import ContextEngine
from ..mcp.manager import MCPManager
from ..memory.store import MemoryStore
from ..models.agent import AgentRole
from ..models.agent import AgentProfile
from ..models.session import SessionState, SessionStatus, TaskStatus
from ..streaming.sse import SSEEmitter, EventType
from .agents.coder import CoderAgent, create_coder_profile
from .agents.base import BaseAgent
logger = logging.getLogger(__name__)
class OrchestratorEngine:
"""Drives execution for a session message. Single agent, no planning."""
"""Drives execution for a session message with the selected agent."""
def __init__(
self,
@@ -33,13 +33,14 @@ class OrchestratorEngine:
mcp_client: MCPManager,
memory_store: MemoryStore,
sse_emitter: SSEEmitter,
agent_profile: AgentProfile,
) -> None:
self.model = model_adapter
self.context = context_engine
self.mcp = mcp_client
self.memory = memory_store
self.sse = sse_emitter
self._coder_profile = create_coder_profile()
self.agent_profile = agent_profile
# ------------------------------------------------------------------
# Public
@@ -84,11 +85,15 @@ class OrchestratorEngine:
session: SessionState,
message: str,
) -> dict[str, Any]:
"""Execute: message → coder → response."""
"""Execute: message → agent → response."""
await self.sse.emit(
EventType.EXECUTION_STARTED,
{"session_id": session.session_id, "message": message[:200]},
{
"session_id": session.session_id,
"agent_id": session.agent_id,
"message": message[:200],
},
session_id=session.session_id,
)
@@ -96,9 +101,9 @@ class OrchestratorEngine:
task = session.begin_task(objective=message)
task.status = TaskStatus.EXECUTING
# Execute with the coder agent directly
agent = CoderAgent(
profile=self._coder_profile,
# Execute with the selected agent
agent = BaseAgent(
profile=self.agent_profile,
model_adapter=self.model,
context_engine=self.context,
mcp_client=self.mcp,
@@ -130,6 +135,7 @@ class OrchestratorEngine:
session.task_history.append({
"task_id": task.task_id,
"objective": message,
"agent_id": session.agent_id,
"status": "completed",
"steps": 1,
"facts": task.facts_extracted[-10:],
@@ -167,6 +173,7 @@ class OrchestratorEngine:
{
"session_id": session.session_id,
"task_id": task.task_id,
"agent_id": session.agent_id,
"steps_completed": 1,
"steps_failed": [],
"status": "completed",
@@ -177,8 +184,9 @@ class OrchestratorEngine:
)
logger.info(
"Task %s completed (%d tools, %d artifacts, %d input tokens)",
"Task %s completed (agent=%s, %d tools, %d artifacts, %d input tokens)",
task.task_id,
session.agent_id,
len(result.get("tool_executions", [])),
len(result.get("artifacts", [])),
total_input,
@@ -187,6 +195,7 @@ class OrchestratorEngine:
return {
"session_id": session.session_id,
"task_id": task.task_id,
"agent_id": session.agent_id,
"content": content or "Task completed.",
"steps_completed": 1,
"steps_failed": [],

View File

@@ -0,0 +1,138 @@
"""Agent Registry — descubrimiento dinámico de agentes desde carpetas."""
from __future__ import annotations
import logging
from pathlib import Path
from typing import Any
import yaml
from ..models.agent import AgentProfile
logger = logging.getLogger(__name__)
class AgentRegistry:
"""Descubre y carga agentes desde subcarpetas de agents_dir.
Cada subcarpeta debe contener:
- agent.yaml — metadata y configuración del agente
- system.md — system prompt en markdown
"""
def __init__(self, agents_dir: Path) -> None:
self._agents: dict[str, AgentProfile] = {}
self._metadata: dict[str, dict[str, Any]] = {}
self._agents_dir = agents_dir
# ------------------------------------------------------------------
# Carga
# ------------------------------------------------------------------
def load(self) -> None:
"""Escanea agents_dir y carga todos los agentes encontrados."""
self._agents.clear()
self._metadata.clear()
if not self._agents_dir.is_dir():
logger.warning("Agents directory not found: %s", self._agents_dir)
return
for agent_dir in sorted(self._agents_dir.iterdir()):
if not agent_dir.is_dir():
continue
yaml_path = agent_dir / "agent.yaml"
prompt_path = agent_dir / "system.md"
if not yaml_path.exists():
logger.warning("Skipping %s — agent.yaml not found", agent_dir.name)
continue
try:
with open(yaml_path, encoding="utf-8") as f:
meta = yaml.safe_load(f) or {}
system_prompt = ""
if prompt_path.exists():
system_prompt = prompt_path.read_text(encoding="utf-8")
agent_id = meta.get("name", agent_dir.name)
profile = AgentProfile(
role=agent_id,
name=agent_id,
display_name=meta.get("display_name", agent_id),
description=meta.get("description", ""),
icon=meta.get("icon", "bot"),
category=meta.get("category", "general"),
system_prompt=system_prompt,
allowed_tools=meta.get("allowed_tools", []),
model_id=meta.get("model_id"),
temperature=meta.get("temperature"),
max_tokens=meta.get("max_tokens"),
context_sections=meta.get("context_sections", [
"immutable_rules",
"project_profile",
"knowledge_base",
"task_state",
]),
stream_deltas=meta.get("stream_deltas", True),
)
self._agents[agent_id] = profile
self._metadata[agent_id] = meta
logger.info("Loaded agent: %s (%s)", agent_id, meta.get("display_name", agent_id))
except Exception as e:
logger.error("Failed to load agent from %s: %s", agent_dir.name, e)
logger.info("Agent registry loaded: %d agents", len(self._agents))
# ------------------------------------------------------------------
# Consultas
# ------------------------------------------------------------------
def get(self, agent_id: str) -> AgentProfile | None:
"""Devuelve el AgentProfile para un agent_id."""
return self._agents.get(agent_id)
def get_metadata(self, agent_id: str) -> dict[str, Any] | None:
"""Devuelve la metadata completa (yaml) de un agente."""
return self._metadata.get(agent_id)
def get_system_prompt(self, agent_id: str) -> str | None:
"""Devuelve el system prompt de un agente."""
profile = self._agents.get(agent_id)
return profile.system_prompt if profile else None
def list_agents(self) -> list[dict[str, Any]]:
"""Lista todos los agentes con metadata (sin system prompt)."""
result = []
for agent_id, profile in self._agents.items():
result.append({
"id": agent_id,
"display_name": profile.display_name,
"description": profile.description,
"icon": profile.icon,
"category": profile.category,
"temperature": profile.temperature,
"max_tokens": profile.max_tokens,
"model_id": profile.model_id,
})
return result
@property
def default_agent_id(self) -> str:
return "acai"
@property
def agent_ids(self) -> list[str]:
return list(self._agents.keys())
def __contains__(self, agent_id: str) -> bool:
return agent_id in self._agents
def __len__(self) -> int:
return len(self._agents)

View File

@@ -1,60 +0,0 @@
"""Agent router — selects the right subagent for each step."""
from __future__ import annotations
import logging
from ..models.agent import AgentRole
from ..models.session import TaskStep
logger = logging.getLogger(__name__)
# Keyword-based routing hints
_ROLE_KEYWORDS: dict[AgentRole, list[str]] = {
AgentRole.COLLECTOR: [
"gather", "collect", "read", "explore", "search", "find",
"discover", "analyze", "investigate", "research", "scan",
"understand", "review existing",
],
AgentRole.CODER: [
"implement", "write", "create", "build", "code", "fix",
"modify", "refactor", "add", "update", "generate", "develop",
"edit", "change", "configure", "set up",
],
AgentRole.REVIEWER: [
"review", "validate", "check", "verify", "test", "audit",
"inspect", "evaluate", "assess", "confirm",
],
}
def route_step(step: TaskStep) -> AgentRole:
"""Determine which agent role should handle this step.
Uses the step's declared agent_role if valid, otherwise falls back
to keyword-based routing.
"""
# Respect explicit assignment
declared = step.agent_role.lower()
try:
role = AgentRole(declared)
return role
except ValueError:
pass
# Keyword-based fallback
desc_lower = step.description.lower()
scores: dict[AgentRole, int] = {role: 0 for role in _ROLE_KEYWORDS}
for role, keywords in _ROLE_KEYWORDS.items():
for kw in keywords:
if kw in desc_lower:
scores[role] += 1
best = max(scores, key=lambda r: scores[r])
if scores[best] > 0:
logger.info("Routed step '%s' to %s (score=%d)", step.description[:60], best, scores[best])
return best
# Default to coder
return AgentRole.CODER