Initial commit
This commit is contained in:
3
src/orchestrator/__init__.py
Normal file
3
src/orchestrator/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .engine import OrchestratorEngine
|
||||
|
||||
__all__ = ["OrchestratorEngine"]
|
||||
BIN
src/orchestrator/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
src/orchestrator/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
src/orchestrator/__pycache__/engine.cpython-312.pyc
Normal file
BIN
src/orchestrator/__pycache__/engine.cpython-312.pyc
Normal file
Binary file not shown.
BIN
src/orchestrator/__pycache__/router.cpython-312.pyc
Normal file
BIN
src/orchestrator/__pycache__/router.cpython-312.pyc
Normal file
Binary file not shown.
6
src/orchestrator/agents/__init__.py
Normal file
6
src/orchestrator/agents/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from .planner import PlannerAgent
|
||||
from .coder import CoderAgent
|
||||
from .collector import CollectorAgent
|
||||
from .reviewer import ReviewerAgent
|
||||
|
||||
__all__ = ["PlannerAgent", "CoderAgent", "CollectorAgent", "ReviewerAgent"]
|
||||
BIN
src/orchestrator/agents/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
src/orchestrator/agents/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
src/orchestrator/agents/__pycache__/base.cpython-312.pyc
Normal file
BIN
src/orchestrator/agents/__pycache__/base.cpython-312.pyc
Normal file
Binary file not shown.
BIN
src/orchestrator/agents/__pycache__/coder.cpython-312.pyc
Normal file
BIN
src/orchestrator/agents/__pycache__/coder.cpython-312.pyc
Normal file
Binary file not shown.
BIN
src/orchestrator/agents/__pycache__/collector.cpython-312.pyc
Normal file
BIN
src/orchestrator/agents/__pycache__/collector.cpython-312.pyc
Normal file
Binary file not shown.
BIN
src/orchestrator/agents/__pycache__/planner.cpython-312.pyc
Normal file
BIN
src/orchestrator/agents/__pycache__/planner.cpython-312.pyc
Normal file
Binary file not shown.
BIN
src/orchestrator/agents/__pycache__/reviewer.cpython-312.pyc
Normal file
BIN
src/orchestrator/agents/__pycache__/reviewer.cpython-312.pyc
Normal file
Binary file not shown.
241
src/orchestrator/agents/base.py
Normal file
241
src/orchestrator/agents/base.py
Normal file
@@ -0,0 +1,241 @@
|
||||
"""Base subagent class with shared execution logic."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any, AsyncIterator
|
||||
|
||||
from ...adapters.base import ModelAdapter, ModelConfig, StreamChunk
|
||||
from ...context.engine import ContextEngine
|
||||
from ...mcp.client import MCPClient
|
||||
from ...memory.store import MemoryStore
|
||||
from ...models.agent import AgentProfile
|
||||
from ...models.artifacts import ArtifactSummary
|
||||
from ...models.session import SessionState
|
||||
from ...models.tools import ToolExecution, ToolExecutionStatus
|
||||
from ...streaming.sse import SSEEmitter, EventType
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BaseAgent:
|
||||
"""Base class for all subagents."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
profile: AgentProfile,
|
||||
model_adapter: ModelAdapter,
|
||||
context_engine: ContextEngine,
|
||||
mcp_client: MCPClient,
|
||||
memory_store: MemoryStore,
|
||||
sse_emitter: SSEEmitter,
|
||||
) -> None:
|
||||
self.profile = profile
|
||||
self.model = model_adapter
|
||||
self.context = context_engine
|
||||
self.mcp = mcp_client
|
||||
self.memory = memory_store
|
||||
self.sse = sse_emitter
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
session: SessionState,
|
||||
max_steps: int = 10,
|
||||
) -> dict[str, Any]:
|
||||
"""Run the agent's execution loop.
|
||||
|
||||
Returns a result dict with keys: content, artifacts, tool_executions.
|
||||
"""
|
||||
artifacts: list[ArtifactSummary] = await self.memory.list_artifacts(
|
||||
session.session_id
|
||||
)
|
||||
tool_executions: list[ToolExecution] = []
|
||||
accumulated_content = ""
|
||||
working_items: list[dict[str, Any]] = []
|
||||
|
||||
for step in range(max_steps):
|
||||
# Build context — NEVER includes raw tool output
|
||||
ctx = await self.context.build_context(
|
||||
session=session,
|
||||
agent=self.profile,
|
||||
artifacts=artifacts,
|
||||
working_items=working_items,
|
||||
)
|
||||
|
||||
# Prepare tool definitions
|
||||
tool_defs = self._get_allowed_tools()
|
||||
|
||||
# Stream model response
|
||||
config = ModelConfig(
|
||||
model_id=self.profile.model_id or "",
|
||||
max_tokens=self.profile.max_tokens or 4096,
|
||||
temperature=self.profile.temperature or 0.3,
|
||||
)
|
||||
|
||||
full_text = ""
|
||||
tool_calls: list[dict[str, Any]] = []
|
||||
current_tool: dict[str, Any] = {}
|
||||
|
||||
async for chunk in self.model.stream(
|
||||
messages=ctx.to_messages(),
|
||||
tools=tool_defs if tool_defs else None,
|
||||
config=config,
|
||||
):
|
||||
if chunk.delta:
|
||||
full_text += chunk.delta
|
||||
await self.sse.emit(
|
||||
EventType.AGENT_DELTA,
|
||||
{
|
||||
"agent": self.profile.role,
|
||||
"delta": chunk.delta,
|
||||
"step": step,
|
||||
},
|
||||
session_id=session.session_id,
|
||||
)
|
||||
|
||||
if chunk.tool_name and not current_tool.get("name"):
|
||||
current_tool = {
|
||||
"id": chunk.tool_call_id,
|
||||
"name": chunk.tool_name,
|
||||
"arguments": "",
|
||||
}
|
||||
await self.sse.emit(
|
||||
EventType.TOOL_STARTED,
|
||||
{"tool": chunk.tool_name, "step": step},
|
||||
session_id=session.session_id,
|
||||
)
|
||||
|
||||
if chunk.tool_arguments and current_tool:
|
||||
current_tool["arguments"] += chunk.tool_arguments
|
||||
|
||||
if chunk.finish_reason == "tool_use" and current_tool.get("name"):
|
||||
# Parse arguments
|
||||
try:
|
||||
args = json.loads(current_tool["arguments"]) if current_tool["arguments"] else {}
|
||||
except json.JSONDecodeError:
|
||||
args = {}
|
||||
current_tool["parsed_arguments"] = args
|
||||
tool_calls.append(current_tool)
|
||||
current_tool = {}
|
||||
|
||||
if chunk.finish_reason == "end_turn":
|
||||
break
|
||||
|
||||
accumulated_content += full_text
|
||||
|
||||
# If no tool calls, we're done
|
||||
if not tool_calls:
|
||||
break
|
||||
|
||||
# Execute tool calls
|
||||
for tc in tool_calls:
|
||||
tool_exec = await self._execute_tool(
|
||||
session=session,
|
||||
tool_name=tc["name"],
|
||||
arguments=tc.get("parsed_arguments", {}),
|
||||
artifacts=artifacts,
|
||||
)
|
||||
tool_executions.append(tool_exec)
|
||||
|
||||
# Add summarised result to working context (NEVER raw)
|
||||
working_items.append({
|
||||
"role": "tool_result",
|
||||
"content": f"[{tc['name']}] {tool_exec.result_summary}",
|
||||
})
|
||||
|
||||
return {
|
||||
"content": accumulated_content,
|
||||
"artifacts": artifacts,
|
||||
"tool_executions": tool_executions,
|
||||
}
|
||||
|
||||
async def _execute_tool(
|
||||
self,
|
||||
session: SessionState,
|
||||
tool_name: str,
|
||||
arguments: dict[str, Any],
|
||||
artifacts: list[ArtifactSummary],
|
||||
) -> ToolExecution:
|
||||
"""Execute a tool and summarise the result."""
|
||||
exec_id = uuid.uuid4().hex[:12]
|
||||
tool_exec = ToolExecution(
|
||||
execution_id=exec_id,
|
||||
tool_name=tool_name,
|
||||
arguments=arguments,
|
||||
status=ToolExecutionStatus.RUNNING,
|
||||
)
|
||||
|
||||
start = time.monotonic()
|
||||
try:
|
||||
if self.mcp.is_running and tool_name in self.mcp.tools:
|
||||
result = await self.mcp.call_tool(tool_name, arguments)
|
||||
raw_output = self._extract_mcp_output(result)
|
||||
else:
|
||||
raw_output = f"Tool '{tool_name}' not available via MCP."
|
||||
|
||||
duration = (time.monotonic() - start) * 1000
|
||||
|
||||
# Summarise — raw output NEVER enters context
|
||||
task_id = session.current_task.task_id if session.current_task else "none"
|
||||
artifact = self.context.summarize_tool_output(
|
||||
tool_name=tool_name,
|
||||
raw_output=raw_output,
|
||||
session_id=session.session_id,
|
||||
task_id=task_id,
|
||||
)
|
||||
|
||||
# Store artifact
|
||||
await self.memory.store_artifact(session.session_id, artifact)
|
||||
artifacts.append(artifact)
|
||||
|
||||
tool_exec.status = ToolExecutionStatus.COMPLETED
|
||||
tool_exec.result_summary = artifact.summary
|
||||
tool_exec.duration_ms = duration
|
||||
|
||||
await self.sse.emit(
|
||||
EventType.TOOL_COMPLETED,
|
||||
{
|
||||
"tool": tool_name,
|
||||
"status": "completed",
|
||||
"summary": artifact.summary[:200],
|
||||
},
|
||||
session_id=session.session_id,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
tool_exec.status = ToolExecutionStatus.FAILED
|
||||
tool_exec.error = str(e)
|
||||
tool_exec.duration_ms = (time.monotonic() - start) * 1000
|
||||
logger.error("Tool execution failed: %s — %s", tool_name, e)
|
||||
|
||||
await self.sse.emit(
|
||||
EventType.TOOL_COMPLETED,
|
||||
{"tool": tool_name, "status": "failed", "error": str(e)},
|
||||
session_id=session.session_id,
|
||||
)
|
||||
|
||||
return tool_exec
|
||||
|
||||
def _get_allowed_tools(self) -> list[dict[str, Any]]:
|
||||
"""Return tool definitions filtered by this agent's allowed_tools."""
|
||||
if not self.mcp.is_running:
|
||||
return []
|
||||
all_tools = self.mcp.get_tool_definitions()
|
||||
if not self.profile.allowed_tools:
|
||||
return all_tools # No filter → all tools
|
||||
return [t for t in all_tools if t["name"] in self.profile.allowed_tools]
|
||||
|
||||
@staticmethod
|
||||
def _extract_mcp_output(result: dict[str, Any]) -> str:
|
||||
"""Extract text content from MCP tool result."""
|
||||
content = result.get("content", [])
|
||||
if isinstance(content, list):
|
||||
parts: list[str] = []
|
||||
for item in content:
|
||||
if isinstance(item, dict) and item.get("type") == "text":
|
||||
parts.append(item.get("text", ""))
|
||||
return "\n".join(parts) if parts else json.dumps(result)
|
||||
return str(content)
|
||||
46
src/orchestrator/agents/coder.py
Normal file
46
src/orchestrator/agents/coder.py
Normal file
@@ -0,0 +1,46 @@
|
||||
"""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 un Agente Programador. Tu rol es ejecutar tareas de implementación usando las herramientas disponibles.
|
||||
|
||||
## Instrucciones
|
||||
- Concéntrate en la descripción del paso actual.
|
||||
- Usa herramientas para lograr la tarea.
|
||||
- Sé preciso y minucioso.
|
||||
- Reporta lo que lograste, problemas encontrados y hechos relevantes.
|
||||
- NO produzcas explicaciones innecesarias — produce resultados.
|
||||
- Responde SIEMPRE en español.
|
||||
|
||||
## Uso de herramientas
|
||||
- Usa herramientas cuando necesites leer archivos, escribir código o ejecutar comandos.
|
||||
- Los resultados de herramientas se te presentarán resumidos — no verás la salida cruda.
|
||||
- Si necesitas más detalle de un resultado, solicita rehidratación.
|
||||
"""
|
||||
|
||||
|
||||
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",
|
||||
"artifact_memory",
|
||||
"working_context",
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
class CoderAgent(BaseAgent):
|
||||
"""Executes implementation steps."""
|
||||
pass
|
||||
46
src/orchestrator/agents/collector.py
Normal file
46
src/orchestrator/agents/collector.py
Normal file
@@ -0,0 +1,46 @@
|
||||
"""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
|
||||
107
src/orchestrator/agents/planner.py
Normal file
107
src/orchestrator/agents/planner.py
Normal file
@@ -0,0 +1,107 @@
|
||||
"""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. Tu rol es descomponer un objetivo en un plan de ejecución estructurado.
|
||||
|
||||
## Instrucciones
|
||||
- Analiza el objetivo y divídelo en pasos concretos y ordenados.
|
||||
- Cada paso debe ser ejecutable de forma independiente por un agente especializado.
|
||||
- Asigna cada paso al rol de agente apropiado: coder, collector o reviewer.
|
||||
- Responde SIEMPRE en español.
|
||||
|
||||
## Formato de salida
|
||||
Devuelve SOLO un objeto JSON:
|
||||
{
|
||||
"plan": [
|
||||
{"description": "descripción del paso", "agent_role": "coder|collector|reviewer"},
|
||||
...
|
||||
],
|
||||
"constraints": ["restricciones o notas importantes"],
|
||||
"facts": ["hechos establecidos del análisis"]
|
||||
}
|
||||
|
||||
NO incluyas comentarios fuera del JSON."""
|
||||
|
||||
|
||||
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) -> list[TaskStep]:
|
||||
"""Generate a plan and return TaskSteps."""
|
||||
result = await self.execute(session, max_steps=1)
|
||||
content = result["content"].strip()
|
||||
|
||||
# Parse the JSON plan from the model output
|
||||
try:
|
||||
# Try to extract JSON from the content
|
||||
json_str = content
|
||||
if "```" in content:
|
||||
# Extract from code block
|
||||
start = content.find("{")
|
||||
end = content.rfind("}") + 1
|
||||
if start >= 0 and end > start:
|
||||
json_str = content[start:end]
|
||||
|
||||
parsed = json.loads(json_str)
|
||||
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,
|
||||
)
|
||||
)
|
||||
|
||||
# Extract constraints and facts into task state
|
||||
if session.current_task:
|
||||
session.current_task.constraints.extend(
|
||||
parsed.get("constraints", [])
|
||||
)
|
||||
session.current_task.facts_extracted.extend(
|
||||
parsed.get("facts", [])
|
||||
)
|
||||
|
||||
return steps
|
||||
|
||||
except (json.JSONDecodeError, KeyError) as e:
|
||||
logger.warning("Failed to parse planner output: %s", e)
|
||||
# Fallback: single step with the full objective
|
||||
return [
|
||||
TaskStep(
|
||||
description=session.current_task.objective
|
||||
if session.current_task
|
||||
else "Execute task",
|
||||
agent_role="coder",
|
||||
)
|
||||
]
|
||||
47
src/orchestrator/agents/reviewer.py
Normal file
47
src/orchestrator/agents/reviewer.py
Normal file
@@ -0,0 +1,47 @@
|
||||
"""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
|
||||
295
src/orchestrator/engine.py
Normal file
295
src/orchestrator/engine.py
Normal file
@@ -0,0 +1,295 @@
|
||||
"""Orchestrator Engine — the main execution loop.
|
||||
|
||||
Flow: message → plan → route → execute steps → summarize → update → stream
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from ..adapters.base import ModelAdapter
|
||||
from ..config import settings
|
||||
from ..context.engine import ContextEngine
|
||||
from ..mcp.client import MCPClient
|
||||
from ..memory.store import MemoryStore
|
||||
from ..models.agent import AgentRole
|
||||
from ..models.session import SessionState, SessionStatus, TaskStatus
|
||||
from ..streaming.sse import SSEEmitter, EventType
|
||||
from .agents.coder import CoderAgent, create_coder_profile
|
||||
from .agents.collector import CollectorAgent, create_collector_profile
|
||||
from .agents.planner import PlannerAgent, create_planner_profile
|
||||
from .agents.reviewer import ReviewerAgent, create_reviewer_profile
|
||||
from .router import route_step
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OrchestratorEngine:
|
||||
"""Drives the full execution lifecycle for a session message."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
model_adapter: ModelAdapter,
|
||||
context_engine: ContextEngine,
|
||||
mcp_client: MCPClient,
|
||||
memory_store: MemoryStore,
|
||||
sse_emitter: SSEEmitter,
|
||||
) -> None:
|
||||
self.model = model_adapter
|
||||
self.context = context_engine
|
||||
self.mcp = mcp_client
|
||||
self.memory = memory_store
|
||||
self.sse = sse_emitter
|
||||
|
||||
# Pre-built agent profiles
|
||||
self._profiles = {
|
||||
AgentRole.PLANNER: create_planner_profile(),
|
||||
AgentRole.CODER: create_coder_profile(),
|
||||
AgentRole.COLLECTOR: create_collector_profile(),
|
||||
AgentRole.REVIEWER: create_reviewer_profile(),
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def process_message(
|
||||
self,
|
||||
session: SessionState,
|
||||
message: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Process a user message through the full orchestration pipeline.
|
||||
|
||||
Pipeline: plan → execute steps → review → complete
|
||||
|
||||
Handles errors gracefully: failed steps are marked and skipped,
|
||||
the session always returns to idle/error — never stuck in executing.
|
||||
"""
|
||||
task = None
|
||||
try:
|
||||
return await asyncio.wait_for(
|
||||
self._run_pipeline(session, message),
|
||||
timeout=settings.max_execution_timeout_seconds,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
logger.error("Execution timed out for session %s", session.session_id)
|
||||
if session.current_task:
|
||||
session.current_task.mark_failed("Execution timed out")
|
||||
session.status = SessionStatus.ERROR
|
||||
await self.sse.emit(
|
||||
EventType.ERROR,
|
||||
{"error": "execution_timeout", "message": "Task exceeded maximum execution time"},
|
||||
session_id=session.session_id,
|
||||
)
|
||||
return self._error_result(session, "Execution timed out")
|
||||
except Exception as e:
|
||||
logger.exception("Unhandled error in pipeline for session %s", session.session_id)
|
||||
if session.current_task:
|
||||
session.current_task.mark_failed(str(e))
|
||||
session.status = SessionStatus.ERROR
|
||||
await self.sse.emit(
|
||||
EventType.ERROR,
|
||||
{"error": "pipeline_error", "message": str(e)},
|
||||
session_id=session.session_id,
|
||||
)
|
||||
return self._error_result(session, str(e))
|
||||
|
||||
async def _run_pipeline(
|
||||
self,
|
||||
session: SessionState,
|
||||
message: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Inner pipeline — wrapped by process_message for error handling."""
|
||||
|
||||
await self.sse.emit(
|
||||
EventType.EXECUTION_STARTED,
|
||||
{"session_id": session.session_id, "message": message[:200]},
|
||||
session_id=session.session_id,
|
||||
)
|
||||
|
||||
# 1. Create task from message
|
||||
task = session.begin_task(objective=message)
|
||||
|
||||
# 2. Plan
|
||||
task.status = TaskStatus.PLANNING
|
||||
try:
|
||||
planner = self._create_agent(AgentRole.PLANNER)
|
||||
plan_steps = await planner.plan(session)
|
||||
task.plan = plan_steps
|
||||
task.status = TaskStatus.EXECUTING
|
||||
except Exception as e:
|
||||
logger.error("Planning failed: %s", e)
|
||||
task.mark_failed(f"Planning failed: {e}")
|
||||
session.status = SessionStatus.ERROR
|
||||
await self.sse.emit(
|
||||
EventType.ERROR,
|
||||
{"error": "planning_failed", "message": str(e)},
|
||||
session_id=session.session_id,
|
||||
)
|
||||
return self._error_result(session, f"Planning failed: {e}")
|
||||
|
||||
logger.info(
|
||||
"Plan created with %d steps for task %s",
|
||||
len(plan_steps),
|
||||
task.task_id,
|
||||
)
|
||||
|
||||
# 3. Execute each step — failures are logged and skipped
|
||||
results: list[dict[str, Any]] = []
|
||||
failed_steps: list[int] = []
|
||||
|
||||
for i, step in enumerate(task.plan):
|
||||
if i >= settings.max_execution_steps:
|
||||
logger.warning("Max execution steps reached")
|
||||
break
|
||||
|
||||
task.current_step_index = i
|
||||
step.status = TaskStatus.EXECUTING
|
||||
step.started_at = datetime.now(timezone.utc)
|
||||
|
||||
role = route_step(step)
|
||||
agent = self._create_agent(role)
|
||||
|
||||
await self.sse.emit(
|
||||
EventType.SUBAGENT_ASSIGNED,
|
||||
{
|
||||
"step": i + 1,
|
||||
"total_steps": len(task.plan),
|
||||
"agent": role.value,
|
||||
"description": step.description,
|
||||
},
|
||||
session_id=session.session_id,
|
||||
)
|
||||
|
||||
try:
|
||||
step_result = await agent.execute(
|
||||
session=session,
|
||||
max_steps=settings.subagent_max_steps,
|
||||
)
|
||||
results.append(step_result)
|
||||
|
||||
step.status = TaskStatus.COMPLETED
|
||||
step.completed_at = datetime.now(timezone.utc)
|
||||
step.result_summary = (step_result.get("content", ""))[:500]
|
||||
step.tools_used = [
|
||||
te.tool_name for te in step_result.get("tool_executions", [])
|
||||
]
|
||||
|
||||
for artifact in step_result.get("artifacts", []):
|
||||
task.facts_extracted.extend(artifact.facts[:5])
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Step %d failed: %s", i + 1, e)
|
||||
step.status = TaskStatus.FAILED
|
||||
step.completed_at = datetime.now(timezone.utc)
|
||||
step.result_summary = f"Error: {e}"
|
||||
failed_steps.append(i + 1)
|
||||
|
||||
await self.sse.emit(
|
||||
EventType.ERROR,
|
||||
{"error": "step_failed", "step": i + 1, "message": str(e)},
|
||||
session_id=session.session_id,
|
||||
)
|
||||
# Continue with next step — don't block the pipeline
|
||||
|
||||
# 4. Review (if plan had more than 1 step and at least one succeeded)
|
||||
review_result: dict[str, Any] = {}
|
||||
if len(task.plan) > 1 and results:
|
||||
task.status = TaskStatus.REVIEWING
|
||||
try:
|
||||
reviewer = self._create_agent(AgentRole.REVIEWER)
|
||||
review_result = await reviewer.execute(
|
||||
session=session,
|
||||
max_steps=2,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Review failed: %s", e)
|
||||
review_result = {"content": f"Review skipped due to error: {e}"}
|
||||
|
||||
# 5. Complete — session ALWAYS returns to idle
|
||||
session.complete_task()
|
||||
|
||||
final_content = self._assemble_response(results, review_result)
|
||||
status = "completed" if not failed_steps else "partial"
|
||||
|
||||
await self.sse.emit(
|
||||
EventType.EXECUTION_COMPLETED,
|
||||
{
|
||||
"session_id": session.session_id,
|
||||
"task_id": task.task_id,
|
||||
"steps_completed": len(results),
|
||||
"steps_failed": failed_steps,
|
||||
"status": status,
|
||||
},
|
||||
session_id=session.session_id,
|
||||
)
|
||||
|
||||
return {
|
||||
"session_id": session.session_id,
|
||||
"task_id": task.task_id,
|
||||
"content": final_content,
|
||||
"steps_completed": len(results),
|
||||
"steps_failed": failed_steps,
|
||||
"artifacts_count": sum(
|
||||
len(r.get("artifacts", [])) for r in results
|
||||
),
|
||||
"review": review_result.get("content", ""),
|
||||
"status": status,
|
||||
}
|
||||
|
||||
def _error_result(self, session: SessionState, error: str) -> dict[str, Any]:
|
||||
"""Build a standardized error response."""
|
||||
task_id = session.current_task.task_id if session.current_task else "none"
|
||||
return {
|
||||
"session_id": session.session_id,
|
||||
"task_id": task_id,
|
||||
"content": f"Error: {error}",
|
||||
"steps_completed": 0,
|
||||
"steps_failed": [],
|
||||
"artifacts_count": 0,
|
||||
"review": "",
|
||||
"status": "error",
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internals
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _create_agent(self, role: AgentRole) -> PlannerAgent | CoderAgent | CollectorAgent | ReviewerAgent:
|
||||
"""Instantiate a subagent for the given role."""
|
||||
profile = self._profiles[role]
|
||||
agent_cls = {
|
||||
AgentRole.PLANNER: PlannerAgent,
|
||||
AgentRole.CODER: CoderAgent,
|
||||
AgentRole.COLLECTOR: CollectorAgent,
|
||||
AgentRole.REVIEWER: ReviewerAgent,
|
||||
}[role]
|
||||
|
||||
return agent_cls(
|
||||
profile=profile,
|
||||
model_adapter=self.model,
|
||||
context_engine=self.context,
|
||||
mcp_client=self.mcp,
|
||||
memory_store=self.memory,
|
||||
sse_emitter=self.sse,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _assemble_response(
|
||||
results: list[dict[str, Any]],
|
||||
review_result: dict[str, Any],
|
||||
) -> str:
|
||||
"""Combine step results into a coherent final response."""
|
||||
parts: list[str] = []
|
||||
for i, r in enumerate(results):
|
||||
content = r.get("content", "").strip()
|
||||
if content:
|
||||
parts.append(f"### Step {i + 1}\n{content}")
|
||||
|
||||
if review_result.get("content"):
|
||||
parts.append(f"### Review\n{review_result['content']}")
|
||||
|
||||
return "\n\n".join(parts) if parts else "Task completed."
|
||||
60
src/orchestrator/router.py
Normal file
60
src/orchestrator/router.py
Normal file
@@ -0,0 +1,60 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user