🛡️ AgentForge — 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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
02 Vista de pájaro: dos servicios
AgentForge son dos procesos que se hablan por HTTP/JSON, levantados por docker-compose:
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 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.)
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).
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/agentforge_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.
create_app(): instancia FastAPI, añade TraceIdMiddleware, expone /health, monta los 5 routers. Construye Settings() para el logging.
deps.py construye y cachea los 5 objetos del dominio (@lru_cache); los routers solo los piden por parámetro.
StateSnapshot de LangGraph a un AgentExecution. Único punto de entrada al runtime.
CompositeGuardrailEngine.
composite no conoce a sus sub-engines (solo el Protocol).
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".
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 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.| Fichero | Qué define |
|---|---|
domain/agent.py | AgentDefinition (prompt, modelo, output_schema, guardrails, risk_threshold_for_hitl…), LLMConfig, AgentVersionMeta. |
domain/policy.py | PolicyDefinition (listas de PolicyValidator de entrada y salida, on_validator_error), PolicyVersionMeta. |
domain/guardrail.py | GuardrailViolation (trace_id, stage input/output, validator, severity, message, blocked). |
domain/execution.py | AgentExecution (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).
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)
| Validador | Etapa | Qué comprueba | Severidad típica |
|---|---|---|---|
detect_pii | input | PII 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_injection | input | Heurísticas de patrones conocidos ("ignore previous instructions", "you are now", "reveal the system prompt"…). | block |
toxic_language | input | Lista negra con umbral (MVP). | warning |
forbidden_topics | input | Coincidencia de substring con temas prohibidos (instrucciones de explotación, credenciales, código malicioso). | block |
schema_match | output | Valida 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_leakage | output | Re-aplica detect_pii sobre la salida serializada. | block |
forbidden_action_keywords | output | Que las acciones propuestas no contengan comandos peligrosos (DROP TABLE, rm -rf, shutdown -h now, delete production, format c:). | block |
telco_safety_rules | output | Reglas 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):
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.
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) | Hace | Puede saltar a |
|---|---|---|
validate_inputbuild_node_validate_input(engine, policy) | Corre los input_validators de la política sobre el texto del usuario; acumula violaciones. | END si alguna blocked → status = blocked_by_guardrail |
llm_reasonbuild_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_outputbuild_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_actionsbuild_node_propose_actions() | Extrae parsed_output["proposed_actions"] al estado. | siempre → approve_gate |
approve_gatebuild_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) |
finalizebuild_node_finalize() | Si human_decision.rejected → status = 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:
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.
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)
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.
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 reject → finalize ve rejected: true → status = failed, error = rejected_by_human.El mismo recorrido, con curl (provider mock, sin claves):
# 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)
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)
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
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
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); 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) | /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 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. |
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.
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.
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 · ruta | Qué hace | Errores |
|---|---|---|
GET /health | {"status": "ok"} — usado por los healthchecks de Docker. | — |
GET /agents | Lista de AgentDefinition (la versión activa de cada agente). | — |
GET /agents/{name} | El agente (versión activa). | 404 no existe |
GET /agents/{name}/versions | Lista 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}/invoke | Lanza 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 /executions | Lista 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}/approve | Reanuda 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}/reject | Rechaza un HITL. Body {reason: str}. resume(decision={...rejected:true}) → status = failed, error = rejected_by_human. Escribe en executions.jsonl. | 404 · 409 |
GET /policies | Lista de PolicyDefinition (validadores de entrada/salida y su config). | — |
GET /policies/{name}/versions | Lista 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) | Keyword | Respuesta del mock — acciones propuestas | ¿HITL? |
|---|---|---|---|
01_sip_registration_dropcaí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 |
sí — act-1 (risk 4 ≥ umbral 4 + requires_approval) |
02_mos_degradation_pool_sbcdegradació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_activesaturación replicada en HSS active-active |
"hss" |
act-1 isolate_replication_link en hss-pair-madrid · risk 5 · requires_approval |
sí — act-1 (risk 5) |
"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ágina | Qué muestra | Endpoints que usa |
|---|---|---|
🏛️ Registropages/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} |
▶️ Ejecutarpages/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 |
🤝 Aprobacionespages/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 |
📜 Historialpages/4_…_Historial.py | Pestaña de ejecuciones (tabla + detalle por trace_id) y pestaña de violaciones (filtrable por severidad). | /executions, /executions/{t}, /violations |
📐 Politicaspages/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
- Importar
agentforge_core.mainejecutaapp = create_app():Settings()→configure_logging(level)→FastAPI(...)→add_middleware(TraceIdMiddleware)→ registraGET /health→ importa los routers →include_router(agents, executions ×2, policies, violations). - 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. - Cada request:
TraceIdMiddleware.dispatch→ router → resuelveDepends(...)(que puede disparar la construcción perezosa) → handler → respuesta conX-Trace-Id.
En contenedores: uvicorn agentforge_core.main:app --host 0.0.0.0 --port 8000; HEALTHCHECK → curl /health; monta ./agents:ro, ./policies:ro, ./data:rw; DATA_DIR=/app/data, etc. El dashboard depende de core: service_healthy y usa AGENTFORGE_CORE_URL=http://core:8000. La imagen del core instala en_core_web_sm de spaCy para Presidio.
Variables de entorno (config.py · .env)
| Var | Default | Lo usa |
|---|---|---|
LLM_PROVIDER | mock | build_llm_provider (mock|azure|openai) |
LLM_FALLBACK_PROVIDER | "" | declarado; la factory aún no lo aplica |
AZURE_OPENAI_* | "" / 2024-08-01-preview | AzureOpenAIProvider |
OPENAI_API_KEY · OPENAI_MODEL | "" · gpt-4o | OpenAIProvider |
GUARDRAILS_NEMO_ENABLED | false | build_guardrail_engine (añade NeMo al composite) |
LOG_LEVEL | INFO | configure_logging |
DATA_DIR | ./data | checkpointer · índice · JSONL |
AGENTS_DIR | ./agents | FileSystemAgentRegistry |
POLICIES_DIR | ./policies | FileSystemPolicyStore |
AGENTFORGE_CORE_URL | http://core:8000 | el dashboard (CoreClient) |
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 usantests/fixtures/(versiones mínimas) y hacendeps.*.cache_clear()tras apuntarDATA_DIRa untmp_path.tests/integration/— montan la app conTestClientcontra 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 unawaiting_approvalsobrevive 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 autocontenido — docs/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.
xdg-open docs/walkthrough.html # Linux open docs/walkthrough.html # macOS # o arrástralo al navegador · o file://<ruta-del-repo>/docs/walkthrough.html
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é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 salida y su config, 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 step, timestamp, duration_ms y un detail. La "caja negra" de la ejecución. |
| HITL | Human-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. |
| 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, completed, failed. |
| Estados de un agente | draft, 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)
domain/execution.pyydomain/agent.py— qué datos hay. (§06)policies/default/versions/v1.yamlyagents/incident_analyzer/versions/v2.yaml— cómo se declara todo. (§07, §10)runtime/nodes.pyyruntime/graph.py— el flujo de ejecución. (§08)api/executions.py— cómo se expone (invoke / approve / reject). (§14)tests/integration/test_invoke_hitl.py— el ciclo completo, en ~40 líneas. (§18)- Levanta
docker compose up, haz clic por las cinco páginas del dashboard (§16), y abredocs/walkthrough.htmlen el navegador (§19).
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.
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.
domain/agent.py—AgentDefinition,LLMConfig,AgentVersionMeta. Al terminar sabrás exactamente qué es "un agente" en este sistema: una declaración Pydantic, no código Python.domain/guardrail.py—GuardrailViolation. Qué devuelve un guardrail cuando detecta un problema: una violación tipada, no una excepción.domain/policy.py—PolicyDefinition. La lista de reglas que rigen un agente concreto.domain/execution.py— el modelo más rico:AgentExecution,ProposedAction,ExecutionStatus,DecisionStep. Contiene el estado completo de una ejecución. Fíjate en queAgentExecutiontiene definición del agente separada de su instancia en runtime.
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.
llm/base.py—LLMProvider(un métodoasync complete()),Message,CompletionResult. Dos clases Pydantic y un Protocol de cuatro líneas.guardrails/base.py—GuardrailEngine: dos métodos (validate_inputyvalidate_output), ambos devuelvenlist[GuardrailViolation].
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.
llm/mock.py— implementaLLMProviderdevolviendo un JSON hardcodeado. ~30 líneas. Permite correr el grafo completo sin tocar OpenAI ni Azure.guardrails/validators.py— validadores keyword-based simples. ImplementanGuardrailEnginesin dependencias externas.- 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).
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.
-
runtime/state.pyprimero —AgentState: unTypedDictcon 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 undecision_path. -
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 ponerblocked_by_guardrail.build_node_llm_reason— llama al provider, captura el fallo y lo transforma enstatus="failed".build_node_validate_output— parsea el JSON del LLM y valida el output contra la política.build_node_propose_actions— extraeproposed_actionsdel output parseado.build_node_approve_gate— el nodo HITL: llama ainterrupt({"awaiting_actions": risky})si hay acciones con riesgo ≥ threshold. La ejecución se pausa aquí hasta que llegue unCommand(resume=...).build_node_finalize— filtra acciones por las aprobadas, construyefinal_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. -
runtime/graph.py— conecta los nodos. Los tres routers condicionales (_after_validate_input,_after_llm,_after_validate_output) hacen que cualquier error cortocircuite haciaENDsin pasar por nodos posteriores.
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)
runtime/checkpointer.py— configura SQLite como backend de checkpoints LangGraph. Sin esto,interrupt()no puede persistir el estado entre la llamada ainvokey la llamada aapprove.runtime/orchestrator.py— el objeto que la API usará. RecibeAgentDefinition+PolicyDefinition+ providers ya construidos, compila el grafo y expone dos métodos:invoke()(nueva ejecución) yapprove()(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.
api/deps.py— el armario de cableado: los@lru_cacheque 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.api/executions.py— los tres endpoints críticos:POST /executions/invoke,POST /executions/{trace_id}/approve,GET /executions/{trace_id}.api/agents.py— CRUD del registry (listar agentes, leer versión concreta).api/middlewares.py— inyectaX-Trace-Idy bind-ea el contexto de structlog.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.
registry/repository.py— carga definiciones de agentes desde YAML, cachea en memoria.registry/versioning.py— hash SHA-256 +index.yaml: el sistema de versionado tipo-Git.registry/policy_store.py— análogo al repository, pero para políticas.registry/factory.py— el factory que elige qué implementación de registry usar segúnSettings.
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.
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.- 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 deldecision_path.pages/3_Aprobaciones.py— la más compleja: muestra acciones pendientes, permite aprobar o rechazar individual o masivamente.
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.