Files
forja/docs/explicacion.md
T
JuanandClaude Opus 4.7 ce629139c2 docs: añade docs/componentes.md — referencia de cableado a bajo nivel
Complementa a docs/explicacion.md (alto nivel) con la vista de bajo nivel:
- grafo de dependencias de módulos por niveles topológicos (imports internos)
- el grafo de inyección de dependencias de api/deps.py (lru_cache + factories)
- las firmas exactas de los contratos: LLMProvider, GuardrailEngine, los
  validadores, el registry/policy store, el AgentOrchestrator, AgentState y el
  cableado del grafo LangGraph, y cómo orchestrator._build_execution mapea el
  StateSnapshot a AgentExecution
- la cadena de llamada de cada endpoint (qué deps, qué llama, qué persiste)
- mapa de persistencia (quién escribe/lee cada artefacto YAML/JSON/JSONL/SQLite)
- mapa CoreClient ↔ endpoints ↔ páginas del dashboard
- arranque/ciclo de vida y tabla Settings → consumidor
- "gotchas" conocidos
README y docs/explicacion.md enlazan al nuevo documento.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 15:20:00 +02:00

35 KiB

AgentForge explicado de principio a fin

Para quién es esto. Una guía didáctica para alguien que llega nuevo al proyecto —técnico o no— y quiere entender qué hace, por qué está hecho así y cómo encajan las piezas sin tener que leer todo el código primero.

Si solo quieres arrancarlo, ve al README.md. Si quieres las decisiones técnicas en bruto, ve a ARCHITECTURE.md. Si quieres la referencia de cableado a bajo nivel (módulos, firmas, grafos de dependencias, cadenas de llamada), ve a docs/componentes.md. Este documento está en medio: cuenta la historia.


1. El problema, en una frase

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.

AgentForge 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 que trae el repo es 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. Acciones de riesgo alto quedan pausadas esperando aprobación humana. Todo queda registrado.


2. Las seis ideas grandes

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

# Idea Dónde vive
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 y diff. Sin redeploy. agents/, policies/, registry/
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. policies/, guardrails/
3 La ejecución del agente es un grafo de estados con checkpoints. No es "llama al LLM y ya"; es un flujo: validar entrada → razonar → validar salida → proponer acciones → puerta de aprobación → finalizar. Cada paso se persiste. runtime/ (LangGraph)
4 Human-in-the-Loop (HITL) de verdad. Si el agente propone algo arriesgado, el grafo se pausa en mitad de la 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 + endpoints /approve, /reject
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
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)

3. Vista de pájaro: dos servicios

AgentForge son dos procesos que se hablan por HTTP/JSON:

┌───────────────────────────── docker-compose ──────────────────────────────┐
│                                                                            │
│   ┌──────────────────────┐      HTTP/JSON      ┌────────────────────────┐  │
│   │  agentforge-dashboard │  ───────────────►   │   agentforge-core      │  │
│   │  Streamlit   :8501    │  ◄───────────────   │   FastAPI    :8000     │  │
│   │  (la "consola")       │                     │   (el cerebro)         │  │
│   └──────────────────────┘                     └───────────┬────────────┘  │
│                                                            │               │
│                      ┌────────────────┬────────────────────┼───────────────┤
│                      ▼                ▼                     ▼               │
│                 capa LLM        capa Guardrails        runtime LangGraph    │
│             (Strategy+factory) (Strategy+factory)   (grafo + checkpointer)  │
│                      │                │                     │               │
│                      └────────────────┴──────────┬──────────┘               │
│                                                  ▼                          │
│                          Persistencia: YAML · JSON · JSONL · SQLite         │
└────────────────────────────────────────────────────────────────────────────┘
  • agentforge-core (FastAPI, puerto 8000) — todo el dominio: registry de agentes, motor de guardrails, runtime de ejecución, persistencia. No tiene UI.
  • agentforge-dashboard (Streamlit, puerto 8501) — una consola visual. No contiene lógica de negocio: es un cliente HTTP del core con cinco páginas.

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.

Las capas del core (de fuera hacia dentro)

   HTTP  ─►  middlewares  ─►  routers (api/)  ─►  dependencias (deps.py)
                                   │                    │  ← aquí se inyectan los
                                   │                    │    objetos del dominio
                                   ▼                    ▼
                            orchestrator  ──►  graph (LangGraph)  ──►  nodes
                                                       │                 │
                                                       │                 ├─► llm/        (¿qué dice el LLM?)
                                                       │                 ├─► guardrails/  (¿pasa los filtros?)
                                                       │                 └─► domain/      (¿qué forma tienen los datos?)
                                                       ▼
                                          checkpointer (SQLite)  +  persistence (JSONL)  +  registry (YAML/JSON)

4. Recorrido por los módulos (y quién depende de quién)

El código del core vive bajo core/src/agentforge_core/. Lo agrupo por capas, de las más internas (sin dependencias) a las más externas.

4.1 domain/ — el vocabulario del sistema

Modelos Pydantic puros. No dependen de nada del proyecto; todo lo demás depende de ellos. Son el "idioma común".

Fichero Qué define
agent.py AgentDefinition (prompt, modelo, output_schema, guardrails, risk_threshold_for_hitl, ...), LLMConfig, AgentVersionMeta.
policy.py PolicyDefinition (listas de PolicyValidator de entrada y de salida, on_validator_error), PolicyVersionMeta.
guardrail.py GuardrailViolation (trace_id, stage input/output, validator, severity, message, blocked).
execution.py AgentExecution (el "expediente" de una ejecución: status, decision_path, violations, proposed_actions, needs_human_for, final_output, error), ProposedAction, DecisionStep, AgentExecutionSummary.

Pista didáctica: si quieres entender el sistema rápido, empieza leyendo domain/execution.py. Te dice exactamente qué información se produce y se guarda.

4.2 config.py y observability/logging.py — los cimientos transversales

  • config.pySettings (pydantic-settings): lee .env (proveedor LLM, rutas de agents//policies//data/, nivel de log, claves de Azure/OpenAI, flags de NeMo). Es el único sitio que sabe de variables de entorno. Todos los factory.py reciben un Settings.
  • observability/logging.py: configura structlog con salida JSON y un contexto donde se "bind-ea" el trace_id. Lo usa todo el código que loguea (log = structlog.get_logger(__name__)).

Dependen: de nada del proyecto. Dependen de ellos: prácticamente todo.

4.3 llm/ — la capa de proveedores de LLM (Strategy pattern)

Fichero Rol
base.py El Protocol LLMProvider (async complete(messages, temperature, max_tokens) -> CompletionResult) y los tipos Message, CompletionResult. La interfaz.
mock.py MockProvider: determinista, sin claves. Elige una respuesta canónica buscando subcadenas ("sip", "mos", "hss") en el input. Es lo que hace que el demo funcione out-of-the-box.
azure.py, openai.py Implementaciones reales (Azure OpenAI / OpenAI) con reintentos exponenciales y, opcionalmente, fallback entre proveedores.
factory.py build_llm_provider(settings): devuelve el provider según LLM_PROVIDER (mock / azure / openai), y envuelve un fallback opcional.

Depende de: domain/, config.py. Dependen de él: el runtime/ (el nodo llm_reason) y el deps.py de la API.

4.4 registry/ — catálogo y versionado de agentes y políticas

Fichero Rol
repository.py FileSystemAgentRegistry: lee agents/<nombre>/index.yaml + versions/<id>.yaml. CRUD de agentes y versiones, lectura de la versión "activa".
policy_store.py FileSystemPolicyStore: lo mismo para policies/. Devuelve PolicyDefinitions.
versioning.py compute_hash(yaml_text) (SHA-256 sobre el contenido normalizado) y el diff unificado entre dos versiones. Es la "magia tipo Git": versiones inmutables identificadas por hash, comparables.
factory.py build_registry(settings) y build_policy_store(settings).

Depende de: domain/, config.py. Dependen de él: la API (/agents, /policies), el orchestrator (para resolver qué agente/política ejecutó una traza dada).

4.5 guardrails/ — el motor de validación

Aquí está el corazón del "gobierno". La estructura sigue otra vez Protocol + implementaciones + composite + factory:

            GuardrailEngine (Protocol)            ← base.py: validate_input / validate_output
                     ▲
        ┌────────────┼─────────────┐
        │                          │
  GuardrailsAIEngine        NeMoGuardrailsEngine   ← guardrails_ai.py / nemo.py
   (el real: aplica los       (stub; desactivado
    validadores de la          por defecto)
    política)
        │
        └──────────┐
                   ▼
       CompositeGuardrailEngine                    ← composite.py: corre N sub-engines
       (corre los sub-engines en paralelo            en paralelo (asyncio.gather) y
        con asyncio y agrega las violaciones)        une las listas de violaciones

   factory.py: build_guardrail_engine(settings) → ensambla el Composite
   validators.py: las funciones concretas — detect_pii, prompt_injection,
                  toxic_language, forbidden_topics, schema_match, pii_leakage,
                  forbidden_action_keywords, telco_safety_rules

Cómo se conecta una política con un validador: la política (policies/default/versions/v1.yaml) lista, por ejemplo:

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 }
  ...
on_validator_error: fail_closed

El GuardrailsAIEngine recorre esa lista, busca cada type en su registro de funciones de validators.py, la llama con el texto y el config, y junta las GuardrailViolation que devuelva. on_validator_error: fail_closed significa que si un validador peta, cuenta como bloqueo (seguridad antes que disponibilidad).

Sobre detect_pii: usa Presidio (con el modelo spaCy en_core_web_sm) si está instalado; si no, cae a una detección por regex (email, teléfono, NIF español, IP). Por eso el comportamiento en local (sin Presidio) y en Docker (con Presidio) puede diferir — la política solo pide recognizers de patrón fiables para evitar falsos positivos del NER.

Depende de: domain/, config.py. Dependen de él: el runtime/ (los nodos validate_input/validate_output) y el deps.py.

4.6 runtime/ — la ejecución como grafo de estados (LangGraph)

Esta es la capa más "viva". Modela una ejecución del agente como un grafo dirigido.

Fichero Rol
state.py AgentState: un TypedDict con todo lo que fluye por el grafo (input, salida del LLM, acciones propuestas, violaciones acumuladas, decision_path, status, decisión humana, ...). Algunos campos usan reducers (Annotated[list, operator.add]) para que cada nodo añada en vez de sobrescribir.
nodes.py Las funciones-nodo, construidas por factories parametrizadas con el agente, la política, el motor de guardrails y el provider LLM: validate_input, llm_reason, validate_output, propose_actions, approve_gate, finalize. Cada nodo añade un DecisionStep con su duración.
graph.py build_graph(...): cablea los nodos y las aristas condicionales (p. ej. si validate_input bloqueó → salta directo al final). Compila el grafo con un checkpointer.
checkpointer.py build_checkpointer(data_dir): un AsyncSqliteSaver de LangGraph sobre data_dir/checkpoints.sqlite, expuesto como context manager asíncrono. Es lo que hace que un awaiting_approval sobreviva a un reinicio.
orchestrator.py AgentOrchestrator: la única puerta de entrada al runtime. Tres operaciones: invoke() (lanza), resume() (reanuda un HITL con la decisión), snapshot() (lee el estado actual sin avanzarlo). Cada llamada abre su propio checkpointer y traduce el StateSnapshot de LangGraph a un AgentExecution del dominio.

El grafo, dibujado:

        START
          │
          ▼
   ┌─────────────────┐   bloqueada (PII, injection...)
   │ validate_input  │ ─────────────────────────────────────────► END
   └────────┬────────┘
            │ ok
            ▼
   ┌─────────────────┐   LLM no disponible / error
   │   llm_reason    │ ─────────────────────────────────────────► END
   └────────┬────────┘
            │ ok
            ▼
   ┌─────────────────┐   salida no cumple el esquema / PII en la salida
   │ validate_output │ ─────────────────────────────────────────► END
   └────────┬────────┘
            │ ok
            ▼
   ┌─────────────────┐
   │ propose_actions │   (extrae las acciones propuestas del JSON del LLM)
   └────────┬────────┘
            │
            ▼
   ┌─────────────────┐   ¿hay alguna acción con risk_score ≥ umbral
   │  approve_gate   │    o requires_approval=True?
   └────────┬────────┘
            │
       ┌────┴───────────────────────────────┐
       │ no                                 │ sí
       ▼                                    ▼
  ┌──────────┐                      interrupt({...})  ──►  el grafo SE PAUSA aquí.
  │ finalize │                       El estado queda en checkpoints.sqlite.
  └────┬─────┘                       Más tarde llega resume(decision={...})
       │                             y se reanuda en este mismo punto.
       ▼                                    │
      END                                   ▼
                                        ┌──────────┐
                                        │ finalize │  (filtra a las acciones aprobadas;
                                        └────┬─────┘   si fue rechazo → status=failed)
                                             ▼
                                            END

Depende de: llm/, guardrails/, domain/, config.py (vía los objetos que le inyectan). Dependen de él: la API (api/executions.py solo conoce el AgentOrchestrator, no LangGraph).

4.7 api/ — la fachada HTTP (FastAPI)

Fichero Rol
middlewares.py TraceIdMiddleware: lee X-Trace-Id de la petición (o genera uno), lo bind-ea al contexto de structlog, y lo devuelve en la respuesta. Es el origen del hilo de trazabilidad.
deps.py Las dependencias inyectables. Cada get_* (get_settings, get_registry, get_policy_store, get_llm_provider, get_guardrail_engine, get_orchestrator) está cacheada con lru_cache(maxsize=1): la app construye cada cosa una sola vez. Expone aliases (RegistryDep = Annotated[..., Depends(get_registry)], etc.) que los routers piden por parámetro. Los tests llaman a .cache_clear() para reconstruir todo apuntando a un DATA_DIR temporal.
persistence.py Helpers de log append-only en JSONL: append_execution, append_violation, read_execution_summaries. Inmutable, auditable, fácil de "shipear" a un sistema de logs.
agents.py Router /agents: list, get, versiones, GET /agents/{n}/versions/{a}/diff/{b}.
executions.py El más cargado: POST /agents/{n}/invoke, GET /executions, GET /executions/{trace_id}, POST /executions/{trace_id}/approve, POST /executions/{trace_id}/reject. Mantiene además execution_index.json (mapa trace_id → agente/versión) para poder reanudar tras un reinicio.
policies.py, violations.py Routers /policies y /violations (este con filtros por severidad, stage, etc.).

Y la raíz de la app: core/src/agentforge_core/main.pycreate_app() instancia FastAPI, añade el TraceIdMiddleware, monta los routers, y expone /health.

Depende de: todo lo de arriba (vía deps.py). Dependen de él: el dashboard (por HTTP) y los tests de integración (tests/integration/, vía TestClient).

4.8 dashboard/ — la consola Streamlit

Fichero Rol
client.py CoreClient: un cliente HTTP síncrono (httpx, con reintentos) que envuelve todos los endpoints del core y mapea 404/409/422 a {"error": ...}. Es lo único que sabe hablar con el core.
app.py La página raíz: sidebar de branding + un health-check del core.
pages/1_🏛️_Registro.py Catálogo de agentes: detalle (prompt, esquema, LLM, guardrails), tabla de versiones, diff coloreado v1↔v2.
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.
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.
pages/4_📜_Historial.py Pestaña de ejecuciones (tabla + detalle por trace_id) y pestaña de violaciones (filtrable por severidad).
pages/5_📐_Politicas.py Inventario de políticas: validadores de entrada/salida (cada uno expandible con su config) y versiones.
components/ Trozos reutilizables de UI: diff_view (pinta +/-/@@), trace_view (el timeline del decision_path), violation_view (badges de severidad).

Depende de: el agentforge-core por HTTP (vía AGENTFORGE_CORE_URL). Nadie del core depende del dashboard. No tiene tests unitarios (mal coste/beneficio para Streamlit); su verificación es la checklist manual de docs/manual_qa.md.

4.9 Lo que no es código: agents/, policies/, data/

  • agents/incident_analyzer/ — el agente de ejemplo: index.yaml (catálogo de versiones), versions/v1.yaml y v2.yaml, y examples/*.txt (tres escenarios de incidente de telco que el demo usa).
  • policies/default/ — la política de guardrails de ejemplo (index.yaml + versions/v1.yaml).
  • data/ — estado runtime (gitignored): checkpoints.sqlite, executions.jsonl, violations.jsonl, execution_index.json. Se crea sola.

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

Tres veces (LLM, guardrails, registry) verás la misma estructura:

   base.py        →  un Protocol (la interfaz: "qué se puede hacer")
   <impl_a>.py    →  una implementación (p. ej. mock)
   <impl_b>.py    →  otra implementación (p. ej. azure)
   factory.py     →  build_X(settings: Settings) -> X   ← elige y monta

¿Por qué? Porque 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. Lo mismo con guardrails (puedes añadir el engine de NeMo) y con el registry (hoy es de ficheros; mañana podría ser de base de datos). Es el principio de "configuración antes que código".

Y todo se enchufa una sola vez al arrancar, en api/deps.py (los lru_cache): el registry, el policy store, el provider, el motor de guardrails y el orchestrator son singletons del proceso. Los routers solo los piden; no saben construirlos.


6. El recorrido de UNA petición, de principio a fin

Esta es la sección que conviene leer despacio: aquí se ve cómo encaja todo. Sigamos POST /agents/incident_analyzer/invoke con el escenario SIP (el que dispara HITL).

Acto 1 — la petición entra y se ejecuta el grafo

Cliente (dashboard o curl)
   │  POST /agents/incident_analyzer/invoke   { "input": "<texto del incidente SIP>" }
   ▼
TraceIdMiddleware ............ genera trace_id = UUID, lo bind-ea al log
   ▼
router invoke_agent (api/executions.py)
   │  pide por inyección: RegistryDep, PolicyStoreDep, OrchestratorDep, SettingsDep
   │  registry.get_agent("incident_analyzer")  →  AgentDefinition (versión activa = v2)
   │  policies.get_policy(agent.guardrails[0]) →  PolicyDefinition ("default")
   ▼
orchestrator.invoke(agent_def, policy, user_input)
   │  abre el checkpointer (AsyncSqliteSaver sobre data/checkpoints.sqlite)
   │  build_graph(agent_def, policy, provider, engine, checkpointer)
   │  graph.ainvoke(estado_inicial, config={thread_id: trace_id})
   ▼
   ┌──── el grafo ────────────────────────────────────────────────────────────┐
   │ validate_input  → engine.validate_input(texto, policy, trace_id)          │
   │                   recorre los input_validators de la política en paralelo │
   │                   (detect_pii, prompt_injection, ...) → 0 violaciones     │
   │                   añade DecisionStep("validate_input", ...)               │
   │ llm_reason      → provider.complete([system_prompt, user_input], ...)     │
   │                   (MockProvider ve "sip" → respuesta canónica SIP)        │
   │                   añade DecisionStep("llm_reason", model, tokens, ...)    │
   │ validate_output → parsea el JSON del LLM; engine.validate_output(...)     │
   │                   (schema_match, pii_leakage, forbidden_action_keywords,  │
   │                    telco_safety_rules) → 0 violaciones                    │
   │ propose_actions → extrae proposed_actions del JSON: [act-1 (risk 4,       │
   │                   requires_approval), act-2 (risk 3)]                     │
   │ approve_gate    → ¿alguna acción con risk ≥ 4 (umbral del agente) o       │
   │                   requires_approval? SÍ (act-1) → interrupt({...})        │
   │                   ⇒ el grafo SE DETIENE. El estado se escribe en SQLite.  │
   └──────────────────────────────────────────────────────────────────────────┘
   ▼
orchestrator._snapshot(...)  → LangGraph reporta "hay un nodo pendiente"
                             ⇒ status = "awaiting_approval"
                             ⇒ needs_human_for = [act-1]   (y devuelve un AgentExecution)
   ▼
router: status no es terminal → NO se escribe en executions.jsonl,
        pero SÍ se registra en execution_index.json: { trace_id → (incident_analyzer, v2) }
   ▼
respuesta 200  { status: "awaiting_approval", trace_id, needs_human_for: [act-1], decision_path: [...], ... }

En el dashboard, la página Ejecutar muestra el timeline y un aviso "ve a Aprobaciones". La página Aprobaciones hace GET /executions, encuentra esta ejecución (el endpoint reconstruye las awaiting_approval desde el índice + el checkpointer) y muestra act-1 con su risk_score, target y rollback_plan.

Acto 2 — el humano decide; el grafo se reanuda

Humano (en la página Aprobaciones, o curl)
   │  POST /executions/<trace_id>/approve   { approved_action_ids: ["act-1"], comment: "ok rollback" }
   ▼
router approve_execution (api/executions.py)
   │  _resolve(...)  → lee execution_index.json → sabe que fue (incident_analyzer, v2)
   │  reconstruye el AgentDefinition y la PolicyDefinition
   │  _ensure_awaiting(...) → orchestrator.snapshot(...) confirma que sigue en awaiting_approval
   ▼
orchestrator.resume(agent_def, policy, trace_id, decision={approved_action_ids:["act-1"], rejected:false})
   │  abre OTRA VEZ el checkpointer (mismo data_dir) — el estado pausado sigue ahí,
   │  aunque hubiera habido un reinicio del proceso entremedias
   │  graph.ainvoke(Command(resume=decision), config={thread_id: trace_id})
   ▼
   ┌──── el grafo continúa desde donde se quedó ──────────────────────────────┐
   │ approve_gate  → el interrupt() devuelve la `decision` del humano          │
   │ finalize      → final_actions = acciones cuyo id está aprobado = [act-1]  │
   │                 final_output = { ...salida del LLM..., approved_actions:[act-1] }│
   │                 status = "completed"                                     │
   └──────────────────────────────────────────────────────────────────────────┘
   ▼
router: status terminal → append_execution(...) escribe el AgentExecution en executions.jsonl
   ▼
respuesta 200  { status: "completed", final_output: { ..., approved_actions: [act-1] }, ... }

(Si en vez de approve se llama a reject, el nodo finalize ve rejected: truestatus = "failed", error = "rejected_by_human". También terminal → al JSONL.)

El mismo recorrido, como diagrama de secuencia

sequenceDiagram
    participant U as Cliente / Dashboard
    participant MW as TraceIdMiddleware
    participant API as router (api/executions.py)
    participant ORCH as AgentOrchestrator
    participant G as Grafo LangGraph
    participant CP as checkpoints.sqlite
    participant LOG as executions.jsonl / index.json

    U->>MW: POST /agents/incident_analyzer/invoke {input}
    MW->>API: + trace_id
    API->>API: registry.get_agent · policies.get_policy
    API->>ORCH: invoke(agent, policy, input)
    ORCH->>G: ainvoke(estado, thread_id=trace_id)
    G->>G: validate_input → llm_reason → validate_output → propose_actions
    G->>G: approve_gate: hay riesgo alto → interrupt()
    G->>CP: persiste estado pausado
    ORCH-->>API: AgentExecution(status=awaiting_approval, needs_human_for=[act-1])
    API->>LOG: index.json[trace_id] = (incident_analyzer, v2)
    API-->>U: 200 {status: awaiting_approval, trace_id, ...}

    Note over U,CP: ...más tarde (incluso tras reiniciar el core)...

    U->>API: POST /executions/{trace_id}/approve {approved_action_ids:[act-1]}
    API->>LOG: lee index.json → (incident_analyzer, v2)
    API->>ORCH: resume(agent, policy, trace_id, decision)
    ORCH->>CP: reabre checkpointer (estado pausado sigue ahí)
    ORCH->>G: ainvoke(Command(resume=decision), thread_id=trace_id)
    G->>G: approve_gate (recibe decisión) → finalize → status=completed
    ORCH-->>API: AgentExecution(status=completed, final_output)
    API->>LOG: append a executions.jsonl
    API-->>U: 200 {status: completed, final_output, ...}

7. Persistencia: cuatro formas, cuatro razones

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

Qué Cómo Por qué así
Definiciones de agentes y políticas YAML por versión + index.yaml Lo escribe un humano (legible, comentable) y lo cataloga la máquina.
Identidad de versiones / comparación hash SHA-256 del contenido + difflib Versiones inmutables identificables y comparables — "tipo Git".
Mapa trace_id → agente/versión JSON (execution_index.json) Necesario para reanudar un HITL sabiendo qué configuración lo ejecutó; debe sobrevivir a reinicios.
Log de ejecuciones terminales y de violaciones JSONL append-only Inmutable, 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.

8. Glosario

Término Significado en este proyecto
Agente Una 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 de salida y su configuración, más on_validator_error (fail_closed/fail_open).
Validador / guardrail Una 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ón El 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_path La lista de pasos por los que pasó el grafo, cada uno con su step, timestamp, duration_ms y un detail. La "caja negra" de la ejecución.
HITL (Human-in-the-Loop) El patrón de pausar la ejecución cuando hay una acción arriesgada y esperar a que un humano apruebe o rechace. Implementado con interrupt() de LangGraph.
Checkpointer El componente de LangGraph que persiste el estado del grafo (aquí, en SQLite). Lo que hace posible reanudar un HITL tras un reinicio.
trace_id UUID que identifica una petición/ejecución y se propaga por middleware → logs → API → ficheros de auditoría.
Orchestrator La única clase que habla con LangGraph (invoke/resume/snapshot); aísla al resto del sistema del runtime.
Factory Función build_X(settings) que monta la implementación de X adecuada según la configuración.
Status de ejecución running, awaiting_approval (pausada en HITL), blocked_by_guardrail (la frenó un validador), completed, failed.
Estados de un agente draft, active, deprecated (metadato en su YAML).

9. Mapa del repositorio y por dónde empezar a leer

agentforge/
├── core/
│   ├── src/agentforge_core/
│   │   ├── domain/        ← los modelos de datos (empieza por execution.py)
│   │   ├── config.py · observability/   ← cimientos transversales
│   │   ├── llm/           ← proveedores LLM (Protocol + impls + factory)
│   │   ├── registry/      ← catálogo y versionado (YAML/JSON, hash, diff)
│   │   ├── guardrails/    ← motor de validación (Protocol + composite + validators)
│   │   ├── runtime/       ← el grafo LangGraph (state, nodes, graph, checkpointer, orchestrator)
│   │   ├── api/           ← FastAPI (middlewares, deps, routers, persistence)
│   │   └── main.py        ← create_app(): ensambla la app
│   ├── Dockerfile · requirements.txt
├── dashboard/
│   ├── src/agentforge_dashboard/   ← Streamlit (client + app + pages + components)
│   ├── Dockerfile · requirements.txt
├── agents/incident_analyzer/   ← el agente de ejemplo (YAMLs + escenarios .txt)
├── policies/default/           ← la política de guardrails de ejemplo
├── data/                       ← estado runtime (gitignored)
├── tests/                      ← pytest: tests/unit/ y tests/integration/ (88 tests en total; el demo Streamlit no se testea con unit tests)
├── docs/                       ← este documento, manual_qa.md, futuro.md, superpowers/
├── docker-compose.yml          ← levanta core + dashboard
├── Makefile                    ← install / test / test-all / lint / smoke / up / down
├── README.md · ARCHITECTURE.md

Ruta de lectura sugerida (1 hora):

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

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

Es un MVP. La detección de PII más fina (modelos grandes, español), autenticación, OpenTelemetry, multi-tenant, persistencia en Postgres, evaluadores LLM-as-judge, una cola de aprobaciones con SLA... están en el roadmap: docs/futuro.md. El estado actual y cómo verificarlo: README.md y docs/manual_qa.md.