feat(api): implementa /executions con invoke + HITL approve/reject + persistencia

- POST /agents/{name}/invoke, GET /executions, GET /executions/{trace_id},
  POST /executions/{trace_id}/{approve,reject}.
- execution_index.json persiste trace_id → (agent_name, version) para reconstruir
  el contexto en approve/reject tras un reinicio.
- Refactor del orchestrator: split _snapshot → _build_execution + _snapshot, nuevo
  método público snapshot() (lee estado sin avanzar). Sustituye a los helpers
  _snapshot_execution/_build_graph_for_snapshot del plan, incompatibles con el
  checkpointer por-llamada.
- Aliases Annotated[...] de dependencias movidos a deps.py.
- Fixture mínima tests/fixtures/policies/default/.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Juan
2026-05-10 15:38:44 +02:00
co-authored by Claude Opus 4.7
parent 13ba1f7826
commit 8927b192f8
9 changed files with 420 additions and 39 deletions
+2 -7
View File
@@ -2,19 +2,14 @@
from __future__ import annotations from __future__ import annotations
from typing import Annotated from fastapi import APIRouter, HTTPException
from fastapi import APIRouter, Depends, HTTPException from agentforge_core.api.deps import RegistryDep
from agentforge_core.api.deps import get_registry
from agentforge_core.domain.agent import AgentDefinition, AgentVersionMeta from agentforge_core.domain.agent import AgentDefinition, AgentVersionMeta
from agentforge_core.registry.repository import FileSystemAgentRegistry
from agentforge_core.registry.versioning import DiffResult from agentforge_core.registry.versioning import DiffResult
router = APIRouter() router = APIRouter()
RegistryDep = Annotated[FileSystemAgentRegistry, Depends(get_registry)]
@router.get("", response_model=list[AgentDefinition]) @router.get("", response_model=list[AgentDefinition])
def list_agents(registry: RegistryDep) -> list[AgentDefinition]: def list_agents(registry: RegistryDep) -> list[AgentDefinition]:
+10
View File
@@ -8,6 +8,9 @@ tests llaman a `.cache_clear()` para reconstruirlos contra un `DATA_DIR` tempora
from __future__ import annotations from __future__ import annotations
from functools import lru_cache from functools import lru_cache
from typing import Annotated
from fastapi import Depends
from agentforge_core.config import Settings from agentforge_core.config import Settings
from agentforge_core.guardrails.base import GuardrailEngine from agentforge_core.guardrails.base import GuardrailEngine
@@ -53,3 +56,10 @@ def get_orchestrator() -> AgentOrchestrator:
engine=get_guardrail_engine(), engine=get_guardrail_engine(),
data_dir=settings.data_dir, data_dir=settings.data_dir,
) )
# Aliases `Annotated[...]` para inyección en los routers (evitan `Depends()` en defaults).
SettingsDep = Annotated[Settings, Depends(get_settings)]
RegistryDep = Annotated[FileSystemAgentRegistry, Depends(get_registry)]
PolicyStoreDep = Annotated[FileSystemPolicyStore, Depends(get_policy_store)]
OrchestratorDep = Annotated[AgentOrchestrator, Depends(get_orchestrator)]
+209 -2
View File
@@ -1,5 +1,212 @@
"""Router stub de /executions — se implementa en una task posterior.""" """Router /executions y POST /agents/{name}/invoke: ejecución, HITL approve/reject, listado."""
from fastapi import APIRouter from __future__ import annotations
import json
from pathlib import Path
from uuid import UUID
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
from agentforge_core.api.deps import (
OrchestratorDep,
PolicyStoreDep,
RegistryDep,
SettingsDep,
)
from agentforge_core.api.persistence import (
append_execution,
append_violation,
read_execution_summaries,
)
from agentforge_core.domain.agent import AgentDefinition
from agentforge_core.domain.execution import AgentExecution, AgentExecutionSummary
from agentforge_core.domain.policy import PolicyDefinition
from agentforge_core.registry.policy_store import FileSystemPolicyStore
from agentforge_core.registry.repository import FileSystemAgentRegistry
from agentforge_core.runtime.orchestrator import AgentOrchestrator
router = APIRouter() router = APIRouter()
# POST /agents/{name}/invoke vive aquí pero se monta en main bajo el prefijo /agents.
invoke_router = APIRouter()
_TERMINAL_STATUSES = {"completed", "failed", "blocked_by_guardrail"}
class InvokeRequest(BaseModel):
input: str
version: str | None = None
class ApproveRequest(BaseModel):
approved_action_ids: list[str] = Field(default_factory=list)
comment: str | None = None
class RejectRequest(BaseModel):
reason: str
# --- Índice trace_id → (agent_name, version), persistido en disco ----------------------
# /approve y /reject solo reciben el trace_id; necesitan reconstruir qué agente/versión
# ejecutó. Se persiste para sobrevivir a reinicios del proceso con HITL pendiente.
def _index_path(data_dir: Path) -> Path:
return data_dir / "execution_index.json"
def _load_index(data_dir: Path) -> dict[str, dict[str, str]]:
path = _index_path(data_dir)
if not path.exists():
return {}
data: dict[str, dict[str, str]] = json.loads(path.read_text(encoding="utf-8"))
return data
def _record_execution(data_dir: Path, trace_id: str, agent_name: str, version: str) -> None:
data_dir.mkdir(parents=True, exist_ok=True)
index = _load_index(data_dir)
index[trace_id] = {"agent_name": agent_name, "version": version}
_index_path(data_dir).write_text(
json.dumps(index, ensure_ascii=False, indent=2), encoding="utf-8"
)
def _resolve(
registry: FileSystemAgentRegistry,
policies: FileSystemPolicyStore,
data_dir: Path,
trace_id: UUID,
) -> tuple[AgentDefinition, PolicyDefinition]:
"""Recupera (agent_def, policy) de una ejecución registrada; 404 si no existe."""
meta = _load_index(data_dir).get(str(trace_id))
if meta is None:
raise HTTPException(status_code=404, detail="execution not found")
try:
agent_def = registry.get_version(meta["agent_name"], meta["version"])
policy = policies.get_policy(agent_def.guardrails[0])
except (FileNotFoundError, IndexError, KeyError) as exc:
raise HTTPException(
status_code=500, detail=f"execution refers to missing config: {exc}"
) from exc
return agent_def, policy
async def _ensure_awaiting(
orchestrator: AgentOrchestrator,
agent_def: AgentDefinition,
policy: PolicyDefinition,
trace_id: UUID,
) -> None:
snap = await orchestrator.snapshot(agent_def=agent_def, policy=policy, trace_id=trace_id)
if snap is None:
raise HTTPException(status_code=404, detail="execution not found")
if snap.status != "awaiting_approval":
raise HTTPException(
status_code=409,
detail=f"execution status '{snap.status}' does not allow approve/reject",
)
# --- Endpoints -------------------------------------------------------------------------
@invoke_router.post("/{name}/invoke", response_model=AgentExecution)
async def invoke_agent(
name: str,
body: InvokeRequest,
registry: RegistryDep,
policies: PolicyStoreDep,
orchestrator: OrchestratorDep,
settings: SettingsDep,
) -> AgentExecution:
try:
agent_def = registry.get_agent(name, body.version)
except FileNotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
if not agent_def.guardrails:
raise HTTPException(status_code=422, detail="agent has no policy attached")
try:
policy = policies.get_policy(agent_def.guardrails[0])
except FileNotFoundError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
execution = await orchestrator.invoke(
agent_def=agent_def, policy=policy, user_input=body.input
)
_record_execution(
settings.data_dir, str(execution.trace_id), agent_def.name, agent_def.version
)
if execution.status in _TERMINAL_STATUSES:
append_execution(settings.data_dir, execution)
for violation in execution.violations:
append_violation(settings.data_dir, violation)
return execution
@router.get("", response_model=list[AgentExecutionSummary])
def list_executions(settings: SettingsDep) -> list[AgentExecutionSummary]:
return read_execution_summaries(settings.data_dir)
@router.get("/{trace_id}", response_model=AgentExecution)
async def get_execution(
trace_id: UUID,
registry: RegistryDep,
policies: PolicyStoreDep,
orchestrator: OrchestratorDep,
settings: SettingsDep,
) -> AgentExecution:
agent_def, policy = _resolve(registry, policies, settings.data_dir, trace_id)
execution = await orchestrator.snapshot(agent_def=agent_def, policy=policy, trace_id=trace_id)
if execution is None:
raise HTTPException(status_code=404, detail="execution not found")
return execution
@router.post("/{trace_id}/approve", response_model=AgentExecution)
async def approve_execution(
trace_id: UUID,
body: ApproveRequest,
registry: RegistryDep,
policies: PolicyStoreDep,
orchestrator: OrchestratorDep,
settings: SettingsDep,
) -> AgentExecution:
agent_def, policy = _resolve(registry, policies, settings.data_dir, trace_id)
await _ensure_awaiting(orchestrator, agent_def, policy, trace_id)
execution = await orchestrator.resume(
agent_def=agent_def,
policy=policy,
trace_id=trace_id,
decision={
"approved_action_ids": body.approved_action_ids,
"comment": body.comment,
"rejected": False,
},
)
append_execution(settings.data_dir, execution)
return execution
@router.post("/{trace_id}/reject", response_model=AgentExecution)
async def reject_execution(
trace_id: UUID,
body: RejectRequest,
registry: RegistryDep,
policies: PolicyStoreDep,
orchestrator: OrchestratorDep,
settings: SettingsDep,
) -> AgentExecution:
agent_def, policy = _resolve(registry, policies, settings.data_dir, trace_id)
await _ensure_awaiting(orchestrator, agent_def, policy, trace_id)
execution = await orchestrator.resume(
agent_def=agent_def,
policy=policy,
trace_id=trace_id,
decision={"approved_action_ids": [], "rejected": True, "reason": body.reason},
)
append_execution(settings.data_dir, execution)
return execution
+1
View File
@@ -27,6 +27,7 @@ def create_app() -> FastAPI:
from agentforge_core.api import agents, executions, policies, violations from agentforge_core.api import agents, executions, policies, violations
app.include_router(agents.router, prefix="/agents", tags=["agents"]) app.include_router(agents.router, prefix="/agents", tags=["agents"])
app.include_router(executions.invoke_router, prefix="/agents", tags=["agents"])
app.include_router(executions.router, prefix="/executions", tags=["executions"]) app.include_router(executions.router, prefix="/executions", tags=["executions"])
app.include_router(policies.router, prefix="/policies", tags=["policies"]) app.include_router(policies.router, prefix="/policies", tags=["policies"])
app.include_router(violations.router, prefix="/violations", tags=["violations"]) app.include_router(violations.router, prefix="/violations", tags=["violations"])
@@ -1,10 +1,11 @@
"""Orchestrator: envuelve build_graph + ainvoke/resume + serialización a ``AgentExecution``. """Orchestrator: envuelve build_graph + ainvoke/resume + serialización a ``AgentExecution``.
Es el único punto de entrada al runtime: el router de FastAPI lo usa para lanzar Es el único punto de entrada al runtime: el router de FastAPI lo usa para lanzar
ejecuciones y reanudar pausas Human-in-the-Loop. Cada llamada abre su propio ejecuciones (``invoke``), reanudar pausas Human-in-the-Loop (``resume``) y leer el
estado actual sin avanzarlo (``snapshot``). Cada llamada abre su propio
``AsyncSqliteSaver`` (context manager) sobre ``data_dir/checkpoints.sqlite``, así que ``AsyncSqliteSaver`` (context manager) sobre ``data_dir/checkpoints.sqlite``, así que
el estado de un ``awaiting_approval`` sobrevive a un reinicio del proceso: basta crear un ``awaiting_approval`` sobrevive a un reinicio del proceso: basta crear otro
otro ``AgentOrchestrator`` apuntando al mismo ``data_dir`` y llamar a ``resume``. ``AgentOrchestrator`` apuntando al mismo ``data_dir`` y llamar a ``resume``.
""" """
from __future__ import annotations from __future__ import annotations
@@ -36,7 +37,7 @@ def _thread_config(trace_id: UUID) -> dict[str, Any]:
class AgentOrchestrator: class AgentOrchestrator:
"""Punto único de entrada para invocar agentes y reanudar pausas HITL.""" """Punto único de entrada para invocar agentes, reanudar HITL y leer su estado."""
def __init__( def __init__(
self, *, provider: LLMProvider, engine: GuardrailEngine, data_dir: Path self, *, provider: LLMProvider, engine: GuardrailEngine, data_dir: Path
@@ -45,6 +46,17 @@ class AgentOrchestrator:
self._engine = engine self._engine = engine
self._data_dir = data_dir self._data_dir = data_dir
def _build_graph(
self, agent_def: AgentDefinition, policy: PolicyDefinition, checkpointer: Any
) -> Any:
return build_graph(
agent_def=agent_def,
policy=policy,
provider=self._provider,
engine=self._engine,
checkpointer=checkpointer,
)
async def invoke( async def invoke(
self, self,
*, *,
@@ -56,7 +68,6 @@ class AgentOrchestrator:
"""Lanza una ejecución. Si hay acciones que requieren aprobación, devuelve """Lanza una ejecución. Si hay acciones que requieren aprobación, devuelve
``status="awaiting_approval"`` y el grafo queda pausado en el checkpointer.""" ``status="awaiting_approval"`` y el grafo queda pausado en el checkpointer."""
tid = trace_id or uuid4() tid = trace_id or uuid4()
started_at = datetime.now(UTC)
initial: dict[str, Any] = { initial: dict[str, Any] = {
"trace_id": str(tid), "trace_id": str(tid),
"agent_name": agent_def.name, "agent_name": agent_def.name,
@@ -74,20 +85,14 @@ class AgentOrchestrator:
"final_output": None, "final_output": None,
} }
async with build_checkpointer(self._data_dir) as checkpointer: async with build_checkpointer(self._data_dir) as checkpointer:
graph = build_graph( graph = self._build_graph(agent_def, policy, checkpointer)
agent_def=agent_def,
policy=policy,
provider=self._provider,
engine=self._engine,
checkpointer=checkpointer,
)
crashed = False crashed = False
try: try:
await graph.ainvoke(initial, config=_thread_config(tid)) await graph.ainvoke(initial, config=_thread_config(tid))
except Exception: except Exception:
log.exception("agent_invoke_failed", trace_id=str(tid)) log.exception("agent_invoke_failed", trace_id=str(tid))
crashed = True crashed = True
return await self._snapshot(graph, agent_def, tid, started_at, crashed=crashed) return await self._snapshot(graph, agent_def, tid, crashed=crashed)
async def resume( async def resume(
self, self,
@@ -98,34 +103,40 @@ class AgentOrchestrator:
decision: dict[str, Any], decision: dict[str, Any],
) -> AgentExecution: ) -> AgentExecution:
"""Reanuda una ejecución pausada en HITL con la decisión del operador.""" """Reanuda una ejecución pausada en HITL con la decisión del operador."""
started_at = datetime.now(UTC)
async with build_checkpointer(self._data_dir) as checkpointer: async with build_checkpointer(self._data_dir) as checkpointer:
graph = build_graph( graph = self._build_graph(agent_def, policy, checkpointer)
agent_def=agent_def,
policy=policy,
provider=self._provider,
engine=self._engine,
checkpointer=checkpointer,
)
crashed = False crashed = False
try: try:
await graph.ainvoke(Command(resume=decision), config=_thread_config(trace_id)) await graph.ainvoke(Command(resume=decision), config=_thread_config(trace_id))
except Exception: except Exception:
log.exception("agent_resume_failed", trace_id=str(trace_id)) log.exception("agent_resume_failed", trace_id=str(trace_id))
crashed = True crashed = True
return await self._snapshot(graph, agent_def, trace_id, started_at, crashed=crashed) return await self._snapshot(graph, agent_def, trace_id, crashed=crashed)
async def snapshot(
self, *, agent_def: AgentDefinition, policy: PolicyDefinition, trace_id: UUID
) -> AgentExecution | None:
"""Lee el estado actual de una ejecución del checkpointer, sin avanzarla.
Devuelve ``None`` si no hay estado guardado para ese ``trace_id``.
"""
async with build_checkpointer(self._data_dir) as checkpointer:
graph = self._build_graph(agent_def, policy, checkpointer)
state = await graph.aget_state(_thread_config(trace_id))
if not state.values:
return None
return self._build_execution(state, agent_def, trace_id, crashed=False)
async def _snapshot( async def _snapshot(
self, self, graph: Any, agent_def: AgentDefinition, trace_id: UUID, *, crashed: bool
graph: Any,
agent_def: AgentDefinition,
trace_id: UUID,
started_at: datetime,
*,
crashed: bool,
) -> AgentExecution: ) -> AgentExecution:
"""Lee el estado del checkpointer y lo serializa a ``AgentExecution``."""
state = await graph.aget_state(_thread_config(trace_id)) state = await graph.aget_state(_thread_config(trace_id))
return self._build_execution(state, agent_def, trace_id, crashed=crashed)
def _build_execution(
self, state: Any, agent_def: AgentDefinition, trace_id: UUID, *, crashed: bool
) -> AgentExecution:
"""Mapea un ``StateSnapshot`` de LangGraph a un ``AgentExecution``."""
values: dict[str, Any] = state.values or {} values: dict[str, Any] = state.values or {}
status: str = values.get("status", "running") status: str = values.get("status", "running")
if crashed and status not in _TERMINAL_STATUSES: if crashed and status not in _TERMINAL_STATUSES:
@@ -147,6 +158,7 @@ class AgentOrchestrator:
else None else None
) )
error = values.get("error") or ("internal_error" if crashed else None) error = values.get("error") or ("internal_error" if crashed else None)
started_at = decision_path[0].timestamp if decision_path else datetime.now(UTC)
finished_at = datetime.now(UTC) if status in _TERMINAL_STATUSES else None finished_at = datetime.now(UTC) if status in _TERMINAL_STATUSES else None
return AgentExecution( return AgentExecution(
trace_id=trace_id, trace_id=trace_id,
+8
View File
@@ -0,0 +1,8 @@
name: default
versions:
- id: v1
hash: testhashp1
author: Juan
message: fixture mínima
created_at: 2026-04-01T00:00:00Z
active_version: v1
+6
View File
@@ -0,0 +1,6 @@
name: default
version: v1
description: Política mínima para tests del core (sin validadores)
input_validators: []
output_validators: []
on_validator_error: fail_closed
+124
View File
@@ -0,0 +1,124 @@
"""Tests del router /executions: invoke, get, list, approve, reject."""
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from agentforge_core.api import deps
from agentforge_core.main import create_app
FIXTURES = Path(__file__).parent.parent / "fixtures"
@pytest.fixture(autouse=True)
def patch_settings(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("DATA_DIR", str(tmp_path))
monkeypatch.setenv("AGENTS_DIR", str(FIXTURES / "agents"))
monkeypatch.setenv("POLICIES_DIR", str(FIXTURES / "policies"))
monkeypatch.setenv("LLM_PROVIDER", "mock")
for fn in (
deps.get_settings,
deps.get_registry,
deps.get_policy_store,
deps.get_orchestrator,
deps.get_llm_provider,
deps.get_guardrail_engine,
):
fn.cache_clear()
@pytest.fixture
def client() -> TestClient:
return TestClient(create_app())
def test_invoke_devuelve_execution_completada(client: TestClient) -> None:
r = client.post("/agents/incident_analyzer/invoke", json={"input": "degradación MOS pool SBC"})
assert r.status_code == 200
body = r.json()
assert body["status"] == "completed"
assert body["agent_name"] == "incident_analyzer"
assert body["agent_version"] == "v1"
assert body["needs_human_for"] is None
def test_invoke_agente_inexistente_404(client: TestClient) -> None:
r = client.post("/agents/inexistente/invoke", json={"input": "x"})
assert r.status_code == 404
def test_invoke_riesgo_alto_pausa_hitl(client: TestClient) -> None:
r = client.post("/agents/incident_analyzer/invoke", json={"input": "caída registros sip"})
assert r.status_code == 200
body = r.json()
assert body["status"] == "awaiting_approval"
assert body["needs_human_for"]
assert {a["id"] for a in body["needs_human_for"]} == {"act-1"}
def test_get_execution_existe_tras_invoke(client: TestClient) -> None:
trace_id = client.post(
"/agents/incident_analyzer/invoke", json={"input": "degradación MOS pool SBC"}
).json()["trace_id"]
r = client.get(f"/executions/{trace_id}")
assert r.status_code == 200
assert r.json()["trace_id"] == trace_id
def test_get_execution_inexistente_404(client: TestClient) -> None:
r = client.get("/executions/00000000-0000-0000-0000-000000000000")
assert r.status_code == 404
def test_list_executions_incluye_la_completada(client: TestClient) -> None:
client.post("/agents/incident_analyzer/invoke", json={"input": "degradación MOS pool SBC"})
r = client.get("/executions")
assert r.status_code == 200
rows = r.json()
assert len(rows) == 1
assert rows[0]["status"] == "completed"
assert rows[0]["n_proposed_actions"] == 1
def test_approve_resume_completa_ejecucion(client: TestClient) -> None:
invoked = client.post(
"/agents/incident_analyzer/invoke", json={"input": "caída registros sip"}
).json()
trace_id = invoked["trace_id"]
action_ids = [a["id"] for a in invoked["needs_human_for"]]
r = client.post(
f"/executions/{trace_id}/approve",
json={"approved_action_ids": action_ids, "comment": "OK adelante"},
)
assert r.status_code == 200
body = r.json()
assert body["status"] == "completed"
assert {a["id"] for a in body["final_output"]["approved_actions"]} == {"act-1"}
def test_reject_marca_failed(client: TestClient) -> None:
trace_id = client.post(
"/agents/incident_analyzer/invoke", json={"input": "caída registros sip"}
).json()["trace_id"]
r = client.post(f"/executions/{trace_id}/reject", json={"reason": "no procede ahora"})
assert r.status_code == 200
body = r.json()
assert body["status"] == "failed"
assert "rejected_by_human" in body["error"]
def test_approve_sobre_estado_no_pausado_da_409(client: TestClient) -> None:
trace_id = client.post(
"/agents/incident_analyzer/invoke", json={"input": "degradación MOS pool SBC"}
).json()["trace_id"]
r = client.post(f"/executions/{trace_id}/approve", json={"approved_action_ids": []})
assert r.status_code == 409
def test_approve_trace_id_desconocido_da_404(client: TestClient) -> None:
r = client.post(
"/executions/11111111-1111-1111-1111-111111111111/approve",
json={"approved_action_ids": []},
)
assert r.status_code == 404
+18
View File
@@ -2,6 +2,7 @@
from datetime import UTC, datetime from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
from uuid import uuid4
import pytest import pytest
@@ -64,6 +65,23 @@ async def test_invoke_camino_feliz(orchestrator: AgentOrchestrator) -> None:
] ]
async def test_snapshot_relee_estado_sin_avanzar(orchestrator: AgentOrchestrator) -> None:
agent = _agent()
paused = await orchestrator.invoke(
agent_def=agent, policy=_policy(), user_input="caída registros sip"
)
again = await orchestrator.snapshot(agent_def=agent, policy=_policy(), trace_id=paused.trace_id)
assert again is not None
assert again.status == "awaiting_approval"
assert again.trace_id == paused.trace_id
# Idempotente: leer no añade pasos al decision_path.
assert [s.step for s in again.decision_path] == [s.step for s in paused.decision_path]
async def test_snapshot_trace_id_desconocido_devuelve_none(orchestrator: AgentOrchestrator) -> None:
assert await orchestrator.snapshot(agent_def=_agent(), policy=_policy(), trace_id=uuid4()) is None
async def test_invoke_pausa_hitl_y_resume_aprueba(orchestrator: AgentOrchestrator) -> None: async def test_invoke_pausa_hitl_y_resume_aprueba(orchestrator: AgentOrchestrator) -> None:
agent = _agent() agent = _agent()
paused = await orchestrator.invoke( paused = await orchestrator.invoke(