Initial commit

This commit is contained in:
Jordan
2026-04-01 23:16:45 +01:00
commit 91cfdaee72
200 changed files with 25589 additions and 0 deletions

View File

@@ -0,0 +1,3 @@
from .engine import OrchestratorEngine
__all__ = ["OrchestratorEngine"]

Binary file not shown.

Binary file not shown.

Binary file not shown.

View 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"]

View 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)

View 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

View 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

View 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",
)
]

View 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
View 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."

View 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