🛡️ Forja · Walkthrough
Plataforma de gobernanza de agentes IA · documento generado para entender el proyecto completo

🛡️ Forja — Walkthrough

Catalogación y versionado de agentes y políticas, guardrails en runtime, ejecución stateful con Human-in-the-Loop, observabilidad y trazabilidad de extremo a extremo. Aquí está todo: de la vista de pájaro al cableado de cada módulo, con diagramas.

🐍 Python 3.11+ FastAPI · core :8000 📊 Streamlit · dashboard :8501 🔀 LangGraph · runtime stateful 🛡️ Guardrails-AI + Presidio 📦 Pydantic v2 · dominio 🗃️ YAML · JSON · JSONL · SQLite 🐳 docker-compose

00 Qué es y por qué

Poner agentes de IA en producción sin una capa de gobierno produce sistemas opacos: prompts que cambian sin historial, validaciones inconsistentes, acciones de alto impacto sin supervisión y ninguna auditoría de lo que decidió el agente.

Forja es el "plano de control" que pones delante de tus agentes antes de dejarlos tocar nada importante. No es un framework para construir agentes; es la capa que los cataloga, versiona, valida, ejecuta de forma supervisada y audita.

🧭 El caso de ejemplo del repo

Un agente de operaciones de telco — incident_analyzer: recibe la descripción de un incidente de plataforma de voz (caída de registros SIP, degradación de MOS, saturación de HSS…) y propone acciones con análisis de riesgo y plan de rollback. Las acciones de riesgo alto quedan pausadas esperando aprobación humana. Todo queda registrado con un trace_id.

Este documento va de lo general a lo concreto: primero las ideas y la forma del sistema, luego cada módulo y sus interrelaciones, y por último el cableado de bajo nivel (grafo, persistencia, API). Si solo quieres arrancarlo, ve al README.md; si quieres la narrativa completa, docs/explicacion.md; si quieres firmas exactas y cadenas de llamada, docs/componentes.md.

01 Las seis ideas grandes

Si entiendes estas seis ideas, entiendes el proyecto. Todo lo demás son detalles de implementación.

IDEA 1

Agentes y políticas como ficheros declarativos, versionados como Git

Un agente es un YAML (prompt, modelo, esquema de salida, umbral de aprobación). Cambias el YAML → nueva versión, con hash SHA-256 y diff legible. Sin redeploy.

agents/ · policies/ · registry/
IDEA 2

Los guardrails son una política, no código disperso

Una política lista validadores de entrada y de salida con su configuración. El motor los aplica; "qué se valida" es configuración, no código.

policies/ · guardrails/
IDEA 3

La ejecución del agente es un grafo de estados con checkpoints

No es "llama al LLM y ya": validar entrada → razonar → validar salida → proponer acciones → puerta de aprobación → finalizar. Cada paso se persiste.

runtime/ (LangGraph)
IDEA 4

Human-in-the-Loop de verdad

Si el agente propone algo arriesgado, el grafo se pausa a mitad de ejecución, el estado se guarda en disco, y se reanuda más tarde —incluso tras reiniciar el proceso— cuando un humano aprueba o rechaza.

nodo approve_gate · /approve · /reject
IDEA 5

Trazabilidad obligatoria

Cada petición lleva un trace_id (UUID) que se propaga por el middleware → los logs → la API → los ficheros de auditoría. Cada paso del grafo deja una entrada en el decision_path con su duración.

observability/ · domain/execution.py
IDEA 6

Todo lo "intercambiable" está detrás de una interfaz + un factory

El proveedor de LLM, el motor de guardrails, el registry… son Protocols con varias implementaciones. Un factory elige cuál según la configuración. Cambias .env, no el código.

llm/ · guardrails/ · registry/ (los factory.py)

02 Vista de pájaro: dos servicios

Forja son dos procesos que se hablan por HTTP/JSON, levantados por docker-compose:

  • forja-core (FastAPI, puerto 8000) — todo el dominio: registry de agentes, motor de guardrails, runtime de ejecución, persistencia. No tiene UI.
  • forja-dashboard (Streamlit, puerto 8501) — una consola visual con cinco páginas. No contiene lógica de negocio: es un cliente HTTP del core.

La separación importa: el core podría servir a una CLI, a otro servicio, a un pipeline… el dashboard es solo una de las caras posibles. (Y este documento es otra cara más: el HTML que estás leyendo.)

servicio / módulo componente de entrada componente interno clave añadido por este walkthrough → flujo de datos / dependencia
docker-compose · dos contenedores forja-dashboard Streamlit · :8501 · "la consola" 5 páginas · sin lógica de negocio CoreClient (httpx) → habla solo HTTP forja-core FastAPI · :8000 · "el cerebro" dominio · runtime · guardrails · persistencia /health · /agents · /executions · /policies · /violations HTTP / JSON el dashboard es cliente del core docs/walkthrough.html este documento — HTML autocontenido, sin servidor Dentro del core · todo enchufado una vez en api/deps.py capa LLM Strategy + factory mock · azure · openai ¿qué dice el LLM? capa Guardrails Strategy + factory Composite( GuardrailsAI [, NeMo] ) ¿pasa los filtros? runtime LangGraph grafo + checkpointer orchestrator: invoke · resume · snapshot la ejecución, paso a paso Persistencia — la herramienta adecuada para cada cosa YAML (definiciones) · JSON (índice trace_id→agente) · JSONL append-only (logs de auditoría) · SQLite (checkpoints HITL) agents/*.yaml · policies/*.yaml · data/execution_index.json · data/executions.jsonl · data/violations.jsonl · data/checkpoints.sqlite
Figura 1. Los dos servicios y, dentro del core, las tres capas intercambiables (LLM, Guardrails, Runtime) más la persistencia. Cada flecha es "depende de / fluye hacia". Este documento (verde) es un HTML autocontenido — se consulta como fichero, no requiere servidor.

03 Las capas del core, de fuera hacia dentro

Una petición HTTP atraviesa estas capas en orden. Cada una solo conoce a la de dentro; nada de dentro conoce a las de fuera (eso es lo que mantiene el sistema desacoplado y testeable).

① HTTP request
POST /agents/{name}/invoke · {input: "…"}
② TraceIdMiddleware
api/middlewares.py · genera/lee X-Trace-Id, lo bind-ea a structlog
③ Router
api/agents.py · api/executions.py · api/policies.py · api/violations.py
④ Dependencias (DI)
api/deps.py · @lru_cache: Registry, PolicyStore, LLMProvider, GuardrailEngine, Orchestrator (singletons)
⑤ AgentOrchestrator
runtime/orchestrator.py · única puerta al runtime: invoke / resume / snapshot
⑥ Grafo LangGraph
runtime/graph.py · cablea nodos + aristas condicionales, compila con checkpointer
⑦ Nodos
runtime/nodes.py · validate_input → llm_reason → validate_output → propose_actions → approve_gate → finalize
⑧ Adentro: llm/ · guardrails/ · domain/
¿qué dice el LLM? · ¿pasa los filtros? · ¿qué forma tienen los datos?
⑨ Persistencia
checkpointer (SQLite) + api/persistence.py (JSONL) + registry (YAML/JSON)
🔒 Regla de oro

Solo api/ importa de runtime.orchestrator (y deps.py lo construye). El resto de api/ no toca LangGraph; runtime/ no toca api/. Solo los factory.py y main.py conocen Settings. domain/ no importa nada del proyecto.

04 El grafo de módulos (quién depende de quién)

El código del core vive bajo core/src/forja_core/. Es un DAG: las capas de abajo no importan nada de las de arriba. El "nivel" es la profundidad topológica. Lee de abajo hacia arriba: el vocabulario primero, la composición de la app al final.

6app
main create_app(): instancia FastAPI, añade TraceIdMiddleware, expone /health, monta los 5 routers. Construye Settings() para el logging.
5HTTP
api.depsapi.agentsapi.executionsapi.policiesapi.violations deps.py construye y cachea los 5 objetos del dominio (@lru_cache); los routers solo los piden por parámetro.
4runtime
runtime.orchestrator Compone el grafo + el checkpointer; traduce el StateSnapshot de LangGraph a un AgentExecution. Único punto de entrada al runtime.
3
guardrails.factoryruntime.nodesruntime.graph Las funciones-nodo (parametrizadas con engine/policy/provider/agent) y el cableado del grafo; la factory que ensambla el CompositeGuardrailEngine.
2
llm.factoryregistry.factoryguardrails.guardrails_aiguardrails.compositeguardrails.nemo Las factories de LLM y registry; los engines concretos de guardrails. composite no conoce a sus sub-engines (solo el Protocol).
1
domain.executionllm.mockllm.azurellm.openairegistry.repositoryregistry.policy_storeguardrails.validatorsguardrails.baseapi.persistenceapi.middlewares Implementaciones e interfaces concretas que dependen solo del nivel 0.
0vocab
configobservability.loggingdomain.agentdomain.policydomain.guardrailregistry.versioningllm.baseruntime.stateruntime.checkpointer No importan nada del proyecto. domain/ es el idioma común; todo lo demás depende de él.

El paquete dashboard/ no aparece aquí: no comparte código con el core, solo lo llama por HTTP.

05 El patrón que se repite: Protocol + factory + Settings

Tres veces (LLM, guardrails, registry) verás la misma estructura. Permite cambiar el comportamiento sin tocar el código: pones LLM_PROVIDER=azure en .env y el factory te da el provider de Azure; pones mock y tienes un demo determinista sin claves. Es el principio de "configuración antes que código".

La forma genérica base.py un Protocol (la interfaz) impl_a impl_b impl_c factory.py build_X(settings) → X Settings (pydantic-settings ← .env) — el único que sabe de env vars elige y monta uno Las tres instancias reales LLM — llm/base.py: LLMProvider MockProvider · AzureOpenAIProvider · OpenAIProvider build_llm_provider(settings) → match settings.llm_provider · usado por el nodo llm_reason Guardrails — guardrails/base.py: GuardrailEngine GuardrailsAIEngine · NeMoGuardrailsEngine (opcional) · vía CompositeGuardrailEngine build_guardrail_engine(settings) → NeMo solo si GUARDRAILS_NEMO_ENABLED Registry — registry/repository.py · registry/policy_store.py FileSystemAgentRegistry · FileSystemPolicyStore (hoy ficheros; mañana ¿BD?) build_agent_registry(settings) · build_policy_store(settings)
Figura 2. Izquierda: la forma genérica (interfaz Protocol → N implementaciones → un factory que elige según Settings). Derecha: las tres instancias concretas. Todo se enchufa una sola vez al arrancar, en api/deps.py (los @lru_cache).

06 El dominio — el vocabulario del sistema

Modelos Pydantic puros bajo domain/. No dependen de nada del proyecto; todo lo demás depende de ellos. Si quieres entender el sistema rápido, empieza leyendo domain/execution.py: te dice exactamente qué información se produce y se guarda.

AgentDefinition · agent.py name · version · owner · purpose state: draft | active | deprecated guardrails: list[str] → nombres de política llm: LLMConfig system_prompt: str output_schema: dict (JSON Schema) risk_threshold_for_hitl: int (1..5) updated_at LLMConfig providermodel temperaturemax_tokens AgentVersionMeta id · hashauthor messagecreated_at index.yaml PolicyDefinition · policy.py name · version · description input_validators: list[PolicyValidator] output_validators: list[PolicyValidator] on_validator_error: fail_open | fail_closed (def: fail_closed) PolicyValidator type: strconfig: dict PolicyVersionMeta id · hash · authormessage · created_at por nombre AgentExecution · execution.py el "expediente" de una invocación · va a JSONL al terminar trace_id: UUID · agent_name · agent_version status: running | awaiting_approval | blocked_by_guardrail | completed | failed started_at · finished_at decision_path: list[DecisionStep] violations: list[GuardrailViolation] proposed_actions: list[ProposedAction] needs_human_for: list[ProposedAction] | None final_output: dict | None · error: str | None DecisionStep step · timestampduration_ms · detail ProposedAction id · action · targetrisk_score(1-5) · rollback_plan · requires_approval GuardrailViolation · guardrail.py trace_id: UUID · timestamp stage: input | output · validator: str severity: info | warning | block message: str · blocked: bool acumula AgentExecutionSummary — versión ligera para listados
Figura 3. Los modelos del dominio. AgentDefinition referencia políticas por nombre; el AgentRegistry resuelve la versión activa vía index.yaml. AgentExecution es el expediente que se acumula durante la ejecución y se persiste al terminar; sus violaciones se escriben además, una a una, en violations.jsonl.
FicheroQué define
domain/agent.pyAgentDefinition (prompt, modelo, output_schema, guardrails, risk_threshold_for_hitl…), LLMConfig, AgentVersionMeta.
domain/policy.pyPolicyDefinition (listas de PolicyValidator de entrada y salida, on_validator_error), PolicyVersionMeta.
domain/guardrail.pyGuardrailViolation (trace_id, stage input/output, validator, severity, message, blocked).
domain/execution.pyAgentExecution (status, decision_path, violations, proposed_actions, needs_human_for, final_output, error), ProposedAction, DecisionStep, AgentExecutionSummary.

07 Guardrails — el motor de validación

Aquí está el corazón del "gobierno". La estructura sigue otra vez Protocol + implementaciones + composite + factory. Una política lista qué validar; el motor lo aplica. Por defecto, fail-closed: si un validador peta, cuenta como bloqueo (seguridad antes que disponibilidad).

PolicyDefinition policies/default/v1.yaml input_validators[…] output_validators[…] on_validator_error: fail_closed CompositeGuardrailEngine corre N sub-engines en paralelo (asyncio.gather) y aplana las listas de violaciones GuardrailsAIEngine el real: aplica los validadores de la política integra Presidio para PII (con fallback a regex) NeMoGuardrailsEngine stub · solo si GUARDRAILS_NEMO_ENABLED=true INPUT_VALIDATORS (type → fn) detect_pii · prompt_injection toxic_language · forbidden_topics payload = str (texto del usuario) OUTPUT_VALIDATORS (type → fn) schema_match · pii_leakage forbidden_action_keywords telco_safety_rules payload = dict (JSON del LLM) qué validar busca cada type → list[GuardrailViolation] · cualquier blocked=True frena el grafo agrega fail_closed: si una fn validadora lanza → se añade una violación severity=block, blocked=True. type desconocido → log warning, no bloquea.
Figura 4. El motor de guardrails. La política dice qué validar; GuardrailsAIEngine recorre esa lista, busca cada type en su registro de funciones de validators.py, las llama con (payload, config, trace_id, kind), y junta las GuardrailViolation que devuelvan. CompositeGuardrailEngine corre los sub-engines en paralelo y agrega.

Los validadores concretos (guardrails/validators.py)

ValidadorEtapaQué compruebaSeveridad típica
detect_piiinputPII vía Presidio (modelo spaCy en_core_web_sm) o, si no está, regex de EMAIL / PHONE / ES_NIF / IP. La política default pide solo recognizers de patrón fiables (EMAIL_ADDRESS, ES_NIF, IP_ADDRESS, IBAN_CODE) para evitar falsos positivos del NER.block
prompt_injectioninputHeurísticas de patrones conocidos ("ignore previous instructions", "you are now", "reveal the system prompt"…).block
toxic_languageinputLista negra con umbral (MVP).warning
forbidden_topicsinputCoincidencia de substring con temas prohibidos (instrucciones de explotación, credenciales, código malicioso).block
schema_matchoutputValida el JSON del LLM contra un JSON Schema (severity, root_cause_hypothesis, proposed_actions con id/action/target/risk_score/rollback_plan/requires_approval).block
pii_leakageoutputRe-aplica detect_pii sobre la salida serializada.block
forbidden_action_keywordsoutputQue las acciones propuestas no contengan comandos peligrosos (DROP TABLE, rm -rf, shutdown -h now, delete production, format c:).block
telco_safety_rulesoutputReglas declarativas: nunca acción sobre prod sin rollback; nunca acción masiva (all/todos/*) sin canary en el plan.block

Así se conecta una política con un validador (extracto de policies/default/versions/v1.yaml):

policies/default/versions/v1.yamlyaml
name: default
version: v1
input_validators:
  - type: detect_pii
    config: { entities: [EMAIL_ADDRESS, ES_NIF, IP_ADDRESS, IBAN_CODE], severity_on_match: block }
  - type: prompt_injection
    config: { severity_on_match: block }
  - type: toxic_language
    config: { threshold: 0.7, severity_on_match: warning }
  - type: forbidden_topics
    config: { topics: ["instrucciones de explotación", "credenciales", "código malicioso"], severity_on_match: block }
output_validators:
  - type: schema_match          # valida el JSON del LLM contra un JSON Schema
    config: { severity_on_mismatch: block, schema: { … } }
  - type: pii_leakage
  - type: forbidden_action_keywords
    config: { keywords: ["DROP TABLE", "rm -rf", "shutdown -h now", "delete production", "format c:"] }
  - type: telco_safety_rules
    config: { rules: [never_propose_action_targeting_production_without_rollback, never_propose_mass_action_without_canary] }
on_validator_error: fail_closed   # si un validador lanza una excepción → cuenta como bloqueo

08 El grafo de ejecución (LangGraph)

Una ejecución del agente se modela como un grafo dirigido de estados con checkpoints. No es "llama al LLM y ya": es un flujo con aristas condicionales y una pausa real para Human-in-the-Loop. Cada nodo añade un DecisionStep con su duración al decision_path.

nodo del grafo pausa (estado persistido) salida temprana (status terminal) — camino normal · - - camino condicional
START validate_input engine.validate_input(...) llm_reason provider.complete([sys, user]) validate_output json.loads + engine.validate_output propose_actions extrae proposed_actions[] approve_gate ¿alguna acción con risk_score ≥ umbral o requires_approval = True? finalize filtra a las acciones aprobadas END ok ok ok no hay riesgo END bloqueada — PII, injection, topic… → status = blocked_by_guardrail END LLM no disponible / error → status = failed (llm_unavailable) END salida no parsea / no cumple esquema / PII en la salida → blocked_by_guardrail | failed interrupt({ awaiting_actions: [...] }) el grafo SE PAUSA aquí. LangGraph persiste el estado en data/checkpoints.sqlite status del snapshot = awaiting_approval hay riesgo → sí …más tarde (¡incluso tras reiniciar!)… resume(Command(resume=decision)) POST /executions/{trace_id}/approve | /reject interrupt() devuelve la decisión → approve_gate continúa END human_decision.rejected → status = failed (rejected_by_human) si no hubo HITL: todas las acciones
Figura 5. El grafo. Columna izquierda: el camino feliz START → validate_input → llm_reason → validate_output → propose_actions → approve_gate → finalize → END. Salidas tempranas (rojo) cuando un guardrail bloquea o algo falla. approve_gate bifurca: si no hay riesgo, va directo a finalize; si lo hay, llama a interrupt() y el grafo se detiene, con el estado en SQLite, hasta que llega un resume(decision) — que puede ocurrir tras un reinicio del proceso.

Los nodos (runtime/nodes.py) y el estado (runtime/state.py)

Nodo (factory)HacePuede saltar a
validate_input
build_node_validate_input(engine, policy)
Corre los input_validators de la política sobre el texto del usuario; acumula violaciones.END si alguna blockedstatus = blocked_by_guardrail
llm_reason
build_node_llm_reason(provider, agent_def)
provider.complete([Message("system", system_prompt), Message("user", input)], temperature, max_tokens). Guarda raw_llm_output y un step con modelo/tokens/latencia.END si el provider lanza → status = failed, error = llm_unavailable
validate_output
build_node_validate_output(engine, policy)
json.loads del output; corre los output_validators (schema_match, pii_leakage, …).END si no parsea (output_schema_mismatch) o hay blocked
propose_actions
build_node_propose_actions()
Extrae parsed_output["proposed_actions"] al estado.siempre → approve_gate
approve_gate
build_node_approve_gate(agent_def)
Filtra acciones arriesgadas (risk_score ≥ risk_threshold_for_hitl o requires_approval). Si las hay → interrupt({"awaiting_actions": risky}) (pausa). Al reanudar, recibe la decision humana.siempre → finalize (tras la pausa, si la hubo)
finalize
build_node_finalize()
Si human_decision.rejectedstatus = failed, error = rejected_by_human. Si no → final_output = {**parsed, "approved_actions": }, status = completed.END

El estado que fluye por el grafo es un TypedDict. decision_path usa un reducer (Annotated[list, operator.add]) para que cada nodo añada pasos en vez de sobrescribir:

core/src/forja_core/runtime/state.pypython
class AgentState(TypedDict, total=False):
    trace_id: str; agent_name: str; agent_version: str; user_input: str
    messages: list[dict]; raw_llm_output: str | None; parsed_output: dict | None
    proposed_actions: list[dict]; violations: list[dict]
    decision_path: Annotated[list[dict], operator.add]   # ← reducer: cada nodo AÑADE pasos
    status: str; error: str | None
    human_decision: dict | None; final_output: dict | None
Mirar dentro: el cableado del grafo y el nodo que pausa

El grafo se cablea con aristas fijas y condicionales (runtime/graph.py): tras validate_input, si status == "blocked_by_guardrail"END; tras llm_reason, si status == "failed"END; etc. propose_actions → approve_gate → finalize → END son aristas fijas.

runtime/graph.py — aristas condicionalespython
g.add_edge(START, "validate_input")

def _after_validate_input(state):
    return END if state.get("status") == "blocked_by_guardrail" else "llm_reason"
g.add_conditional_edges("validate_input", _after_validate_input, {END: END, "llm_reason": "llm_reason"})

# … _after_llm → END si status=="failed"  ·  _after_validate_output → END si status in {blocked, failed} …
g.add_edge("propose_actions", "approve_gate")
g.add_edge("approve_gate", "finalize")
g.add_edge("finalize", END)
return g.compile(checkpointer=checkpointer)
runtime/nodes.py — build_node_approve_gatepython
def build_node_approve_gate(agent_def):
    async def approve_gate(state):
        actions = state.get("proposed_actions", [])
        risky = [a for a in actions
                 if int(a.get("risk_score", 1)) >= agent_def.risk_threshold_for_hitl
                 or bool(a.get("requires_approval"))]
        if not risky:
            return {"decision_path": [_step("approve_gate", started, hitl=False)]}
        decision = interrupt({"awaiting_actions": risky})   # ← pausa; reanuda con Command(resume=decision)
        return {"human_decision": decision,
                "decision_path": [_step("approve_gate", started, hitl=True, resumed=True)]}
    return approve_gate

El AgentOrchestrator abre su propio AsyncSqliteSaver en cada operación (invoke / resume / snapshot) sobre data_dir/checkpoints.sqlite — por eso un awaiting_approval sobrevive a un reinicio: basta crear otro orchestrator apuntando al mismo data_dir y llamar a resume. El orchestrator traduce el StateSnapshot de LangGraph a un AgentExecution: si state.next (hay un nodo pendiente) y el status no es terminal ⇒ es el interrupt()status = "awaiting_approval" y needs_human_for = las acciones arriesgadas.

09 El viaje de UNA petición, de principio a fin

Esta es la sección que conviene leer despacio: aquí se ve cómo encaja todo. Seguimos POST /agents/incident_analyzer/invoke con el escenario SIP — el que dispara HITL — y luego la aprobación.

Clientedashboard / curl routerapi/executions.py · +middleware AgentOrchestratorruntime/orchestrator.py Grafo LangGraphnodes + checkpointer checkpointsdata/checkpoints.sqlite logs / índiceexecutions.jsonl · index.json ACTO 1 · invoke → awaiting_approval POST /agents/incident_analyzer/invoke { input } TraceIdMiddleware genera trace_id = UUID y lo bind-ea al log registry.get_agent → AgentDefinition (v2 activa) policies.get_policy(agent.guardrails[0]) → PolicyDefinition orchestrator.invoke(agent_def, policy, user_input) graph.ainvoke(estado_inicial, config={thread_id: trace_id}) validate_input → llm_reason → validate_output → propose_actions 0 violaciones · MockProvider ve "sip" → respuesta canónica approve_gate: act-1 (risk 4, requires_approval) → interrupt() ⇒ el grafo SE DETIENE aquí persiste el estado pausado aget_state → hay nodo pendiente AgentExecution(status=awaiting_approval, needs_human_for=[act-1]) execution_index.json[trace_id] = (incident_analyzer, v2) · (no es terminal → NO se escribe en executions.jsonl) 200 { status: awaiting_approval, trace_id, decision_path, … } · · · más tarde — incluso tras reiniciar forja-core: el estado pausado sigue en checkpoints.sqlite · · · ACTO 2 · el humano decide → completed POST /executions/{trace_id}/approve { approved_action_ids: [act-1], comment } _resolve: lee execution_index.json → reconstruye agent_def + policy _ensure_awaiting: snapshot → status == awaiting_approval ✓ (409 si no) orchestrator.resume(trace_id, decision={approved_action_ids:[act-1], rejected:false}) reabre el checkpointer (mismo data_dir) — el estado pausado sigue ahí graph.ainvoke(Command(resume=decision), config={thread_id: trace_id}) approve_gate (interrupt() devuelve la decisión) → finalize final_output={…, approved_actions:[act-1]} · status = completed AgentExecution(status=completed, final_output) append → executions.jsonl · (status terminal) 200 { status: completed, final_output, … }
Figura 6. Diagrama de secuencia. Acto 1: la petición entra, el middleware pone un trace_id, el router resuelve agente+política, el orchestrator lanza el grafo, éste recorre cuatro nodos y se pausa en approve_gate con un interrupt() — el estado va a SQLite, se anota el índice y se responde awaiting_approval (sin escribir aún en el log append-only). Acto 2 (puede ser tras un reinicio): llega el approve, el router reconstruye la config desde el índice, el orchestrator reabre el checkpointer y reanuda con Command(resume=...); el grafo termina, y como el status ya es terminal se escribe en executions.jsonl. Si en vez de approve llega rejectfinalize ve rejected: truestatus = failed, error = rejected_by_human.

El mismo recorrido, con curl (provider mock, sin claves):

demo end-to-end con curlbash
# 1) Invocar — el escenario SIP dispara HITL
TRACE=$(curl -s localhost:8000/agents/incident_analyzer/invoke \
  -H 'content-type: application/json' \
  -d '{"input": "Caída de registros SIP tras desplegar la imagen 4.7.2 en CSCF aravaca-01"}' \
  | python -c 'import sys,json; print(json.load(sys.stdin)["trace_id"])')
# → status: "awaiting_approval", needs_human_for: [act-1]

# 2) Revisar la cola HITL
curl -s localhost:8000/executions | python -m json.tool   # incluye las awaiting_approval reconstruidas del índice + checkpointer

# 3) Aprobar la acción segura act-1
curl -s localhost:8000/executions/$TRACE/approve \
  -H 'content-type: application/json' \
  -d '{"approved_action_ids": ["act-1"], "comment": "rollback ok"}'
# → status: "completed", final_output: { ..., "approved_actions": [act-1] }   ·   ya escrito en executions.jsonl

# (el dashboard hace exactamente esto desde las páginas "Ejecutar" y "Aprobaciones")

10 Versionado tipo Git

Un agente no es una fila en una BD: es una carpeta con un index.yaml (el "catálogo de commits") y versions/v1.yaml, versions/v2.yaml… (los "commits"). Cada versión tiene un hash SHA-256 del contenido normalizado, y dos versiones son comparables con un diff unificado (difflib). Lo mismo para las políticas. Cambias un YAML → nueva versión; promueves cambiando active_version. Sin redeploy.

El índice (agents/incident_analyzer/index.yaml)

index.yamlyaml
name: incident_analyzer
versions:
  - id: v1
    hash: pending
    author: Juan
    message: Versión inicial; SIP/IMS y MOS básicos.
    created_at: 2026-04-12T10:00:00Z
  - id: v2
    hash: pending
    author: Juan
    message: Añade codec mismatch y refuerza prompts.
    created_at: 2026-05-01T12:00:00Z
active_version: v2     # ← invoke usa esta si no pides otra

El "commit" activo (versions/v2.yaml)

versions/v2.yamlyaml
name: incident_analyzer
version: v2
owner: Juan
state: active                # draft | active | deprecated
guardrails: [default]         # nombres de política
llm:
  provider: mock
  model: gpt-4o
  temperature: 0.1
  max_tokens: 2000
system_prompt: |
  Eres un analista senior de operaciones de plataforma de voz
  virtualizada (IMS, CSCF, SBC, HSS). Devuelves SIEMPRE un JSON
  con severity, root_cause_hypothesis y proposed_actions. …
output_schema:
  type: object
  required: [severity, root_cause_hypothesis, proposed_actions]
  …
risk_threshold_for_hitl: 4    # risk_score ≥ 4 (o requires_approval) → HITL
🔎 La "magia tipo Git"

registry/versioning.py tiene compute_hash(yaml_text) (SHA-256 del YAML con espacios al final recortados) y unified_diff(a, b, label_a, label_b). El endpoint GET /agents/{name}/versions/{v_from}/diff/{v_to} devuelve un DiffResult con el unified_diff; el dashboard lo pinta coloreado (+ verde, - rojo, @@ azul) en la página Registro. (En los index.yaml de ejemplo el hash es "pending" y nadie lo valida al cargar — es un MVP.)

11 Persistencia: cuatro formas, cuatro razones

Forja no usa una sola base de datos; usa la herramienta adecuada para cada cosa.

forja-core registry · orchestrator api/persistence · runtime/checkpointer YAML — agents/*.yaml · policies/*.yaml lo escribe un humano (legible, comentable) lo cataloga la máquina (index.yaml + versiones) lee · diff · hash SQLite — data/checkpoints.sqlite estado intermedio del grafo, incluida la pausa HITL vía LangGraph AsyncSqliteSaver · permite reanudar escribe/lee en cada transición JSON — data/execution_index.json { trace_id: { agent_name, version } } para reconstruir la config al reanudar un HITL _record_execution en cada invoke · _resolve lo lee JSONL append-only — data/executions.jsonl un AgentExecution por línea · inmutable, auditable se escribe si el status es terminal (y siempre tras approve/reject) append_execution JSONL append-only — data/violations.jsonl una GuardrailViolation por línea se escribe una por violación de la ejecución append_violation GET /executions lee executions.jsonl + reconstruye las awaiting_approval del índice + checkpointer. GET /violations lee violations.jsonl.
Figura 7. Quién escribe y quién lee cada artefacto. Las flechas con doble cabeza indican lectura+escritura. El núcleo: definiciones en YAML (humano), índice en JSON (para reanudar), logs en JSONL (auditoría inmutable), estado intermedio en SQLite (lo que hace posible el HITL persistente).
QuéCómoPor qué así
Definiciones de agentes y políticasYAML por versión + index.yamlLo escribe un humano (legible, comentable); lo cataloga la máquina.
Identidad de versiones / comparaciónhash SHA-256 del contenido + difflibVersiones inmutables identificables y comparables — "tipo Git".
Mapa trace_id → agente/versiónJSON (execution_index.json)/approve y /reject solo reciben el trace_id: hay que reconstruir qué config lo ejecutó, y debe sobrevivir a reinicios.
Log de ejecuciones terminales y de violacionesJSONL append-onlyInmutable, auditable, trivial de "shipear" a un sistema de logs.
Estado intermedio del grafo (incl. pausas HITL)SQLite (checkpoints.sqlite, vía LangGraph)Es lo que LangGraph espera; permite reanudar una ejecución pausada entre reinicios del proceso.

12 Trazabilidad: el hilo del trace_id

Cada petición lleva un trace_id (UUID) que recorre todo el sistema. structlog emite JSON, una línea por evento, con ese trace_id en el contexto. Y cada nodo del grafo deja un DecisionStep en el decision_path con su duration_ms — la "caja negra" de la ejecución.

Request entra
¿trae X-Trace-Id? si no, uuid4()
TraceIdMiddleware
bind_trace_id(...) en structlog contextvars
Routers · Orchestrator
todos los logs llevan trace_id; thread_id del grafo = trace_id
Nodos del grafo
cada uno añade un DecisionStep {step, timestamp, duration_ms, detail}
Guardrails
cada GuardrailViolation lleva el trace_id
Persistencia
executions.jsonl · violations.jsonl · execution_index.json
Response sale
cabecera X-Trace-Id (= el mismo)

Detalles del logging: observability/logging.py configura structlog con JSONRenderer, TimeStamper(iso), niveles, y merge_contextvars. bind_trace_id / clear_trace_id manejan el contexto por request.

13 Estados de una ejecución

El status de un AgentExecution es uno de cinco. Tres son terminales (completed, failed, blocked_by_guardrail); awaiting_approval es el único que persiste indefinidamente esperando a un humano.

running recorriendo el grafo blocked_by_guardrail un validador con blocked=True validate_input / validate_output failed llm_unavailable · output_schema_mismatch · internal_error · rejected_by_human error del LLM / parseo / crash awaiting_approval pausado en interrupt() · estado en SQLite approve_gate: hay riesgo sobrevive a reinicios completed final_output con approved_actions /reject → rejected_by_human /approve sin acciones arriesgadas → finalize directo
Figura 8. Estados de un AgentExecution. running → puede acabar directamente en completed (si no hay acciones arriesgadas), o en blocked_by_guardrail / failed, o quedarse en awaiting_approval hasta que un humano hace /approve (→ completed) o /reject (→ failed). El estado awaiting_approval sobrevive a un reinicio del proceso. (Estados de un agente, en su YAML: draft → active → deprecated — concepto distinto.)

14 Referencia de la API REST

El core expone una API limpia. Todos los endpoints pasan por TraceIdMiddleware (lee/crea X-Trace-Id, lo devuelve en la respuesta). Los response_model son los modelos del dominio. Es exactamente lo que llama el dashboard (vía CoreClient).

Método · rutaQué haceErrores
GET /health{"status": "ok"} — usado por los healthchecks de Docker.
GET /agentsLista de AgentDefinition (la versión activa de cada agente).
GET /agents/{name}El agente (versión activa).404 no existe
GET /agents/{name}/versionsLista de AgentVersionMeta (id, hash, author, message, created_at).404
GET /agents/{name}/versions/{version}El AgentDefinition de esa versión concreta.404
GET /agents/{name}/versions/{v_from}/diff/{v_to}DiffResult con el unified_diff entre dos versiones.
POST /agents/{name}/invokeLanza una ejecución. Body {input: str, version?: str}. Resuelve agente+política, llama a orchestrator.invoke, anota el índice; si el status es terminal escribe en executions.jsonl; escribe las violaciones en violations.jsonl. Devuelve un AgentExecution.404 agente · 422 sin política
GET /executionsLista de AgentExecutionSummary: las terminales del JSONL más las awaiting_approval reconstruidas desde execution_index.json + el checkpointer.
GET /executions/{trace_id}El AgentExecution completo (vía orchestrator.snapshot).404 · 500 si la config referida falta
POST /executions/{trace_id}/approveReanuda un HITL. Body {approved_action_ids: list[str], comment?: str}. resume(decision={...rejected:false})finalize filtra a las aprobadas → completed. Escribe en executions.jsonl.404 · 409 si no está en awaiting_approval
POST /executions/{trace_id}/rejectRechaza un HITL. Body {reason: str}. resume(decision={...rejected:true})status = failed, error = rejected_by_human. Escribe en executions.jsonl.404 · 409
GET /policiesLista de PolicyDefinition (validadores de entrada/salida y su config).
GET /policies/{name}/versionsLista de PolicyVersionMeta.404
GET /violations?trace_id=&severity=Lista de GuardrailViolation desde violations.jsonl, con filtros opcionales por trace_id y severity (info|warning|block).
Además, FastAPI sirve /docs (Swagger UI) y /openapi.json automáticamente. El walkthrough que estás leyendo no es un endpoint: es un fichero (docs/walkthrough.html) — ver §19.

Helpers internos de executions.py alrededor del índice (data/execution_index.json): _record_execution (en cada invoke), _resolve (reconstruye agent_def/policy; 404/500), _ensure_awaiting (404/409).

15 El agente de ejemplo: incident_analyzer

El repo trae un agente de operaciones de telco y un proveedor LLM mock determinista para que el demo funcione out-of-the-box, sin API keys ni red. MockProvider mira el texto de entrada y elige una de tres respuestas canónicas buscando subcadenas ("sip", "mos", "hss", en ese orden); si no encuentra ninguna, una respuesta genérica (o, en tests con texto aleatorio, un fallback por hash). Cada respuesta es un JSON con severity, root_cause_hypothesis y proposed_actions.

Escenario (examples/*.txt)KeywordRespuesta del mock — acciones propuestas¿HITL?
01_sip_registration_drop
caída de registros SIP tras desplegar imagen en CSCF
"sip" act-1 rollback_image en cscf-cluster-aravaca-01 · risk 4 · requires_approval  |  act-2 drain_traffic_to_standby · risk 3 act-1 (risk 4 ≥ umbral 4 + requires_approval)
02_mos_degradation_pool_sbc
degradación de MOS en pool SBC en pico horario
"mos" act-1 scale_out_sbc_pool en sbc-pool-borde-norte · risk 2 · no requiere aprobación no — completa directo
03_hss_capacity_active_active
saturación replicada en HSS active-active
"hss" act-1 isolate_replication_link en hss-pair-madrid · risk 5 · requires_approval act-1 (risk 5)
input del incidente"…caída de registros SIP…" MockProvider¿contiene "sip"? → sí respuesta canónica SIPJSON: severity=high, hypothesis,proposed_actions=[act-1, act-2] propose_actions[act-1 risk4, act-2 risk3] approve_gateact-1.risk_score(4) ≥ risk_threshold_for_hitl(4)→ interrupt() → awaiting_approval
Figura 9. El escenario SIP de punta a punta a través del mock. La palabra "sip" en el input selecciona la respuesta canónica; act-1 tiene risk_score = 4, igual al umbral del agente (risk_threshold_for_hitl = 4) y además requires_approval = true, así que approve_gate pausa la ejecución.

Lo que no es código: agents/incident_analyzer/ (el YAML del agente + examples/*.txt), policies/default/ (la política), y data/ (estado runtime gitignored: checkpoints.sqlite, executions.jsonl, violations.jsonl, execution_index.json — se crea sola). Para usar Azure OpenAI o OpenAI reales: edita .env (LLM_PROVIDER=azure|openai + las claves). Los providers reales tienen retry exponencial 1s/2s/4s.

16 El dashboard (Streamlit)

Una consola visual con cinco páginas. No contiene lógica de negocio: client.py (CoreClient, httpx síncrono con 2 retries) envuelve todos los endpoints del core y mapea 404/409/422 a {"api_error": …} para que las páginas puedan distinguir "la API rechazó la petición" de "ejecución sin error". app.py es la raíz: sidebar de branding + health-check del core. Nadie del core depende del dashboard.

PáginaQué muestraEndpoints que usa
🏛️ Registro
pages/1_…_Registro.py
Catálogo de agentes: detalle (prompt, esquema, LLM, guardrails), tabla de versiones, diff coloreado v1↔v2./agents, /agents/{n}, /agents/{n}/versions, /agents/{n}/versions/{a}/diff/{b}
▶️ Ejecutar
pages/2_…_Ejecutar.py
Lanza un agente: botones con los escenarios pregrabados, textarea, invoke, y render del status + output + violaciones + timeline del decision_path. Avisa si quedó en awaiting_approval./agents, /agents/{n}/invoke
🤝 Aprobaciones
pages/3_…_Aprobaciones.py
La cola de HITL: lista las ejecuciones awaiting_approval, muestra cada acción propuesta (risk_score coloreado, target, rollback_plan) con un checkbox, y aprueba el subconjunto elegido o rechaza con motivo./executions (filtra), /executions/{t}, /executions/{t}/approve, /executions/{t}/reject
📜 Historial
pages/4_…_Historial.py
Pestaña de ejecuciones (tabla + detalle por trace_id) y pestaña de violaciones (filtrable por severidad)./executions, /executions/{t}, /violations
📐 Politicas
pages/5_…_Politicas.py
Inventario de políticas: validadores de entrada/salida (cada uno expandible con su config) y versiones./policies, /policies/{n}/versions

Componentes reutilizables de UI: components/diff_view (pinta +/-/@@), components/trace_view (el timeline del decision_path), components/violation_view (badges de severidad). El dashboard no tiene tests unitarios (mal coste/beneficio para Streamlit); su verificación es la checklist manual de docs/manual_qa.md. Demo guiada en 3 pasos: Registro → comparar v1 vs v2 · Ejecutar → escenario SIP → pausa en HITL · Aprobaciones → aprobar las seguras → ejecución completa.

17 Arranque y configuración

Out-of-the-box, sin claves: cp .env.example .env y docker compose up → dashboard en localhost:8501 y API en localhost:8000. (Este documento se consulta abriendo docs/walkthrough.html en el navegador — no requiere servidor.)

El ciclo de vida del proceso core

  1. Importar forja_core.main ejecuta app = create_app(): Settings()configure_logging(level)FastAPI(...)add_middleware(TraceIdMiddleware) → registra GET /health → importa los routers → include_router (agents, executions ×2, policies, violations).
  2. Las dependencias (get_registry, get_policy_store, get_llm_provider, get_guardrail_engine, get_orchestrator) no se construyen aún; se construyen y cachean en la primera request que las inyecta.
  3. Cada request: TraceIdMiddleware.dispatch → router → resuelve Depends(...) (que puede disparar la construcción perezosa) → handler → respuesta con X-Trace-Id.

En contenedores: uvicorn forja_core.main:app --host 0.0.0.0 --port 8000; HEALTHCHECKcurl /health; monta ./agents:ro, ./policies:ro, ./data:rw; DATA_DIR=/app/data, etc. El dashboard depende de core: service_healthy y usa FORJA_CORE_URL=http://core:8000. La imagen del core instala en_core_web_sm de spaCy para Presidio.

Variables de entorno (config.py · .env)

VarDefaultLo usa
LLM_PROVIDERmockbuild_llm_provider (mock|azure|openai)
LLM_FALLBACK_PROVIDER""declarado; la factory aún no lo aplica
AZURE_OPENAI_*"" / 2024-08-01-previewAzureOpenAIProvider
OPENAI_API_KEY · OPENAI_MODEL"" · gpt-4oOpenAIProvider
GUARDRAILS_NEMO_ENABLEDfalsebuild_guardrail_engine (añade NeMo al composite)
LOG_LEVELINFOconfigure_logging
DATA_DIR./datacheckpointer · índice · JSONL
AGENTS_DIR./agentsFileSystemAgentRegistry
POLICIES_DIR./policiesFileSystemPolicyStore
FORJA_CORE_URLhttp://core:8000el dashboard (CoreClient)
⚠️ Gotchas

detect_pii se comporta distinto según el entorno: con presidio-analyzer instalado (imagen Docker) usa Presidio + en_core_web_sm; sin él (venv local típico), regex fallback. · AsyncSqliteSaver liga su conexión al event loop activo: por eso el checkpointer es un async context manager que el orchestrator abre y cierra en cada operación; requiere aiosqlite<0.21. · data/ debe existir y ser escribible por el usuario del contenedor (agent, uid 1000). · build_llm_provider usa match sin case _ (exhaustivo sobre el Literal de 3 valores). · NeMo solo entra al composite si GUARDRAILS_NEMO_ENABLED=true.

18 Tests

Hay una suite de unit + integration (~90 tests). make test corre solo los unit; make test-all añade los de integración; make lint es ruff + mypy (estricto); make smoke levanta docker compose y hace curl /health.

  • tests/unit/ — dominio (agent, execution, guardrail, policy), config, llm (base, mock, factory), guardrails (ai, composite), registry (repository, policy_store, versioning, factory), runtime (state-graph, nodes, orchestrator), observability/logging, y los routers de la API (agents, executions, policies/violations, health) — estos usan tests/fixtures/ (versiones mínimas) y hacen deps.*.cache_clear() tras apuntar DATA_DIR a un tmp_path.
  • tests/integration/ — montan la app con TestClient contra los assets reales del repo (agents/, policies/): test_invoke_happy_path, test_invoke_hitl (el ciclo completo en ~40 líneas — el mejor sitio para entenderlo), test_invoke_pii_block, test_invoke_resume_after_restart (prueba que un awaiting_approval sobrevive a recrear el orchestrator).
  • El dashboard Streamlit no se prueba con unit tests; se verifica con la checklist de docs/manual_qa.md.

19 Este documento — un HTML autocontenido

Lo que estás leyendo es un único fichero HTML autocontenidodocs/walkthrough.html en el repositorio. Todo está embebido: los estilos, los nueve diagramas SVG, la navegación con scrollspy, el tema claro/oscuro y los bloques de código. No depende de ninguna red ni de ningún CDN, y no necesita que ningún servidor esté corriendo: se abre directamente en cualquier navegador.

abrir el walkthrough — no hace falta docker ni el corebash
xdg-open docs/walkthrough.html        # Linux
open docs/walkthrough.html            # macOS
# o arrástralo al navegador · o file://<ruta-del-repo>/docs/walkthrough.html
🔌 ¿Servirlo desde la API?

Sería trivial si se quisiera: un router de FastAPI de ~10 líneas que devuelva este fichero como HTMLResponse (p. ej. bajo GET /walkthrough), montado aparte de los routers de negocio. En esta versión del repo no está cableado — el documento se consulta como fichero.

20 Glosario

TérminoSignificado en este proyecto
AgenteUna configuración declarativa (YAML): prompt de sistema, modelo LLM, esquema de salida, lista de políticas de guardrails, umbral de riesgo para HITL. No es código.
Política (de guardrails)Un YAML con la lista de validadores de entrada y salida y su config, más on_validator_error (fail_closed/fail_open).
Validador / guardrailUna función que mira el texto de entrada o la salida del LLM y devuelve cero o más GuardrailViolation. Ej.: detect_pii, prompt_injection, schema_match, telco_safety_rules.
ViolaciónEl resultado de un validador: stage (input/output), validator, severity (info/warning/block), message, blocked.
Ejecución (AgentExecution)El "expediente" de una invocación: trace_id, status, decision_path, violations, proposed_actions, needs_human_for, final_output, error.
decision_pathLa lista de pasos por los que pasó el grafo, cada uno con step, timestamp, duration_ms y un detail. La "caja negra" de la ejecución.
HITLHuman-in-the-Loop: pausar la ejecución cuando hay una acción arriesgada y esperar a que un humano apruebe o rechace. Implementado con interrupt() de LangGraph.
CheckpointerEl componente de LangGraph que persiste el estado del grafo (aquí, en SQLite). Lo que hace posible reanudar un HITL tras un reinicio.
trace_idUUID que identifica una petición/ejecución y se propaga por middleware → logs → API → ficheros de auditoría.
OrchestratorLa única clase que habla con LangGraph (invoke/resume/snapshot); aísla al resto del sistema del runtime.
FactoryFunción build_X(settings) que monta la implementación de X adecuada según la configuración.
Status de ejecuciónrunning, awaiting_approval (pausada en HITL), blocked_by_guardrail, completed, failed.
Estados de un agentedraft, active, deprecated (metadato en su YAML).

21 Lo que aún no hace (a propósito)

Es un MVP. En el roadmap (docs/futuro.md): NeMo Guardrails completo (Colang + KB embedding + dialog rails) · autenticación (OAuth2/OIDC, MSAL para Azure AD) · OpenTelemetry (spans por nodo, métricas de violaciones por validador) · multi-tenant (tenant_id aislado) · evaluadores LLM-as-judge (regression suite sobre escenarios canónicos, mide deriva) · UI de aprobación con SLA (cola Kanban, reasignación, alertas) · persistencia migrable a Postgres + pgvector · esquema de prompts mejorado (variables tipadas tipo Jinja, valores por entorno) · promoción explícita draft → active vía PR/aprobación. La detección de PII más fina (modelos grandes, español), también pendiente.

22 Por dónde empezar a leer (ruta de ~1 hora)

  1. domain/execution.py y domain/agent.py — qué datos hay. 06)
  2. policies/default/versions/v1.yaml y agents/incident_analyzer/versions/v2.yaml — cómo se declara todo. 07, §10)
  3. runtime/nodes.py y runtime/graph.py — el flujo de ejecución. 08)
  4. api/executions.py — cómo se expone (invoke / approve / reject). 14)
  5. tests/integration/test_invoke_hitl.py — el ciclo completo, en ~40 líneas. 18)
  6. Levanta docker compose up, haz clic por las cinco páginas del dashboard 16), y abre docs/walkthrough.html en el navegador 19).
📚 Documentos hermanos

README.md — quickstart y estado. · ARCHITECTURE.md — decisiones técnicas en bruto. · docs/explicacion.md — la narrativa de extremo a extremo (en prosa). · docs/componentes.md — la referencia de cableado a bajo nivel (firmas exactas, grafos de dependencias, cadenas de llamada por endpoint). · docs/futuro.md — roadmap. · docs/manual_qa.md — checklist de verificación manual del dashboard.

23 Ruta de codificación manual — de cero a sistema funcionando

Si quieres entender el proyecto escribiéndolo, este es el orden que maximiza la comprensión. La lógica: sigue el grafo de dependencias hacia afuera desde el centro — empieza por lo que no depende de nada, termina por lo que depende de todo.

Principio rector

domain/ no importa nada del proyecto. El grafo no sabe que existe una API. La API no sabe que existe el dashboard. Escribe en esa dirección: adentro → afuera, abstracto → concreto, sin dependencias → con dependencias.

Fase 1 — El vocabulario: domain/ (~30 min)

Estos cuatro ficheros no importan nada del proyecto — solo Pydantic. Son el vocabulario de todo lo demás. Escribirlos primero te obliga a responder "¿qué maneja este sistema?" antes de pensar en cómo lo procesa.

  1. domain/agent.pyAgentDefinition, LLMConfig, AgentVersionMeta. Al terminar sabrás exactamente qué es "un agente" en este sistema: una declaración Pydantic, no código Python.
  2. domain/guardrail.pyGuardrailViolation. Qué devuelve un guardrail cuando detecta un problema: una violación tipada, no una excepción.
  3. domain/policy.pyPolicyDefinition. La lista de reglas que rigen un agente concreto.
  4. domain/execution.py — el modelo más rico: AgentExecution, ProposedAction, ExecutionStatus, DecisionStep. Contiene el estado completo de una ejecución. Fíjate en que AgentExecution tiene definición del agente separada de su instancia en runtime.
Momento clave #1

Al escribir AgentExecution te das cuenta de que el sistema separa definir un agente (YAML → Pydantic) de ejecutarlo (runtime → JSONL). Son dos ciclos de vida distintos.

Fase 2 — Las costuras: los dos Protocols (~15 min)

Los Protocols son las interfaces estructurales del sistema — el contrato que separa "qué hace" de "cómo lo hace". El grafo solo importa estos dos ficheros, nunca OpenAI ni guardrails-ai directamente.

  1. llm/base.pyLLMProvider (un método async complete()), Message, CompletionResult. Dos clases Pydantic y un Protocol de cuatro líneas.
  2. guardrails/base.pyGuardrailEngine: dos métodos (validate_input y validate_output), ambos devuelven list[GuardrailViolation].
Momento clave #2

Escribir estos dos Protocols antes que cualquier implementación te revela por qué cambiar el proveedor LLM de mock a azure no requiere tocar el grafo: el grafo nunca ve la implementación concreta.

Fase 3 — Primera ejecución posible: mocks + YAML del agente (~45 min)

Con dominios e interfaces ya puedes ejecutar el sistema sin ninguna clave de API. Los mocks son tu andamio.

  1. llm/mock.py — implementa LLMProvider devolviendo un JSON hardcodeado. ~30 líneas. Permite correr el grafo completo sin tocar OpenAI ni Azure.
  2. guardrails/validators.py — validadores keyword-based simples. Implementan GuardrailEngine sin dependencias externas.
  3. El primer YAML del agente: agents/incident_analyzer/versions/v1.yaml. Escribe la definición declarativa (nombre, owner, purpose, guardrails, config LLM, system_prompt, output_schema).
Momento clave #3

Al escribir el YAML del agente entiendes que un agente es pura configuración: no tiene código Python. Cambiar su comportamiento — modelo, temperatura, guardrails, threshold de HITL — es editar texto, no redeployar.

Fase 4 — El corazón del sistema: estado → nodos → grafo (~1.5 h)

Esta es la parte más densa e importante. El grafo LangGraph es la máquina de estados que orquesta todo. El orden dentro de la fase también importa.

  1. runtime/state.py primero — AgentState: un TypedDict con todos los campos del estado mutable que fluye por el grafo. El detalle a entender: decision_path: Annotated[list, operator.add] — no se sobreescribe, se acumula; LangGraph llama al reducer automáticamente cada vez que un nodo devuelve un decision_path.
  2. runtime/nodes.py — los seis nodos, en el orden en que aparecerán en el grafo:
    • build_node_validate_input — llama al engine, acumula violations, decide si poner blocked_by_guardrail.
    • build_node_llm_reason — llama al provider, captura el fallo y lo transforma en status="failed".
    • build_node_validate_output — parsea el JSON del LLM y valida el output contra la política.
    • build_node_propose_actions — extrae proposed_actions del output parseado.
    • build_node_approve_gate — el nodo HITL: llama a interrupt({"awaiting_actions": risky}) si hay acciones con riesgo ≥ threshold. La ejecución se pausa aquí hasta que llegue un Command(resume=...).
    • build_node_finalize — filtra acciones por las aprobadas, construye final_output.

    Nota el patrón: cada builder captura sus dependencias por clausura y devuelve una función async (state) → dict. Las dependencias no viajan en el estado — están fijas en el cierre.

  3. runtime/graph.py — conecta los nodos. Los tres routers condicionales (_after_validate_input, _after_llm, _after_validate_output) hacen que cualquier error cortocircuite hacia END sin pasar por nodos posteriores.
Momento clave #4

Al implementar approve_gate entiendes cómo funciona HITL en LangGraph: interrupt() no lanza excepción — serializa el estado en el checkpoint SQLite y devuelve el control. La ejecución se reanuda en el mismo nodo cuando llega Command(resume=...). El estado nunca se pierde entre las dos llamadas HTTP.

Fase 5 — Orquestador y checkpointer (~30 min)

  1. runtime/checkpointer.py — configura SQLite como backend de checkpoints LangGraph. Sin esto, interrupt() no puede persistir el estado entre la llamada a invoke y la llamada a approve.
  2. runtime/orchestrator.py — el objeto que la API usará. Recibe AgentDefinition + PolicyDefinition + providers ya construidos, compila el grafo y expone dos métodos: invoke() (nueva ejecución) y approve() (reanudar tras HITL).

Punto de control: en este momento tienes un sistema funcionando de extremo a extremo. Puedes llamar directamente al orquestador en un test y observar el decision_path completo, sin API ni dashboard.

Fase 6 — La API: de adentro hacia afuera (~1 h)

Dentro de api/, el orden también importa.

  1. api/deps.py — el armario de cableado: los @lru_cache que construyen el registry, el policy store, el LLM provider y el guardrail engine. Escríbelo después de tener todo lo anterior — solo entonces sabrás exactamente qué estás conectando.
  2. api/executions.py — los tres endpoints críticos: POST /executions/invoke, POST /executions/{trace_id}/approve, GET /executions/{trace_id}.
  3. api/agents.py — CRUD del registry (listar agentes, leer versión concreta).
  4. api/middlewares.py — inyecta X-Trace-Id y bind-ea el contexto de structlog.
  5. main.py — monta los routers, configura CORS, arranca la app FastAPI.

Fase 7 — Registry y versionado (~45 min)

Si has estado usando el mock registry, ahora añades la persistencia real basada en YAML.

  1. registry/repository.py — carga definiciones de agentes desde YAML, cachea en memoria.
  2. registry/versioning.py — hash SHA-256 + index.yaml: el sistema de versionado tipo-Git.
  3. registry/policy_store.py — análogo al repository, pero para políticas.
  4. registry/factory.py — el factory que elige qué implementación de registry usar según Settings.

Fase 8 — El dashboard (~1.5 h)

El dashboard es un cliente HTTP del core — puedes empezarlo en cualquier momento después de tener los endpoints.

  1. dashboard/client.py — primero. Te fuerza a pensar en qué necesita la UI antes de diseñar las páginas. Es el contrato del core visto desde el consumidor.
  2. Las páginas por complejidad creciente:
    • pages/1_Registro.py — solo lectura: lista agentes y versiones.
    • pages/5_Politicas.py — solo lectura: muestra políticas activas.
    • pages/4_Historial.py — lectura + filtros: lista ejecuciones pasadas.
    • pages/2_Ejecutar.py — escritura: invoke + polling de estado + visualización del decision_path.
    • pages/3_Aprobaciones.py — la más compleja: muestra acciones pendientes, permite aprobar o rechazar individual o masivamente.
Punto de control mínimo viable (tras Fase 5)

Con las fases 1–5 completas y el mock LLM tienes un sistema funcionando sin Docker ni credenciales externas. Llama directamente al orquestador desde un test de integración, observa el decision_path completo, y pausa-reanuda en el nodo HITL. No necesitas la API ni el dashboard para validar el núcleo.