Files
larry/docs/walkthrough.html
T

1786 lines
154 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="es" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="Walkthrough completo de AgentForge — de alto a bajo nivel, con diagramas.">
<title>AgentForge · Walkthrough</title>
<script>
(function () {
try {
var t = localStorage.getItem('af-theme');
if (!t) t = window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
document.documentElement.setAttribute('data-theme', t);
} catch (e) { document.documentElement.setAttribute('data-theme', 'dark'); }
})();
</script>
<style>
:root, [data-theme="dark"]{
--bg:#0d1117; --bg-soft:#0f141b; --surface:#161b22; --surface-2:#1b222b; --surface-3:#212a34;
--border:#2d333b; --border-soft:#22272e; --text:#e6edf3; --text-soft:#c9d1d9; --muted:#8b949e;
--accent:#6ea8fe; --accent-2:#a78bfa; --accent-3:#f0883e; --ok:#3fb950; --warn:#d29922; --danger:#f85149; --info:#58a6ff;
--code-bg:#0b0f14; --code-border:#222b35;
--shadow:0 1px 0 rgba(0,0,0,.25), 0 8px 28px rgba(0,0,0,.35);
--radius:14px; --radius-sm:9px;
--mono:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,"Liberation Mono",monospace;
--sans:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,system-ui,sans-serif;
}
[data-theme="light"]{
--bg:#ffffff; --bg-soft:#f6f8fa; --surface:#ffffff; --surface-2:#f6f8fa; --surface-3:#eef1f4;
--border:#d0d7de; --border-soft:#e4e8ec; --text:#1f2328; --text-soft:#373d44; --muted:#636c76;
--accent:#0969da; --accent-2:#8250df; --accent-3:#bc4c00; --ok:#1a7f37; --warn:#9a6700; --danger:#cf222e; --info:#0969da;
--code-bg:#f6f8fa; --code-border:#d8dee4;
--shadow:0 1px 0 rgba(31,35,40,.04), 0 8px 24px rgba(31,35,40,.08);
}
*{box-sizing:border-box}
html{scroll-behavior:smooth}
body{
margin:0; background:var(--bg); color:var(--text); font-family:var(--sans);
font-size:16px; line-height:1.65; -webkit-font-smoothing:antialiased; text-rendering:optimizeLegibility;
}
a{color:var(--accent); text-decoration:none}
a:hover{text-decoration:underline}
code,kbd,pre{font-family:var(--mono)}
.layout{display:grid; grid-template-columns:300px minmax(0,1fr); align-items:start}
/* ---------- Sidebar ---------- */
.sidebar{
position:sticky; top:0; height:100vh; overflow:auto; padding:22px 14px 40px 22px;
border-right:1px solid var(--border-soft); background:linear-gradient(180deg,var(--bg-soft),var(--bg));
}
.brand{display:flex; gap:11px; align-items:center; margin-bottom:6px}
.brand .logo{font-size:26px; line-height:1}
.brand .t1{font-weight:700; font-size:17px; letter-spacing:.2px}
.brand .t2{font-size:11.5px; color:var(--muted); margin-top:1px}
.sidebar .meta{font-size:11.5px; color:var(--muted); margin:10px 0 16px; border-top:1px dashed var(--border); padding-top:12px}
.toc{list-style:none; margin:0; padding:0; font-size:13.5px}
.toc li{margin:1px 0}
.toc .group{margin:14px 0 5px; font-size:10.5px; letter-spacing:.13em; text-transform:uppercase; color:var(--muted); font-weight:700}
.toc a{display:block; padding:5px 10px; border-radius:7px; color:var(--text-soft); border-left:2px solid transparent}
.toc a:hover{background:var(--surface); text-decoration:none; color:var(--text)}
.toc a.active{background:var(--surface-2); color:var(--text); border-left-color:var(--accent); font-weight:600}
.toc a .n{color:var(--muted); font-variant-numeric:tabular-nums; margin-right:7px; font-size:12px}
/* ---------- Main ---------- */
main{padding:0 clamp(16px,3.4vw,52px) 120px; max-width:1080px; margin:0 auto; width:100%}
section{scroll-margin-top:18px; padding-top:34px}
section > h2{
font-size:clamp(22px,2.6vw,30px); line-height:1.25; margin:0 0 4px; padding-bottom:9px; border-bottom:1px solid var(--border-soft);
scroll-margin-top:18px; display:flex; align-items:baseline; gap:11px; flex-wrap:wrap;
}
section > h2 .kicker{font-size:12px; font-weight:700; letter-spacing:.14em; text-transform:uppercase; color:var(--accent)}
h3{font-size:18.5px; margin:30px 0 6px; line-height:1.3}
h4{font-size:15px; margin:22px 0 4px; color:var(--text-soft); letter-spacing:.01em}
p{margin:10px 0}
.lead{font-size:17.5px; color:var(--text-soft)}
.muted{color:var(--muted)}
hr{border:0; border-top:1px solid var(--border-soft); margin:34px 0}
ul,ol{padding-left:22px}
li{margin:4px 0}
strong{color:var(--text); font-weight:650}
em{color:var(--text-soft)}
/* ---------- Hero ---------- */
.hero{
margin-top:30px; padding:30px 30px 26px; border:1px solid var(--border); border-radius:var(--radius);
background:
radial-gradient(820px 280px at 88% -40%, color-mix(in srgb,var(--accent-2) 24%,transparent), transparent 70%),
radial-gradient(680px 280px at 6% 120%, color-mix(in srgb,var(--accent) 18%,transparent), transparent 70%),
linear-gradient(180deg,var(--surface),var(--bg-soft));
box-shadow:var(--shadow); position:relative; overflow:hidden;
}
.hero h1{font-size:clamp(28px,4.3vw,42px); line-height:1.12; margin:6px 0 8px; letter-spacing:-.01em}
.hero .tagline{font-size:clamp(15px,1.9vw,18.5px); color:var(--text-soft); max-width:62ch; margin:0}
.hero .crumbs{font-size:12px; color:var(--muted); margin-bottom:2px}
.badges{display:flex; flex-wrap:wrap; gap:7px; margin-top:18px}
.badge{
font-size:11.5px; padding:3.5px 10px; border-radius:999px; border:1px solid var(--border); background:var(--surface-2);
color:var(--text-soft); display:inline-flex; gap:6px; align-items:center; white-space:nowrap;
}
.badge b{color:var(--text); font-weight:650}
.badge.k{border-color:color-mix(in srgb,var(--accent) 50%,var(--border)); background:color-mix(in srgb,var(--accent) 12%,var(--surface-2))}
.hero .quick{display:flex; gap:10px; flex-wrap:wrap; margin-top:20px}
.btn{
display:inline-flex; align-items:center; gap:8px; font-size:13.5px; font-weight:600; padding:8px 14px; border-radius:9px;
border:1px solid var(--border); background:var(--surface-2); color:var(--text); cursor:pointer;
}
.btn:hover{text-decoration:none; border-color:var(--accent); color:var(--text)}
.btn.primary{background:color-mix(in srgb,var(--accent) 20%,var(--surface)); border-color:color-mix(in srgb,var(--accent) 55%,var(--border))}
/* ---------- Topbar (mobile) ---------- */
.topbar{display:none; position:sticky; top:0; z-index:30; background:color-mix(in srgb,var(--bg) 86%,transparent); backdrop-filter:blur(8px); border-bottom:1px solid var(--border-soft); padding:9px 14px; align-items:center; gap:10px}
.topbar .tt{font-weight:700; font-size:14px}
.icon-btn{border:1px solid var(--border); background:var(--surface-2); color:var(--text); border-radius:8px; width:34px; height:34px; display:inline-grid; place-items:center; cursor:pointer; font-size:15px}
/* ---------- Callouts ---------- */
.callout{border:1px solid var(--border); border-left-width:4px; border-radius:var(--radius-sm); padding:13px 16px; margin:18px 0; background:var(--surface); font-size:14.5px}
.callout .ct{font-weight:700; font-size:12px; letter-spacing:.06em; text-transform:uppercase; display:flex; gap:8px; align-items:center; margin-bottom:3px}
.callout p{margin:5px 0}
.callout.tip{border-left-color:var(--accent)} .callout.tip .ct{color:var(--accent)}
.callout.key{border-left-color:var(--accent-2); background:color-mix(in srgb,var(--accent-2) 8%,var(--surface))} .callout.key .ct{color:var(--accent-2)}
.callout.warn{border-left-color:var(--warn); background:color-mix(in srgb,var(--warn) 9%,var(--surface))} .callout.warn .ct{color:var(--warn)}
.callout.ok{border-left-color:var(--ok); background:color-mix(in srgb,var(--ok) 9%,var(--surface))} .callout.ok .ct{color:var(--ok)}
/* ---------- Tables ---------- */
.tbl-wrap{overflow:auto; margin:18px 0; border:1px solid var(--border); border-radius:var(--radius-sm)}
table{border-collapse:collapse; width:100%; font-size:13.8px}
th,td{text-align:left; padding:9px 13px; border-bottom:1px solid var(--border-soft); vertical-align:top}
thead th{background:var(--surface-2); color:var(--text); font-weight:650; position:sticky; top:0; font-size:12.5px; letter-spacing:.02em; border-bottom:1px solid var(--border)}
tbody tr:last-child td{border-bottom:0}
tbody tr:hover{background:color-mix(in srgb,var(--accent) 4%,transparent)}
td code, th code{font-size:12.5px}
/* ---------- Code ---------- */
.code{margin:18px 0; border:1px solid var(--code-border); border-radius:var(--radius-sm); background:var(--code-bg); overflow:hidden}
.code .hd{display:flex; align-items:center; gap:9px; padding:7px 12px; border-bottom:1px solid var(--code-border); background:color-mix(in srgb,var(--surface-2) 60%,var(--code-bg)); font-size:12px; color:var(--muted)}
.code .hd .dot{width:9px;height:9px;border-radius:50%;background:var(--border)}
.code .hd .fn{font-family:var(--mono); color:var(--text-soft)}
.code .hd .lang{margin-left:auto; font-size:10.5px; letter-spacing:.1em; text-transform:uppercase}
.code .cp{margin-left:8px; cursor:pointer; border:1px solid var(--code-border); border-radius:6px; padding:1px 7px; font-size:10.5px; color:var(--muted); background:transparent}
.code .cp:hover{color:var(--text); border-color:var(--border)}
.code pre{margin:0; padding:13px 15px; overflow:auto; font-size:12.9px; line-height:1.6; color:var(--text-soft)}
.code pre .c{color:var(--muted); font-style:italic} /* comment */
.code pre .k{color:var(--accent-2)} /* keyword */
.code pre .s{color:#7ee787} /* string */
[data-theme="light"] .code pre .s{color:#0a7d33}
.code pre .n{color:var(--accent-3)} /* number/literal */
.code pre .y{color:var(--accent)} /* yaml key / fn name */
.code pre .d{color:var(--danger)} /* danger token */
p code, li code, td code{background:var(--surface-2); border:1px solid var(--border-soft); padding:.5px 5px; border-radius:5px; font-size:.86em}
/* ---------- Card grid ---------- */
.cards{display:grid; grid-template-columns:repeat(auto-fit,minmax(255px,1fr)); gap:14px; margin:18px 0}
.card{border:1px solid var(--border); border-radius:var(--radius-sm); padding:15px 16px; background:var(--surface); position:relative}
.card .idx{font-size:11px; font-weight:800; color:var(--accent-2); letter-spacing:.06em}
.card h4{margin:4px 0 5px; font-size:15.5px; color:var(--text)}
.card p{margin:0; font-size:13.6px; color:var(--text-soft)}
.card .where{margin-top:9px; font-size:11.5px; color:var(--muted); font-family:var(--mono)}
/* ---------- Pills / kbd ---------- */
.pill{display:inline-block; font-size:11px; padding:1.5px 8px; border-radius:999px; border:1px solid var(--border); background:var(--surface-2); color:var(--text-soft); font-family:var(--mono)}
.pill.ok{color:var(--ok); border-color:color-mix(in srgb,var(--ok) 45%,var(--border)); background:color-mix(in srgb,var(--ok) 12%,var(--surface))}
.pill.warn{color:var(--warn); border-color:color-mix(in srgb,var(--warn) 45%,var(--border)); background:color-mix(in srgb,var(--warn) 12%,var(--surface))}
.pill.danger{color:var(--danger); border-color:color-mix(in srgb,var(--danger) 45%,var(--border)); background:color-mix(in srgb,var(--danger) 12%,var(--surface))}
.pill.accent{color:var(--accent); border-color:color-mix(in srgb,var(--accent) 45%,var(--border)); background:color-mix(in srgb,var(--accent) 12%,var(--surface))}
.pill.violet{color:var(--accent-2); border-color:color-mix(in srgb,var(--accent-2) 45%,var(--border)); background:color-mix(in srgb,var(--accent-2) 12%,var(--surface))}
/* ---------- Diagrams ---------- */
figure.diagram{margin:22px 0; border:1px solid var(--border); border-radius:var(--radius); background:
radial-gradient(700px 200px at 100% 0%, color-mix(in srgb,var(--accent) 7%,transparent), transparent 60%),
var(--surface); padding:16px 16px 8px; box-shadow:var(--shadow)}
figure.diagram svg{width:100%; height:auto; display:block}
figure.diagram figcaption{font-size:12.5px; color:var(--muted); padding:9px 4px 6px; border-top:1px dashed var(--border); margin-top:6px}
figure.diagram figcaption b{color:var(--text-soft)}
.legend{display:flex; flex-wrap:wrap; gap:7px 14px; font-size:11.5px; color:var(--muted); margin:4px 2px 12px; align-items:center}
.legend .li{display:inline-flex; align-items:center; gap:6px}
.legend .sw{width:13px; height:13px; border-radius:4px; border:1px solid var(--border)}
.sw.box{background:var(--surface); border-color:var(--border)}
.sw.accent{background:color-mix(in srgb,var(--accent) 16%,var(--surface)); border-color:var(--accent)}
.sw.violet{background:color-mix(in srgb,var(--accent-2) 16%,var(--surface)); border-color:var(--accent-2)}
.sw.warn{background:color-mix(in srgb,var(--warn) 18%,var(--surface)); border-color:var(--warn)}
.sw.danger{background:color-mix(in srgb,var(--danger) 16%,var(--surface)); border-color:var(--danger)}
.sw.ok{background:color-mix(in srgb,var(--ok) 16%,var(--surface)); border-color:var(--ok)}
/* SVG element classes */
.dg-frame{fill:transparent; stroke:var(--border); stroke-dasharray:7 6}
.dg-frame-lbl{fill:var(--muted); font:700 12px var(--sans); letter-spacing:.06em}
.dg-box{fill:var(--surface); stroke:var(--border); stroke-width:1.4}
.dg-box.soft{fill:var(--surface-2)}
.dg-box.accent{stroke:var(--accent); fill:color-mix(in srgb,var(--accent) 11%,var(--surface))}
.dg-box.violet{stroke:var(--accent-2); fill:color-mix(in srgb,var(--accent-2) 11%,var(--surface))}
.dg-box.ok{stroke:var(--ok); fill:color-mix(in srgb,var(--ok) 12%,var(--surface))}
.dg-box.warn{stroke:var(--warn); fill:color-mix(in srgb,var(--warn) 13%,var(--surface))}
.dg-box.danger{stroke:var(--danger); fill:color-mix(in srgb,var(--danger) 11%,var(--surface))}
.dg-box.dashed{stroke-dasharray:6 5}
.dg-t{fill:var(--text); font:650 14px var(--sans)}
.dg-t.sm{font-size:12.5px; font-weight:600}
.dg-s{fill:var(--muted); font:400 11.5px var(--sans)}
.dg-m{fill:var(--text-soft); font:400 11.5px var(--mono)}
.dg-m.dim{fill:var(--muted); font-size:11px}
.dg-edge{stroke:var(--muted); stroke-width:1.7; fill:none}
.dg-edge.accent{stroke:var(--accent)} .dg-edge.violet{stroke:var(--accent-2)} .dg-edge.ok{stroke:var(--ok)}
.dg-edge.danger{stroke:var(--danger)} .dg-edge.warn{stroke:var(--warn)}
.dg-edge.dashed{stroke-dasharray:6 5} .dg-edge.thin{stroke-width:1.2}
.dg-el{fill:var(--muted); font:600 11px var(--sans)}
.dg-el.bg{paint-order:stroke; stroke:var(--surface); stroke-width:4px; stroke-linejoin:round}
.dg-life{stroke:var(--border); stroke-width:1.3; stroke-dasharray:3 4}
.dg-actor{fill:var(--surface-2); stroke:var(--border); stroke-width:1.3}
.dg-band{fill:color-mix(in srgb,var(--accent-2) 7%,transparent)}
.dg-divider{stroke:var(--border); stroke-dasharray:5 5; stroke-width:1.2}
/* ---------- CSS-flow diagrams ---------- */
.flow{display:flex; align-items:stretch; gap:0; flex-wrap:wrap; margin:18px 0; counter-reset:fl}
.flow .step{flex:1 1 150px; min-width:140px; border:1px solid var(--border); background:var(--surface); border-radius:var(--radius-sm); padding:12px 13px; position:relative}
.flow .step .st{font-size:13.5px; font-weight:650}
.flow .step .sd{font-size:11.5px; color:var(--muted); margin-top:2px; font-family:var(--mono)}
.flow .arr{display:flex; align-items:center; padding:0 7px; color:var(--muted); font-size:20px}
.flow.v{flex-direction:column; align-items:stretch}
.flow.v .arr{justify-content:center; padding:5px 0; transform:rotate(90deg); width:max-content; align-self:center}
.bands{margin:18px 0; border:1px solid var(--border); border-radius:var(--radius); overflow:hidden}
.band{display:grid; grid-template-columns:96px 1fr; border-bottom:1px solid var(--border-soft)}
.band:last-child{border-bottom:0}
.band:nth-child(odd){background:var(--surface)}
.band:nth-child(even){background:var(--surface-2)}
.band .lvl{display:flex; flex-direction:column; align-items:center; justify-content:center; gap:2px; background:color-mix(in srgb,var(--accent) 9%,var(--surface)); border-right:1px solid var(--border-soft); font-size:11px; color:var(--muted); padding:8px 6px; text-align:center}
.band .lvl b{font-size:17px; color:var(--accent); line-height:1}
.band .mods{display:flex; flex-wrap:wrap; gap:6px; padding:11px 13px; align-items:center}
.band .desc{flex-basis:100%; font-size:11.5px; color:var(--muted); margin-top:1px}
.mod{font-family:var(--mono); font-size:11.8px; padding:3px 8px; border-radius:6px; border:1px solid var(--border); background:var(--bg-soft); color:var(--text-soft)}
.mod.core{border-color:var(--accent); color:var(--accent)}
/* ---------- two-col ---------- */
.cols{display:grid; grid-template-columns:1fr 1fr; gap:18px; margin:18px 0}
.cols > div{min-width:0}
@media (max-width:760px){.cols{grid-template-columns:1fr}}
/* ---------- details ---------- */
details.dd{border:1px solid var(--border); border-radius:var(--radius-sm); margin:16px 0; background:var(--surface); overflow:hidden}
details.dd > summary{cursor:pointer; padding:11px 15px; font-weight:650; font-size:14px; list-style:none; display:flex; align-items:center; gap:9px; background:var(--surface-2)}
details.dd > summary::-webkit-details-marker{display:none}
details.dd > summary::before{content:"▸"; color:var(--muted); transition:transform .15s}
details.dd[open] > summary::before{transform:rotate(90deg)}
details.dd .body{padding:4px 16px 14px}
/* ---------- back to top ---------- */
#totop{position:fixed; right:18px; bottom:18px; width:42px; height:42px; border-radius:50%; border:1px solid var(--border); background:var(--surface); color:var(--text); cursor:pointer; box-shadow:var(--shadow); display:none; place-items:center; font-size:18px; z-index:40}
#totop.show{display:grid}
/* ---------- footer ---------- */
.footer{margin-top:60px; padding-top:22px; border-top:1px solid var(--border-soft); font-size:13px; color:var(--muted)}
/* ---------- responsive ---------- */
@media (max-width:980px){
.layout{grid-template-columns:1fr}
.topbar{display:flex}
.sidebar{position:fixed; left:0; top:0; bottom:0; width:300px; max-width:84vw; z-index:50; transform:translateX(-105%); transition:transform .22s ease; box-shadow:var(--shadow)}
.sidebar.open{transform:none}
.scrim{position:fixed; inset:0; background:rgba(0,0,0,.5); z-index:45; display:none}
.scrim.show{display:block}
main{padding-top:6px}
}
@media (min-width:981px){ .topbar,.scrim{display:none} }
@media print{
.sidebar,.topbar,#totop,.code .cp,.scrim{display:none!important}
.layout{grid-template-columns:1fr}
body{font-size:11pt}
figure.diagram,.code,.tbl-wrap,.callout,details.dd{break-inside:avoid}
section{padding-top:14px}
}
</style>
</head>
<body>
<div class="topbar">
<button class="icon-btn" id="menuBtn" aria-label="Abrir índice"></button>
<span class="tt">🛡️ AgentForge · Walkthrough</span>
<button class="icon-btn" id="themeBtnM" aria-label="Cambiar tema" style="margin-left:auto"></button>
</div>
<div class="scrim" id="scrim"></div>
<div class="layout">
<!-- ============ SIDEBAR ============ -->
<aside class="sidebar" id="sidebar">
<div class="brand">
<span class="logo">🛡️</span>
<span>
<span class="t1">AgentForge</span>
<span class="t2">Walkthrough · de alto a bajo nivel</span>
</span>
</div>
<div style="display:flex; gap:8px; margin-top:6px">
<button class="btn" id="themeBtn" style="flex:1; justify-content:center">◐ Tema</button>
<button class="btn" onclick="window.print()" style="flex:1; justify-content:center">⎙ Imprimir</button>
</div>
<div class="meta">HTML autocontenido · <code>docs/walkthrough.html</code> · AgentForge v0.1.0</div>
<nav>
<ul class="toc" id="toc">
<li class="group">Panorama</li>
<li><a href="#intro"><span class="n">00</span>Qué es y por qué</a></li>
<li><a href="#ideas"><span class="n">01</span>Las seis ideas grandes</a></li>
<li><a href="#vista"><span class="n">02</span>Vista de pájaro: 2 servicios</a></li>
<li><a href="#capas"><span class="n">03</span>Las capas del core</a></li>
<li class="group">Cómo encaja</li>
<li><a href="#modulos"><span class="n">04</span>Grafo de módulos</a></li>
<li><a href="#patron"><span class="n">05</span>Strategy + Factory + Settings</a></li>
<li><a href="#dominio"><span class="n">06</span>El dominio (modelo de datos)</a></li>
<li><a href="#guardrails"><span class="n">07</span>Guardrails: el motor</a></li>
<li><a href="#runtime"><span class="n">08</span>El grafo de ejecución</a></li>
<li><a href="#viaje"><span class="n">09</span>El viaje de una petición</a></li>
<li class="group">Bajo nivel</li>
<li><a href="#versionado"><span class="n">10</span>Versionado tipo Git</a></li>
<li><a href="#persistencia"><span class="n">11</span>Persistencia: 4 formas</a></li>
<li><a href="#observabilidad"><span class="n">12</span>Trazabilidad</a></li>
<li><a href="#estados"><span class="n">13</span>Estados de una ejecución</a></li>
<li><a href="#api"><span class="n">14</span>Referencia de la API</a></li>
<li><a href="#agente"><span class="n">15</span>El agente de ejemplo</a></li>
<li><a href="#dashboard"><span class="n">16</span>El dashboard</a></li>
<li class="group">Operar y leer</li>
<li><a href="#arranque"><span class="n">17</span>Arranque y configuración</a></li>
<li><a href="#tests"><span class="n">18</span>Tests</a></li>
<li><a href="#endpoint"><span class="n">19</span>Este documento</a></li>
<li><a href="#glosario"><span class="n">20</span>Glosario</a></li>
<li><a href="#roadmap"><span class="n">21</span>Qué no hace (a propósito)</a></li>
<li><a href="#leer"><span class="n">22</span>Por dónde empezar a leer</a></li>
<li><a href="#codificar"><span class="n">23</span>Ruta de codificación manual</a></li>
</ul>
</nav>
</aside>
<!-- ============ MAIN ============ -->
<main>
<header class="hero">
<div class="crumbs">Plataforma de gobernanza de agentes IA · documento generado para entender el proyecto completo</div>
<h1>🛡️ AgentForge — Walkthrough</h1>
<p class="tagline">Catalogación y versionado de agentes y políticas, guardrails en runtime, ejecución <em>stateful</em> con Human-in-the-Loop, observabilidad y trazabilidad de extremo a extremo. Aquí está todo: <strong>de la vista de pájaro al cableado de cada módulo</strong>, con diagramas.</p>
<div class="badges">
<span class="badge k">🐍 <b>Python 3.11+</b></span>
<span class="badge"><b>FastAPI</b> · core :8000</span>
<span class="badge">📊 <b>Streamlit</b> · dashboard :8501</span>
<span class="badge">🔀 <b>LangGraph</b> · runtime stateful</span>
<span class="badge">🛡️ <b>Guardrails-AI + Presidio</b></span>
<span class="badge">📦 <b>Pydantic v2</b> · dominio</span>
<span class="badge">🗃️ YAML · JSON · JSONL · SQLite</span>
<span class="badge">🐳 <b>docker-compose</b></span>
</div>
<div class="quick">
<a class="btn primary" href="#ideas">▶ Empezar por las 6 ideas</a>
<a class="btn" href="#runtime">El grafo de ejecución</a>
<a class="btn" href="#viaje">El viaje de una petición</a>
<a class="btn" href="#leer">Ruta de lectura (1 h)</a>
</div>
</header>
<!-- ============================================================= -->
<section id="intro">
<h2><span class="kicker">00</span> Qué es y por qué</h2>
<p class="lead">Poner agentes de IA en producción <strong>sin una capa de gobierno</strong> 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.</p>
<p><strong>AgentForge es el "plano de control" que pones <em>delante</em> de tus agentes</strong> antes de dejarlos tocar nada importante. No es un framework para <em>construir</em> agentes; es la capa que los <strong>cataloga, versiona, valida, ejecuta de forma supervisada y audita</strong>.</p>
<div class="callout key">
<div class="ct">🧭 El caso de ejemplo del repo</div>
<p>Un agente de operaciones de telco — <code>incident_analyzer</code>: 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 <strong>análisis de riesgo</strong> y <strong>plan de rollback</strong>. Las acciones de riesgo alto quedan <strong>pausadas esperando aprobación humana</strong>. Todo queda registrado con un <code>trace_id</code>.</p>
</div>
<p>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 <code>README.md</code>; si quieres la narrativa completa, <code>docs/explicacion.md</code>; si quieres firmas exactas y cadenas de llamada, <code>docs/componentes.md</code>.</p>
</section>
<!-- ============================================================= -->
<section id="ideas">
<h2><span class="kicker">01</span> Las seis ideas grandes</h2>
<p>Si entiendes estas seis ideas, entiendes el proyecto. Todo lo demás son detalles de implementación.</p>
<div class="cards">
<div class="card">
<div class="idx">IDEA 1</div>
<h4>Agentes y políticas como ficheros declarativos, versionados como Git</h4>
<p>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.</p>
<div class="where">agents/ · policies/ · registry/</div>
</div>
<div class="card">
<div class="idx">IDEA 2</div>
<h4>Los guardrails son una <em>política</em>, no código disperso</h4>
<p>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.</p>
<div class="where">policies/ · guardrails/</div>
</div>
<div class="card">
<div class="idx">IDEA 3</div>
<h4>La ejecución del agente es un grafo de estados con checkpoints</h4>
<p>No es "llama al LLM y ya": validar entrada → razonar → validar salida → proponer acciones → puerta de aprobación → finalizar. Cada paso se persiste.</p>
<div class="where">runtime/ (LangGraph)</div>
</div>
<div class="card">
<div class="idx">IDEA 4</div>
<h4>Human-in-the-Loop de verdad</h4>
<p>Si el agente propone algo arriesgado, el grafo <strong>se pausa</strong> 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.</p>
<div class="where">nodo approve_gate · /approve · /reject</div>
</div>
<div class="card">
<div class="idx">IDEA 5</div>
<h4>Trazabilidad obligatoria</h4>
<p>Cada petición lleva un <code>trace_id</code> (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 <code>decision_path</code> con su duración.</p>
<div class="where">observability/ · domain/execution.py</div>
</div>
<div class="card">
<div class="idx">IDEA 6</div>
<h4>Todo lo "intercambiable" está detrás de una interfaz + un factory</h4>
<p>El proveedor de LLM, el motor de guardrails, el registry… son <code>Protocol</code>s con varias implementaciones. Un factory elige cuál según la configuración. Cambias <code>.env</code>, no el código.</p>
<div class="where">llm/ · guardrails/ · registry/ (los factory.py)</div>
</div>
</div>
</section>
<!-- ============================================================= -->
<section id="vista">
<h2><span class="kicker">02</span> Vista de pájaro: dos servicios</h2>
<p>AgentForge son <strong>dos procesos</strong> que se hablan por HTTP/JSON, levantados por <code>docker-compose</code>:</p>
<ul>
<li><strong><code>agentforge-core</code></strong> (FastAPI, puerto <strong>8000</strong>) — todo el dominio: registry de agentes, motor de guardrails, runtime de ejecución, persistencia. <em>No tiene UI.</em></li>
<li><strong><code>agentforge-dashboard</code></strong> (Streamlit, puerto <strong>8501</strong>) — una consola visual con cinco páginas. <strong>No contiene lógica de negocio</strong>: es un cliente HTTP del core.</li>
</ul>
<p>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.)</p>
<div class="legend">
<span class="li"><span class="sw box"></span> servicio / módulo</span>
<span class="li"><span class="sw accent"></span> componente de entrada</span>
<span class="li"><span class="sw violet"></span> componente interno clave</span>
<span class="li"><span class="sw ok"></span> añadido por este walkthrough</span>
<span class="li">→ flujo de datos / dependencia</span>
</div>
<figure class="diagram">
<svg viewBox="0 0 1000 560" role="img" aria-label="Diagrama de los dos servicios y el interior del core">
<defs><marker id="ah1" markerWidth="10" markerHeight="10" refX="7.5" refY="4" orient="auto-start-reverse"><path d="M0,0 L8.5,4 L0,8 Z" fill="context-stroke"/></marker></defs>
<rect class="dg-frame" x="12" y="12" width="976" height="536" rx="18"/>
<text class="dg-frame-lbl" x="34" y="38">docker-compose · dos contenedores</text>
<!-- dashboard -->
<rect class="dg-box accent" x="56" y="60" width="300" height="106" rx="13"/>
<text class="dg-t" x="206" y="92" text-anchor="middle">agentforge-dashboard</text>
<text class="dg-s" x="206" y="112" text-anchor="middle">Streamlit · :8501 · "la consola"</text>
<text class="dg-m dim" x="206" y="132" text-anchor="middle">5 páginas · sin lógica de negocio</text>
<text class="dg-m dim" x="206" y="150" text-anchor="middle">CoreClient (httpx) → habla solo HTTP</text>
<!-- core -->
<rect class="dg-box violet" x="600" y="60" width="344" height="106" rx="13"/>
<text class="dg-t" x="772" y="92" text-anchor="middle">agentforge-core</text>
<text class="dg-s" x="772" y="112" text-anchor="middle">FastAPI · :8000 · "el cerebro"</text>
<text class="dg-m dim" x="772" y="132" text-anchor="middle">dominio · runtime · guardrails · persistencia</text>
<text class="dg-m dim" x="772" y="150" text-anchor="middle">/health · /agents · /executions · /policies · /violations</text>
<!-- arrow between -->
<line class="dg-edge accent" x1="356" y1="105" x2="600" y2="105" marker-end="url(#ah1)" marker-start="url(#ah1)"/>
<text class="dg-el bg" x="478" y="96" text-anchor="middle">HTTP / JSON</text>
<text class="dg-el bg" x="478" y="128" text-anchor="middle" fill="var(--muted)" style="font-weight:400">el dashboard es cliente del core</text>
<!-- walkthrough note -->
<rect class="dg-box ok dashed" x="600" y="190" width="344" height="40" rx="9"/>
<text class="dg-m" x="772" y="207" text-anchor="middle" fill="var(--ok)">docs/walkthrough.html</text>
<text class="dg-s" x="772" y="223" text-anchor="middle">este documento — HTML autocontenido, sin servidor</text>
<!-- core internals title -->
<text class="dg-frame-lbl" x="56" y="274" fill="var(--muted)">Dentro del core · todo enchufado una vez en api/deps.py</text>
<!-- 3 sublayers -->
<rect class="dg-box" x="56" y="292" width="278" height="98" rx="12"/>
<text class="dg-t sm" x="195" y="320" text-anchor="middle">capa LLM</text>
<text class="dg-s" x="195" y="340" text-anchor="middle">Strategy + factory</text>
<text class="dg-m dim" x="195" y="360" text-anchor="middle">mock · azure · openai</text>
<text class="dg-m dim" x="195" y="377" text-anchor="middle">¿qué dice el LLM?</text>
<rect class="dg-box" x="361" y="292" width="278" height="98" rx="12"/>
<text class="dg-t sm" x="500" y="320" text-anchor="middle">capa Guardrails</text>
<text class="dg-s" x="500" y="340" text-anchor="middle">Strategy + factory</text>
<text class="dg-m dim" x="500" y="360" text-anchor="middle">Composite( GuardrailsAI [, NeMo] )</text>
<text class="dg-m dim" x="500" y="377" text-anchor="middle">¿pasa los filtros?</text>
<rect class="dg-box" x="666" y="292" width="278" height="98" rx="12"/>
<text class="dg-t sm" x="805" y="320" text-anchor="middle">runtime LangGraph</text>
<text class="dg-s" x="805" y="340" text-anchor="middle">grafo + checkpointer</text>
<text class="dg-m dim" x="805" y="360" text-anchor="middle">orchestrator: invoke · resume · snapshot</text>
<text class="dg-m dim" x="805" y="377" text-anchor="middle">la ejecución, paso a paso</text>
<!-- core -> sublayers -->
<path class="dg-edge violet" d="M 700 166 C 600 230, 320 240, 220 292" fill="none" marker-end="url(#ah1)"/>
<path class="dg-edge violet" d="M 760 166 C 700 230, 540 245, 500 292" fill="none" marker-end="url(#ah1)"/>
<path class="dg-edge violet" d="M 820 166 C 850 230, 850 245, 805 292" fill="none" marker-end="url(#ah1)"/>
<!-- persistence -->
<rect class="dg-box soft" x="56" y="436" width="888" height="86" rx="13"/>
<text class="dg-t sm" x="500" y="463" text-anchor="middle">Persistencia — la herramienta adecuada para cada cosa</text>
<text class="dg-m" x="500" y="486" text-anchor="middle">YAML (definiciones) · JSON (índice trace_id→agente) · JSONL append-only (logs de auditoría) · SQLite (checkpoints HITL)</text>
<text class="dg-m dim" x="500" y="505" text-anchor="middle">agents/*.yaml · policies/*.yaml · data/execution_index.json · data/executions.jsonl · data/violations.jsonl · data/checkpoints.sqlite</text>
<line class="dg-edge thin" x1="195" y1="390" x2="195" y2="436" marker-end="url(#ah1)"/>
<line class="dg-edge thin" x1="500" y1="390" x2="500" y2="436" marker-end="url(#ah1)"/>
<line class="dg-edge thin" x1="805" y1="390" x2="805" y2="436" marker-end="url(#ah1)"/>
</svg>
<figcaption><b>Figura 1.</b> 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.</figcaption>
</figure>
</section>
<!-- ============================================================= -->
<section id="capas">
<h2><span class="kicker">03</span> Las capas del core, de fuera hacia dentro</h2>
<p>Una petición HTTP atraviesa estas capas en orden. Cada una solo conoce a la de dentro; <strong>nada de dentro conoce a las de fuera</strong> (eso es lo que mantiene el sistema desacoplado y testeable).</p>
<div class="flow v">
<div class="step"><div class="st">① HTTP request</div><div class="sd">POST /agents/{name}/invoke · {input: "…"}</div></div>
<div class="arr"></div>
<div class="step"><div class="st">② TraceIdMiddleware</div><div class="sd">api/middlewares.py · genera/lee X-Trace-Id, lo bind-ea a structlog</div></div>
<div class="arr"></div>
<div class="step"><div class="st">③ Router</div><div class="sd">api/agents.py · api/executions.py · api/policies.py · api/violations.py</div></div>
<div class="arr"></div>
<div class="step"><div class="st">④ Dependencias (DI)</div><div class="sd">api/deps.py · @lru_cache: Registry, PolicyStore, LLMProvider, GuardrailEngine, Orchestrator (singletons)</div></div>
<div class="arr"></div>
<div class="step"><div class="st">⑤ AgentOrchestrator</div><div class="sd">runtime/orchestrator.py · única puerta al runtime: invoke / resume / snapshot</div></div>
<div class="arr"></div>
<div class="step"><div class="st">⑥ Grafo LangGraph</div><div class="sd">runtime/graph.py · cablea nodos + aristas condicionales, compila con checkpointer</div></div>
<div class="arr"></div>
<div class="step"><div class="st">⑦ Nodos</div><div class="sd">runtime/nodes.py · validate_input → llm_reason → validate_output → propose_actions → approve_gate → finalize</div></div>
<div class="arr"></div>
<div class="step"><div class="st">⑧ Adentro: llm/ · guardrails/ · domain/</div><div class="sd">¿qué dice el LLM? · ¿pasa los filtros? · ¿qué forma tienen los datos?</div></div>
<div class="arr"></div>
<div class="step"><div class="st">⑨ Persistencia</div><div class="sd">checkpointer (SQLite) + api/persistence.py (JSONL) + registry (YAML/JSON)</div></div>
</div>
<div class="callout tip">
<div class="ct">🔒 Regla de oro</div>
<p>Solo <code>api/</code> importa de <code>runtime.orchestrator</code> (y <code>deps.py</code> lo construye). El resto de <code>api/</code> no toca LangGraph; <code>runtime/</code> no toca <code>api/</code>. Solo los <code>factory.py</code> y <code>main.py</code> conocen <code>Settings</code>. <code>domain/</code> no importa nada del proyecto.</p>
</div>
</section>
<!-- ============================================================= -->
<section id="modulos">
<h2><span class="kicker">04</span> El grafo de módulos (quién depende de quién)</h2>
<p>El código del core vive bajo <code>core/src/agentforge_core/</code>. Es un <strong>DAG</strong>: 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.</p>
<div class="bands">
<div class="band">
<div class="lvl"><b>6</b><span>app</span></div>
<div class="mods">
<span class="mod core">main</span>
<span class="desc"><code>create_app()</code>: instancia FastAPI, añade <code>TraceIdMiddleware</code>, expone <code>/health</code>, monta los 5 routers. Construye <code>Settings()</code> para el logging.</span>
</div>
</div>
<div class="band">
<div class="lvl"><b>5</b><span>HTTP</span></div>
<div class="mods">
<span class="mod core">api.deps</span><span class="mod core">api.agents</span><span class="mod core">api.executions</span><span class="mod core">api.policies</span><span class="mod core">api.violations</span>
<span class="desc"><code>deps.py</code> construye y cachea los 5 objetos del dominio (<code>@lru_cache</code>); los routers solo los <em>piden</em> por parámetro.</span>
</div>
</div>
<div class="band">
<div class="lvl"><b>4</b><span>runtime</span></div>
<div class="mods">
<span class="mod core">runtime.orchestrator</span>
<span class="desc">Compone el grafo + el checkpointer; traduce el <code>StateSnapshot</code> de LangGraph a un <code>AgentExecution</code>. Único punto de entrada al runtime.</span>
</div>
</div>
<div class="band">
<div class="lvl"><b>3</b></div>
<div class="mods">
<span class="mod core">guardrails.factory</span><span class="mod core">runtime.nodes</span><span class="mod core">runtime.graph</span>
<span class="desc">Las funciones-nodo (parametrizadas con engine/policy/provider/agent) y el cableado del grafo; la factory que ensambla el <code>CompositeGuardrailEngine</code>.</span>
</div>
</div>
<div class="band">
<div class="lvl"><b>2</b></div>
<div class="mods">
<span class="mod core">llm.factory</span><span class="mod core">registry.factory</span><span class="mod core">guardrails.guardrails_ai</span><span class="mod core">guardrails.composite</span><span class="mod core">guardrails.nemo</span>
<span class="desc">Las factories de LLM y registry; los engines concretos de guardrails. <code>composite</code> no conoce a sus sub-engines (solo el <code>Protocol</code>).</span>
</div>
</div>
<div class="band">
<div class="lvl"><b>1</b></div>
<div class="mods">
<span class="mod core">domain.execution</span><span class="mod core">llm.mock</span><span class="mod core">llm.azure</span><span class="mod core">llm.openai</span><span class="mod core">registry.repository</span><span class="mod core">registry.policy_store</span><span class="mod core">guardrails.validators</span><span class="mod core">guardrails.base</span><span class="mod core">api.persistence</span><span class="mod core">api.middlewares</span>
<span class="desc">Implementaciones e interfaces concretas que dependen solo del nivel 0.</span>
</div>
</div>
<div class="band">
<div class="lvl"><b>0</b><span>vocab</span></div>
<div class="mods">
<span class="mod core">config</span><span class="mod core">observability.logging</span><span class="mod core">domain.agent</span><span class="mod core">domain.policy</span><span class="mod core">domain.guardrail</span><span class="mod core">registry.versioning</span><span class="mod core">llm.base</span><span class="mod core">runtime.state</span><span class="mod core">runtime.checkpointer</span>
<span class="desc">No importan <em>nada</em> del proyecto. <code>domain/</code> es el idioma común; todo lo demás depende de él.</span>
</div>
</div>
</div>
<p class="muted">El paquete <code>dashboard/</code> no aparece aquí: no comparte código con el core, solo lo llama por HTTP.</p>
</section>
<!-- ============================================================= -->
<section id="patron">
<h2><span class="kicker">05</span> El patrón que se repite: <code>Protocol</code> + <code>factory</code> + <code>Settings</code></h2>
<p>Tres veces (LLM, guardrails, registry) verás la misma estructura. Permite <strong>cambiar el comportamiento sin tocar el código</strong>: pones <code>LLM_PROVIDER=azure</code> en <code>.env</code> y el factory te da el provider de Azure; pones <code>mock</code> y tienes un demo determinista sin claves. Es el principio de <em>"configuración antes que código"</em>.</p>
<figure class="diagram">
<svg viewBox="0 0 1000 360" role="img" aria-label="El patrón Strategy + Factory + Settings">
<defs><marker id="ah2" markerWidth="10" markerHeight="10" refX="7.5" refY="4" orient="auto-start-reverse"><path d="M0,0 L8.5,4 L0,8 Z" fill="context-stroke"/></marker></defs>
<!-- generic -->
<text class="dg-frame-lbl" x="30" y="28" fill="var(--muted)">La forma genérica</text>
<rect class="dg-box accent" x="30" y="40" width="190" height="46" rx="9"/>
<text class="dg-t sm" x="125" y="60" text-anchor="middle">base.py</text>
<text class="dg-s" x="125" y="76" text-anchor="middle">un Protocol (la interfaz)</text>
<rect class="dg-box" x="30" y="120" width="120" height="42" rx="8"/><text class="dg-m" x="90" y="146" text-anchor="middle">impl_a</text>
<rect class="dg-box" x="160" y="120" width="120" height="42" rx="8"/><text class="dg-m" x="220" y="146" text-anchor="middle">impl_b</text>
<rect class="dg-box" x="290" y="120" width="120" height="42" rx="8"/><text class="dg-m" x="350" y="146" text-anchor="middle">impl_c</text>
<rect class="dg-box violet" x="120" y="210" width="200" height="46" rx="9"/>
<text class="dg-t sm" x="220" y="230" text-anchor="middle">factory.py</text>
<text class="dg-s" x="220" y="246" text-anchor="middle">build_X(settings) → X</text>
<rect class="dg-box soft dashed" x="30" y="290" width="380" height="40" rx="9"/>
<text class="dg-m" x="220" y="315" text-anchor="middle">Settings (pydantic-settings ← .env) — el único que sabe de env vars</text>
<line class="dg-edge thin" x1="125" y1="86" x2="90" y2="120" marker-end="url(#ah2)"/>
<line class="dg-edge thin" x1="125" y1="86" x2="220" y2="120" marker-end="url(#ah2)"/>
<line class="dg-edge thin" x1="125" y1="86" x2="350" y2="120" marker-end="url(#ah2)"/>
<line class="dg-edge violet thin" x1="220" y1="162" x2="220" y2="210" marker-end="url(#ah2)"/>
<line class="dg-edge thin dashed" x1="220" y1="290" x2="220" y2="256" marker-end="url(#ah2)"/>
<text class="dg-el bg" x="285" y="186" text-anchor="middle">elige y monta uno</text>
<!-- divider -->
<line class="dg-divider" x1="470" y1="20" x2="470" y2="340"/>
<!-- 3 instances -->
<text class="dg-frame-lbl" x="510" y="28" fill="var(--muted)">Las tres instancias reales</text>
<rect class="dg-box accent" x="510" y="44" width="450" height="78" rx="10"/>
<text class="dg-t sm" x="525" y="66">LLM — llm/base.py: LLMProvider</text>
<text class="dg-m" x="525" y="86">MockProvider · AzureOpenAIProvider · OpenAIProvider</text>
<text class="dg-s" x="525" y="104">build_llm_provider(settings) → match settings.llm_provider · usado por el nodo llm_reason</text>
<rect class="dg-box violet" x="510" y="134" width="450" height="78" rx="10"/>
<text class="dg-t sm" x="525" y="156">Guardrails — guardrails/base.py: GuardrailEngine</text>
<text class="dg-m" x="525" y="176">GuardrailsAIEngine · NeMoGuardrailsEngine (opcional) · vía CompositeGuardrailEngine</text>
<text class="dg-s" x="525" y="194">build_guardrail_engine(settings) → NeMo solo si GUARDRAILS_NEMO_ENABLED</text>
<rect class="dg-box" x="510" y="224" width="450" height="78" rx="10"/>
<text class="dg-t sm" x="525" y="246">Registry — registry/repository.py · registry/policy_store.py</text>
<text class="dg-m" x="525" y="266">FileSystemAgentRegistry · FileSystemPolicyStore (hoy ficheros; mañana ¿BD?)</text>
<text class="dg-s" x="525" y="284">build_agent_registry(settings) · build_policy_store(settings)</text>
</svg>
<figcaption><b>Figura 2.</b> Izquierda: la forma genérica (interfaz <code>Protocol</code> → N implementaciones → un <code>factory</code> que elige según <code>Settings</code>). Derecha: las tres instancias concretas. Todo se enchufa <strong>una sola vez</strong> al arrancar, en <code>api/deps.py</code> (los <code>@lru_cache</code>).</figcaption>
</figure>
</section>
<!-- ============================================================= -->
<section id="dominio">
<h2><span class="kicker">06</span> El dominio — el vocabulario del sistema</h2>
<p>Modelos Pydantic puros bajo <code>domain/</code>. <strong>No dependen de nada del proyecto</strong>; todo lo demás depende de ellos. Si quieres entender el sistema rápido, empieza leyendo <code>domain/execution.py</code>: te dice exactamente qué información se produce y se guarda.</p>
<figure class="diagram">
<svg viewBox="0 0 1080 600" role="img" aria-label="Modelo de datos del dominio">
<defs><marker id="ah3" markerWidth="10" markerHeight="10" refX="7.5" refY="4" orient="auto-start-reverse"><path d="M0,0 L8.5,4 L0,8 Z" fill="context-stroke"/></marker></defs>
<!-- AgentDefinition -->
<rect class="dg-box accent" x="24" y="40" width="300" height="190" rx="11"/>
<text class="dg-t sm" x="40" y="64" fill="var(--accent)">AgentDefinition · agent.py</text>
<line x1="36" y1="74" x2="312" y2="74" stroke="var(--border)"/>
<text class="dg-m" x="40" y="94">name · version · owner · purpose</text>
<text class="dg-m" x="40" y="113">state: draft | active | deprecated</text>
<text class="dg-m" x="40" y="132">guardrails: list[str] → nombres de política</text>
<text class="dg-m" x="40" y="151">llm: LLMConfig</text>
<text class="dg-m" x="40" y="170">system_prompt: str</text>
<text class="dg-m" x="40" y="189">output_schema: dict (JSON Schema)</text>
<text class="dg-m" x="40" y="208">risk_threshold_for_hitl: int (1..5)</text>
<text class="dg-m dim" x="40" y="225">updated_at</text>
<!-- LLMConfig -->
<rect class="dg-box" x="24" y="252" width="146" height="92" rx="10"/>
<text class="dg-t sm" x="32" y="274">LLMConfig</text>
<text class="dg-m dim" x="32" y="293">provider</text><text class="dg-m dim" x="32" y="309">model</text>
<text class="dg-m dim" x="32" y="325">temperature</text><text class="dg-m dim" x="32" y="341">max_tokens</text>
<!-- AgentVersionMeta -->
<rect class="dg-box" x="180" y="252" width="144" height="92" rx="10"/>
<text class="dg-t sm" x="188" y="274">AgentVersionMeta</text>
<text class="dg-m dim" x="188" y="293">id · hash</text><text class="dg-m dim" x="188" y="309">author</text>
<text class="dg-m dim" x="188" y="325">message</text><text class="dg-m dim" x="188" y="341">created_at</text>
<line class="dg-edge thin" x1="84" y1="230" x2="84" y2="252" marker-end="url(#ah3)"/>
<line class="dg-edge thin dashed" x1="252" y1="230" x2="252" y2="252" marker-end="url(#ah3)"/>
<text class="dg-el bg" x="290" y="244" text-anchor="middle" style="font-weight:400">index.yaml</text>
<!-- PolicyDefinition -->
<rect class="dg-box violet" x="390" y="40" width="300" height="150" rx="11"/>
<text class="dg-t sm" x="406" y="64" fill="var(--accent-2)">PolicyDefinition · policy.py</text>
<line x1="402" y1="74" x2="678" y2="74" stroke="var(--border)"/>
<text class="dg-m" x="406" y="94">name · version · description</text>
<text class="dg-m" x="406" y="113">input_validators: list[PolicyValidator]</text>
<text class="dg-m" x="406" y="132">output_validators: list[PolicyValidator]</text>
<text class="dg-m" x="406" y="151">on_validator_error:</text>
<text class="dg-m" x="406" y="170"> fail_open | fail_closed (def: fail_closed)</text>
<rect class="dg-box" x="390" y="212" width="146" height="60" rx="10"/>
<text class="dg-t sm" x="398" y="234">PolicyValidator</text>
<text class="dg-m dim" x="398" y="253">type: str</text><text class="dg-m dim" x="398" y="267">config: dict</text>
<rect class="dg-box" x="546" y="212" width="144" height="60" rx="10"/>
<text class="dg-t sm" x="554" y="234">PolicyVersionMeta</text>
<text class="dg-m dim" x="554" y="253">id · hash · author</text><text class="dg-m dim" x="554" y="267">message · created_at</text>
<line class="dg-edge violet thin" x1="463" y1="190" x2="463" y2="212" marker-end="url(#ah3)"/>
<path class="dg-edge accent thin dashed" d="M 324 130 C 350 130, 360 110, 390 110" fill="none" marker-end="url(#ah3)"/>
<text class="dg-el bg" x="357" y="100" text-anchor="middle" style="font-weight:400">por nombre</text>
<!-- AgentExecution -->
<rect class="dg-box" x="756" y="40" width="300" height="296" rx="11"/>
<text class="dg-t sm" x="772" y="64">AgentExecution · execution.py</text>
<text class="dg-s" x="772" y="80">el "expediente" de una invocación · va a JSONL al terminar</text>
<line x1="768" y1="88" x2="1044" y2="88" stroke="var(--border)"/>
<text class="dg-m" x="772" y="108">trace_id: UUID · agent_name · agent_version</text>
<text class="dg-m" x="772" y="127">status: running | awaiting_approval |</text>
<text class="dg-m" x="772" y="143"> blocked_by_guardrail | completed | failed</text>
<text class="dg-m" x="772" y="162">started_at · finished_at</text>
<text class="dg-m" x="772" y="181">decision_path: list[DecisionStep]</text>
<text class="dg-m" x="772" y="200">violations: list[GuardrailViolation]</text>
<text class="dg-m" x="772" y="219">proposed_actions: list[ProposedAction]</text>
<text class="dg-m" x="772" y="238">needs_human_for: list[ProposedAction] | None</text>
<text class="dg-m" x="772" y="257">final_output: dict | None · error: str | None</text>
<rect class="dg-box" x="756" y="276" width="143" height="60" rx="10"/>
<text class="dg-t sm" x="764" y="297" style="font-size:11px">DecisionStep</text>
<text class="dg-m dim" x="764" y="314" style="font-size:10px">step · timestamp</text><text class="dg-m dim" x="764" y="328" style="font-size:10px">duration_ms · detail</text>
<rect class="dg-box" x="905" y="276" width="151" height="60" rx="10"/>
<text class="dg-t sm" x="913" y="297" style="font-size:11px">ProposedAction</text>
<text class="dg-m dim" x="913" y="314" style="font-size:10px">id · action · target</text><text class="dg-m dim" x="913" y="328" style="font-size:10px">risk_score(1-5) · rollback_plan · requires_approval</text>
<!-- GuardrailViolation -->
<rect class="dg-box danger" x="390" y="320" width="330" height="116" rx="11"/>
<text class="dg-t sm" x="406" y="344" fill="var(--danger)">GuardrailViolation · guardrail.py</text>
<line x1="402" y1="354" x2="708" y2="354" stroke="var(--border)"/>
<text class="dg-m" x="406" y="374">trace_id: UUID · timestamp</text>
<text class="dg-m" x="406" y="393">stage: input | output · validator: str</text>
<text class="dg-m" x="406" y="412">severity: info | warning | block</text>
<text class="dg-m" x="406" y="431">message: str · blocked: bool</text>
<path class="dg-edge thin" d="M 906 257 C 900 280, 880 290, 850 276" fill="none" marker-end="url(#ah3)"/>
<path class="dg-edge thin" d="M 940 257 C 950 280, 960 290, 970 276" fill="none" marker-end="url(#ah3)"/>
<path class="dg-edge danger thin" d="M 756 215 C 700 250, 650 290, 640 320" fill="none" marker-end="url(#ah3)"/>
<text class="dg-el bg" x="690" y="290" text-anchor="middle" style="font-weight:400">acumula</text>
<!-- AgentExecutionSummary -->
<rect class="dg-box soft" x="756" y="356" width="300" height="40" rx="9"/>
<text class="dg-m" x="906" y="380" text-anchor="middle">AgentExecutionSummary — versión ligera para listados</text>
<line class="dg-edge thin dashed" x1="906" y1="336" x2="906" y2="356" marker-end="url(#ah3)"/>
</svg>
<figcaption><b>Figura 3.</b> Los modelos del dominio. <code>AgentDefinition</code> referencia políticas por nombre; el <code>AgentRegistry</code> resuelve la versión activa vía <code>index.yaml</code>. <code>AgentExecution</code> 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 <code>violations.jsonl</code>.</figcaption>
</figure>
<div class="tbl-wrap">
<table>
<thead><tr><th>Fichero</th><th>Qué define</th></tr></thead>
<tbody>
<tr><td><code>domain/agent.py</code></td><td><code>AgentDefinition</code> (prompt, modelo, <code>output_schema</code>, <code>guardrails</code>, <code>risk_threshold_for_hitl</code>…), <code>LLMConfig</code>, <code>AgentVersionMeta</code>.</td></tr>
<tr><td><code>domain/policy.py</code></td><td><code>PolicyDefinition</code> (listas de <code>PolicyValidator</code> de entrada y salida, <code>on_validator_error</code>), <code>PolicyVersionMeta</code>.</td></tr>
<tr><td><code>domain/guardrail.py</code></td><td><code>GuardrailViolation</code> (trace_id, stage <code>input</code>/<code>output</code>, validator, severity, message, blocked).</td></tr>
<tr><td><code>domain/execution.py</code></td><td><code>AgentExecution</code> (status, <code>decision_path</code>, <code>violations</code>, <code>proposed_actions</code>, <code>needs_human_for</code>, <code>final_output</code>, <code>error</code>), <code>ProposedAction</code>, <code>DecisionStep</code>, <code>AgentExecutionSummary</code>.</td></tr>
</tbody>
</table>
</div>
</section>
<!-- ============================================================= -->
<section id="guardrails">
<h2><span class="kicker">07</span> Guardrails — el motor de validación</h2>
<p>Aquí está el corazón del "gobierno". La estructura sigue otra vez <strong>Protocol + implementaciones + composite + factory</strong>. Una política lista qué validar; el motor lo aplica. Por defecto, <strong>fail-closed</strong>: si un validador peta, cuenta como bloqueo (seguridad antes que disponibilidad).</p>
<figure class="diagram">
<svg viewBox="0 0 1020 470" role="img" aria-label="Composición del motor de guardrails">
<defs><marker id="ah4" markerWidth="10" markerHeight="10" refX="7.5" refY="4" orient="auto-start-reverse"><path d="M0,0 L8.5,4 L0,8 Z" fill="context-stroke"/></marker></defs>
<!-- policy in -->
<rect class="dg-box violet" x="24" y="180" width="210" height="110" rx="11"/>
<text class="dg-t sm" x="129" y="204" text-anchor="middle">PolicyDefinition</text>
<text class="dg-m dim" x="129" y="224" text-anchor="middle">policies/default/v1.yaml</text>
<line x1="40" y1="232" x2="218" y2="232" stroke="var(--border)"/>
<text class="dg-m" x="40" y="250">input_validators[…]</text>
<text class="dg-m" x="40" y="268">output_validators[…]</text>
<text class="dg-m" x="40" y="286">on_validator_error: fail_closed</text>
<!-- composite -->
<rect class="dg-box accent" x="300" y="40" width="320" height="74" rx="11"/>
<text class="dg-t sm" x="460" y="64" text-anchor="middle">CompositeGuardrailEngine</text>
<text class="dg-s" x="460" y="82" text-anchor="middle">corre N sub-engines en paralelo (asyncio.gather)</text>
<text class="dg-s" x="460" y="100" text-anchor="middle">y aplana las listas de violaciones</text>
<!-- sub-engines -->
<rect class="dg-box" x="300" y="160" width="320" height="64" rx="10"/>
<text class="dg-t sm" x="460" y="184" text-anchor="middle">GuardrailsAIEngine</text>
<text class="dg-s" x="460" y="202" text-anchor="middle">el real: aplica los validadores de la política</text>
<text class="dg-s" x="460" y="218" text-anchor="middle">integra Presidio para PII (con fallback a regex)</text>
<rect class="dg-box soft dashed" x="300" y="252" width="320" height="56" rx="10"/>
<text class="dg-t sm" x="460" y="276" text-anchor="middle" fill="var(--muted)">NeMoGuardrailsEngine</text>
<text class="dg-s" x="460" y="294" text-anchor="middle">stub · solo si GUARDRAILS_NEMO_ENABLED=true</text>
<!-- registries -->
<rect class="dg-box" x="690" y="150" width="306" height="86" rx="10"/>
<text class="dg-t sm" x="704" y="172">INPUT_VALIDATORS (type → fn)</text>
<text class="dg-m dim" x="704" y="190">detect_pii · prompt_injection</text>
<text class="dg-m dim" x="704" y="206">toxic_language · forbidden_topics</text>
<text class="dg-m dim" x="704" y="224" fill="var(--muted)">payload = str (texto del usuario)</text>
<rect class="dg-box" x="690" y="254" width="306" height="100" rx="10"/>
<text class="dg-t sm" x="704" y="276">OUTPUT_VALIDATORS (type → fn)</text>
<text class="dg-m dim" x="704" y="294">schema_match · pii_leakage</text>
<text class="dg-m dim" x="704" y="310">forbidden_action_keywords</text>
<text class="dg-m dim" x="704" y="326">telco_safety_rules</text>
<text class="dg-m dim" x="704" y="344" fill="var(--muted)">payload = dict (JSON del LLM)</text>
<!-- edges -->
<path class="dg-edge violet thin" d="M 234 220 C 270 200, 280 120, 300 88" fill="none" marker-end="url(#ah4)"/>
<path class="dg-edge violet thin" d="M 234 250 C 270 250, 280 200, 300 192" fill="none" marker-end="url(#ah4)"/>
<text class="dg-el bg" x="270" y="150" text-anchor="middle" style="font-weight:400">qué validar</text>
<line class="dg-edge accent thin" x1="460" y1="114" x2="460" y2="160" marker-end="url(#ah4)"/>
<line class="dg-edge thin dashed" x1="460" y1="114" x2="460" y2="252" marker-end="url(#ah4)" opacity="0.7"/>
<path class="dg-edge thin" d="M 620 184 C 660 184, 670 190, 690 190" fill="none" marker-end="url(#ah4)"/>
<path class="dg-edge thin" d="M 620 200 C 660 220, 670 290, 690 300" fill="none" marker-end="url(#ah4)"/>
<text class="dg-el bg" x="655" y="170" text-anchor="middle" style="font-weight:400">busca cada type</text>
<!-- output: violations -->
<rect class="dg-box danger dashed" x="300" y="360" width="320" height="48" rx="10"/>
<text class="dg-m" x="460" y="384" text-anchor="middle" fill="var(--danger)">→ list[GuardrailViolation] · cualquier blocked=True frena el grafo</text>
<path class="dg-edge thin" d="M 460 224 L 460 252" marker-end="url(#ah4)" opacity="0"/>
<line class="dg-edge danger thin" x1="460" y1="308" x2="460" y2="360" marker-end="url(#ah4)"/>
<text class="dg-el bg" x="500" y="338" text-anchor="middle" style="font-weight:400">agrega</text>
<text class="dg-frame-lbl" x="24" y="438" fill="var(--muted)">fail_closed: si una fn validadora lanza → se añade una violación severity=block, blocked=True. type desconocido → log warning, no bloquea.</text>
</svg>
<figcaption><b>Figura 4.</b> El motor de guardrails. La política dice <em>qué</em> validar; <code>GuardrailsAIEngine</code> recorre esa lista, busca cada <code>type</code> en su registro de funciones de <code>validators.py</code>, las llama con <code>(payload, config, trace_id, kind)</code>, y junta las <code>GuardrailViolation</code> que devuelvan. <code>CompositeGuardrailEngine</code> corre los sub-engines en paralelo y agrega.</figcaption>
</figure>
<h3>Los validadores concretos (<code>guardrails/validators.py</code>)</h3>
<div class="tbl-wrap">
<table>
<thead><tr><th>Validador</th><th>Etapa</th><th>Qué comprueba</th><th>Severidad típica</th></tr></thead>
<tbody>
<tr><td><code>detect_pii</code></td><td><span class="pill accent">input</span></td><td>PII vía Presidio (modelo spaCy <code>en_core_web_sm</code>) o, si no está, regex de EMAIL / PHONE / ES_NIF / IP. La política <code>default</code> pide solo recognizers de patrón fiables (<code>EMAIL_ADDRESS, ES_NIF, IP_ADDRESS, IBAN_CODE</code>) para evitar falsos positivos del NER.</td><td><span class="pill danger">block</span></td></tr>
<tr><td><code>prompt_injection</code></td><td><span class="pill accent">input</span></td><td>Heurísticas de patrones conocidos ("ignore previous instructions", "you are now", "reveal the system prompt"…).</td><td><span class="pill danger">block</span></td></tr>
<tr><td><code>toxic_language</code></td><td><span class="pill accent">input</span></td><td>Lista negra con umbral (MVP).</td><td><span class="pill warn">warning</span></td></tr>
<tr><td><code>forbidden_topics</code></td><td><span class="pill accent">input</span></td><td>Coincidencia de substring con temas prohibidos (instrucciones de explotación, credenciales, código malicioso).</td><td><span class="pill danger">block</span></td></tr>
<tr><td><code>schema_match</code></td><td><span class="pill violet">output</span></td><td>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).</td><td><span class="pill danger">block</span></td></tr>
<tr><td><code>pii_leakage</code></td><td><span class="pill violet">output</span></td><td>Re-aplica <code>detect_pii</code> sobre la salida serializada.</td><td><span class="pill danger">block</span></td></tr>
<tr><td><code>forbidden_action_keywords</code></td><td><span class="pill violet">output</span></td><td>Que las acciones propuestas no contengan comandos peligrosos (<code>DROP TABLE</code>, <code>rm -rf</code>, <code>shutdown -h now</code>, <code>delete production</code>, <code>format c:</code>).</td><td><span class="pill danger">block</span></td></tr>
<tr><td><code>telco_safety_rules</code></td><td><span class="pill violet">output</span></td><td>Reglas declarativas: nunca acción sobre <code>prod</code> sin rollback; nunca acción masiva (<code>all/todos/*</code>) sin <code>canary</code> en el plan.</td><td><span class="pill danger">block</span></td></tr>
</tbody>
</table>
</div>
<p>Así se conecta una política con un validador (extracto de <code>policies/default/versions/v1.yaml</code>):</p>
<div class="code">
<div class="hd"><span class="dot"></span><span class="fn">policies/default/versions/v1.yaml</span><span class="lang">yaml</span></div>
<pre><span class="y">name</span>: default
<span class="y">version</span>: v1
<span class="y">input_validators</span>:
- <span class="y">type</span>: detect_pii
<span class="y">config</span>: { <span class="y">entities</span>: [EMAIL_ADDRESS, ES_NIF, IP_ADDRESS, IBAN_CODE], <span class="y">severity_on_match</span>: block }
- <span class="y">type</span>: prompt_injection
<span class="y">config</span>: { <span class="y">severity_on_match</span>: block }
- <span class="y">type</span>: toxic_language
<span class="y">config</span>: { <span class="y">threshold</span>: <span class="n">0.7</span>, <span class="y">severity_on_match</span>: warning }
- <span class="y">type</span>: forbidden_topics
<span class="y">config</span>: { <span class="y">topics</span>: [<span class="s">"instrucciones de explotación"</span>, <span class="s">"credenciales"</span>, <span class="s">"código malicioso"</span>], <span class="y">severity_on_match</span>: block }
<span class="y">output_validators</span>:
- <span class="y">type</span>: schema_match <span class="c"># valida el JSON del LLM contra un JSON Schema</span>
<span class="y">config</span>: { <span class="y">severity_on_mismatch</span>: block, <span class="y">schema</span>: { … } }
- <span class="y">type</span>: pii_leakage
- <span class="y">type</span>: forbidden_action_keywords
<span class="y">config</span>: { <span class="y">keywords</span>: [<span class="d">"DROP TABLE"</span>, <span class="d">"rm -rf"</span>, <span class="d">"shutdown -h now"</span>, <span class="d">"delete production"</span>, <span class="d">"format c:"</span>] }
- <span class="y">type</span>: telco_safety_rules
<span class="y">config</span>: { <span class="y">rules</span>: [never_propose_action_targeting_production_without_rollback, never_propose_mass_action_without_canary] }
<span class="y">on_validator_error</span>: fail_closed <span class="c"># si un validador lanza una excepción → cuenta como bloqueo</span></pre>
</div>
</section>
<!-- ============================================================= -->
<section id="runtime">
<h2><span class="kicker">08</span> El grafo de ejecución (LangGraph)</h2>
<p>Una ejecución del agente se modela como un <strong>grafo dirigido de estados con checkpoints</strong>. 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 <code>DecisionStep</code> con su duración al <code>decision_path</code>.</p>
<div class="legend">
<span class="li"><span class="sw box"></span> nodo del grafo</span>
<span class="li"><span class="sw warn"></span> pausa (estado persistido)</span>
<span class="li"><span class="sw danger"></span> salida temprana (status terminal)</span>
<span class="li">— camino normal · - - camino condicional</span>
</div>
<figure class="diagram">
<svg viewBox="0 0 1080 740" role="img" aria-label="El grafo de ejecución LangGraph">
<defs><marker id="ah5" markerWidth="10" markerHeight="10" refX="7.5" refY="4" orient="auto-start-reverse"><path d="M0,0 L8.5,4 L0,8 Z" fill="context-stroke"/></marker></defs>
<!-- START -->
<ellipse class="dg-box accent" cx="200" cy="40" rx="44" ry="20"/>
<text class="dg-t sm" x="200" y="45" text-anchor="middle">START</text>
<!-- validate_input -->
<rect class="dg-box" x="120" y="92" width="160" height="56" rx="10"/>
<text class="dg-t sm" x="200" y="116" text-anchor="middle">validate_input</text>
<text class="dg-s" x="200" y="134" text-anchor="middle">engine.validate_input(...)</text>
<!-- llm_reason -->
<rect class="dg-box" x="120" y="200" width="160" height="56" rx="10"/>
<text class="dg-t sm" x="200" y="224" text-anchor="middle">llm_reason</text>
<text class="dg-s" x="200" y="242" text-anchor="middle">provider.complete([sys, user])</text>
<!-- validate_output -->
<rect class="dg-box" x="120" y="308" width="160" height="56" rx="10"/>
<text class="dg-t sm" x="200" y="332" text-anchor="middle">validate_output</text>
<text class="dg-s" x="200" y="350" text-anchor="middle">json.loads + engine.validate_output</text>
<!-- propose_actions -->
<rect class="dg-box" x="120" y="416" width="160" height="56" rx="10"/>
<text class="dg-t sm" x="200" y="440" text-anchor="middle">propose_actions</text>
<text class="dg-s" x="200" y="458" text-anchor="middle">extrae proposed_actions[]</text>
<!-- approve_gate -->
<rect class="dg-box accent" x="100" y="524" width="200" height="64" rx="10"/>
<text class="dg-t sm" x="200" y="548" text-anchor="middle">approve_gate</text>
<text class="dg-s" x="200" y="566" text-anchor="middle">¿alguna acción con risk_score ≥ umbral</text>
<text class="dg-s" x="200" y="581" text-anchor="middle">o requires_approval = True?</text>
<!-- finalize -->
<rect class="dg-box" x="120" y="640" width="160" height="56" rx="10"/>
<text class="dg-t sm" x="200" y="664" text-anchor="middle">finalize</text>
<text class="dg-s" x="200" y="682" text-anchor="middle">filtra a las acciones aprobadas</text>
<!-- END (bottom) -->
<ellipse class="dg-box" cx="200" cy="722" rx="38" ry="18"/>
<text class="dg-t sm" x="200" y="727" text-anchor="middle">END</text>
<!-- main spine edges -->
<line class="dg-edge accent" x1="200" y1="60" x2="200" y2="92" marker-end="url(#ah5)"/>
<line class="dg-edge" x1="200" y1="148" x2="200" y2="200" marker-end="url(#ah5)"/><text class="dg-el bg" x="216" y="178">ok</text>
<line class="dg-edge" x1="200" y1="256" x2="200" y2="308" marker-end="url(#ah5)"/><text class="dg-el bg" x="216" y="286">ok</text>
<line class="dg-edge" x1="200" y1="364" x2="200" y2="416" marker-end="url(#ah5)"/><text class="dg-el bg" x="216" y="394">ok</text>
<line class="dg-edge" x1="200" y1="472" x2="200" y2="524" marker-end="url(#ah5)"/>
<line class="dg-edge" x1="200" y1="640" x2="200" y2="623" opacity="0"/>
<path class="dg-edge" d="M 160 588 C 130 605, 130 620, 160 640" fill="none" marker-end="url(#ah5)"/><text class="dg-el bg" x="118" y="616">no hay riesgo</text>
<line class="dg-edge" x1="200" y1="696" x2="200" y2="704" marker-end="url(#ah5)"/>
<!-- early-exit edges to a right-side END column -->
<ellipse class="dg-box danger" cx="600" cy="120" rx="40" ry="18"/><text class="dg-t sm" x="600" y="125" text-anchor="middle">END</text>
<path class="dg-edge danger dashed" d="M 280 120 C 420 120, 470 120, 560 120" fill="none" marker-end="url(#ah5)"/>
<text class="dg-el bg" x="420" y="110" text-anchor="middle">bloqueada — PII, injection, topic… → status = blocked_by_guardrail</text>
<ellipse class="dg-box danger" cx="600" cy="228" rx="40" ry="18"/><text class="dg-t sm" x="600" y="233" text-anchor="middle">END</text>
<path class="dg-edge danger dashed" d="M 280 228 C 420 228, 470 228, 560 228" fill="none" marker-end="url(#ah5)"/>
<text class="dg-el bg" x="420" y="218" text-anchor="middle">LLM no disponible / error → status = failed (llm_unavailable)</text>
<ellipse class="dg-box danger" cx="600" cy="336" rx="40" ry="18"/><text class="dg-t sm" x="600" y="341" text-anchor="middle">END</text>
<path class="dg-edge danger dashed" d="M 280 336 C 420 336, 470 336, 560 336" fill="none" marker-end="url(#ah5)"/>
<text class="dg-el bg" x="420" y="326" text-anchor="middle">salida no parsea / no cumple esquema / PII en la salida → blocked_by_guardrail | failed</text>
<!-- HITL branch -->
<rect class="dg-box warn" x="430" y="500" width="290" height="112" rx="12"/>
<text class="dg-t sm" x="575" y="524" text-anchor="middle" fill="var(--warn)">interrupt({ awaiting_actions: [...] })</text>
<text class="dg-s" x="575" y="544" text-anchor="middle">el grafo SE PAUSA aquí.</text>
<text class="dg-s" x="575" y="560" text-anchor="middle">LangGraph persiste el estado en</text>
<text class="dg-m" x="575" y="578" text-anchor="middle">data/checkpoints.sqlite</text>
<text class="dg-s" x="575" y="598" text-anchor="middle">status del snapshot = awaiting_approval</text>
<path class="dg-edge warn" d="M 300 552 C 360 530, 380 525, 430 540" fill="none" marker-end="url(#ah5)"/>
<text class="dg-el bg" x="365" y="522" text-anchor="middle">hay riesgo → sí</text>
<!-- resume back -->
<rect class="dg-box ok dashed" x="770" y="430" width="280" height="74" rx="11"/>
<text class="dg-t sm" x="910" y="454" text-anchor="middle" fill="var(--ok)">…más tarde (¡incluso tras reiniciar!)…</text>
<text class="dg-m" x="910" y="474" text-anchor="middle">resume(Command(resume=decision))</text>
<text class="dg-s" x="910" y="492" text-anchor="middle">POST /executions/{trace_id}/approve | /reject</text>
<path class="dg-edge ok dashed" d="M 720 540 C 800 540, 900 530, 910 504" fill="none" marker-end="url(#ah5)"/>
<path class="dg-edge ok dashed" d="M 870 430 C 700 380, 360 420, 250 524" fill="none" marker-end="url(#ah5)"/>
<text class="dg-el bg" x="560" y="404" text-anchor="middle">interrupt() devuelve la decisión → approve_gate continúa</text>
<!-- finalize branch: rejected -->
<ellipse class="dg-box danger" cx="600" cy="668" rx="40" ry="18"/><text class="dg-t sm" x="600" y="673" text-anchor="middle">END</text>
<path class="dg-edge danger dashed" d="M 280 668 C 420 668, 470 668, 560 668" fill="none" marker-end="url(#ah5)"/>
<text class="dg-el bg" x="420" y="690" text-anchor="middle">human_decision.rejected → status = failed (rejected_by_human)</text>
<text class="dg-el bg" x="200" y="618" text-anchor="middle">si no hubo HITL: todas las acciones</text>
</svg>
<figcaption><b>Figura 5.</b> El grafo. Columna izquierda: el camino feliz <code>START → validate_input → llm_reason → validate_output → propose_actions → approve_gate → finalize → END</code>. Salidas tempranas (rojo) cuando un guardrail bloquea o algo falla. <code>approve_gate</code> bifurca: si no hay riesgo, va directo a <code>finalize</code>; si lo hay, llama a <code>interrupt()</code> y <strong>el grafo se detiene</strong>, con el estado en SQLite, hasta que llega un <code>resume(decision)</code> — que puede ocurrir tras un reinicio del proceso.</figcaption>
</figure>
<h3>Los nodos (<code>runtime/nodes.py</code>) y el estado (<code>runtime/state.py</code>)</h3>
<div class="tbl-wrap">
<table>
<thead><tr><th>Nodo (factory)</th><th>Hace</th><th>Puede saltar a</th></tr></thead>
<tbody>
<tr><td><code>validate_input</code><br><span class="muted">build_node_validate_input(engine, policy)</span></td><td>Corre los <code>input_validators</code> de la política sobre el texto del usuario; acumula violaciones.</td><td><code>END</code> si alguna <code>blocked</code><code>status = blocked_by_guardrail</code></td></tr>
<tr><td><code>llm_reason</code><br><span class="muted">build_node_llm_reason(provider, agent_def)</span></td><td><code>provider.complete([Message("system", system_prompt), Message("user", input)], temperature, max_tokens)</code>. Guarda <code>raw_llm_output</code> y un step con modelo/tokens/latencia.</td><td><code>END</code> si el provider lanza → <code>status = failed</code>, <code>error = llm_unavailable</code></td></tr>
<tr><td><code>validate_output</code><br><span class="muted">build_node_validate_output(engine, policy)</span></td><td><code>json.loads</code> del output; corre los <code>output_validators</code> (<code>schema_match</code>, <code>pii_leakage</code>, …).</td><td><code>END</code> si no parsea (<code>output_schema_mismatch</code>) o hay <code>blocked</code></td></tr>
<tr><td><code>propose_actions</code><br><span class="muted">build_node_propose_actions()</span></td><td>Extrae <code>parsed_output["proposed_actions"]</code> al estado.</td><td>siempre → <code>approve_gate</code></td></tr>
<tr><td><code>approve_gate</code><br><span class="muted">build_node_approve_gate(agent_def)</span></td><td>Filtra acciones <em>arriesgadas</em> (<code>risk_score ≥ risk_threshold_for_hitl</code> o <code>requires_approval</code>). Si las hay → <code>interrupt({"awaiting_actions": risky})</code> (pausa). Al reanudar, recibe la <code>decision</code> humana.</td><td>siempre → <code>finalize</code> (tras la pausa, si la hubo)</td></tr>
<tr><td><code>finalize</code><br><span class="muted">build_node_finalize()</span></td><td>Si <code>human_decision.rejected</code><code>status = failed</code>, <code>error = rejected_by_human</code>. Si no → <code>final_output = {**parsed, "approved_actions": <acciones aprobadas o todas si no hubo HITL>}</code>, <code>status = completed</code>.</td><td><code>END</code></td></tr>
</tbody>
</table>
</div>
<p>El estado que fluye por el grafo es un <code>TypedDict</code>. <code>decision_path</code> usa un <em>reducer</em> (<code>Annotated[list, operator.add]</code>) para que cada nodo <strong>añada</strong> pasos en vez de sobrescribir:</p>
<div class="code">
<div class="hd"><span class="dot"></span><span class="fn">core/src/agentforge_core/runtime/state.py</span><span class="lang">python</span></div>
<pre><span class="k">class</span> <span class="y">AgentState</span>(TypedDict, total=<span class="k">False</span>):
trace_id: <span class="y">str</span>; agent_name: <span class="y">str</span>; agent_version: <span class="y">str</span>; user_input: <span class="y">str</span>
messages: <span class="y">list</span>[<span class="y">dict</span>]; raw_llm_output: <span class="y">str</span> | <span class="k">None</span>; parsed_output: <span class="y">dict</span> | <span class="k">None</span>
proposed_actions: <span class="y">list</span>[<span class="y">dict</span>]; violations: <span class="y">list</span>[<span class="y">dict</span>]
decision_path: Annotated[<span class="y">list</span>[<span class="y">dict</span>], operator.add] <span class="c"># ← reducer: cada nodo AÑADE pasos</span>
status: <span class="y">str</span>; error: <span class="y">str</span> | <span class="k">None</span>
human_decision: <span class="y">dict</span> | <span class="k">None</span>; final_output: <span class="y">dict</span> | <span class="k">None</span></pre>
</div>
<details class="dd">
<summary>Mirar dentro: el cableado del grafo y el nodo que pausa</summary>
<div class="body">
<p>El grafo se cablea con aristas fijas y <strong>condicionales</strong> (<code>runtime/graph.py</code>): tras <code>validate_input</code>, si <code>status == "blocked_by_guardrail"</code><code>END</code>; tras <code>llm_reason</code>, si <code>status == "failed"</code><code>END</code>; etc. <code>propose_actions → approve_gate → finalize → END</code> son aristas fijas.</p>
<div class="code">
<div class="hd"><span class="dot"></span><span class="fn">runtime/graph.py — aristas condicionales</span><span class="lang">python</span></div>
<pre>g.add_edge(START, <span class="s">"validate_input"</span>)
<span class="k">def</span> <span class="y">_after_validate_input</span>(state):
<span class="k">return</span> END <span class="k">if</span> state.get(<span class="s">"status"</span>) == <span class="s">"blocked_by_guardrail"</span> <span class="k">else</span> <span class="s">"llm_reason"</span>
g.add_conditional_edges(<span class="s">"validate_input"</span>, _after_validate_input, {END: END, <span class="s">"llm_reason"</span>: <span class="s">"llm_reason"</span>})
<span class="c"># … _after_llm → END si status=="failed" · _after_validate_output → END si status in {blocked, failed} …</span>
g.add_edge(<span class="s">"propose_actions"</span>, <span class="s">"approve_gate"</span>)
g.add_edge(<span class="s">"approve_gate"</span>, <span class="s">"finalize"</span>)
g.add_edge(<span class="s">"finalize"</span>, END)
<span class="k">return</span> g.compile(checkpointer=checkpointer)</pre>
</div>
<div class="code">
<div class="hd"><span class="dot"></span><span class="fn">runtime/nodes.py — build_node_approve_gate</span><span class="lang">python</span></div>
<pre><span class="k">def</span> <span class="y">build_node_approve_gate</span>(agent_def):
<span class="k">async def</span> <span class="y">approve_gate</span>(state):
actions = state.get(<span class="s">"proposed_actions"</span>, [])
risky = [a <span class="k">for</span> a <span class="k">in</span> actions
<span class="k">if</span> <span class="y">int</span>(a.get(<span class="s">"risk_score"</span>, <span class="n">1</span>)) &gt;= agent_def.risk_threshold_for_hitl
<span class="k">or</span> <span class="y">bool</span>(a.get(<span class="s">"requires_approval"</span>))]
<span class="k">if not</span> risky:
<span class="k">return</span> {<span class="s">"decision_path"</span>: [_step(<span class="s">"approve_gate"</span>, started, hitl=<span class="k">False</span>)]}
decision = <span class="y">interrupt</span>({<span class="s">"awaiting_actions"</span>: risky}) <span class="c"># ← pausa; reanuda con Command(resume=decision)</span>
<span class="k">return</span> {<span class="s">"human_decision"</span>: decision,
<span class="s">"decision_path"</span>: [_step(<span class="s">"approve_gate"</span>, started, hitl=<span class="k">True</span>, resumed=<span class="k">True</span>)]}
<span class="k">return</span> approve_gate</pre>
</div>
<p>El <code>AgentOrchestrator</code> abre su <strong>propio</strong> <code>AsyncSqliteSaver</code> en cada operación (<code>invoke</code> / <code>resume</code> / <code>snapshot</code>) sobre <code>data_dir/checkpoints.sqlite</code> — por eso un <code>awaiting_approval</code> sobrevive a un reinicio: basta crear otro orchestrator apuntando al mismo <code>data_dir</code> y llamar a <code>resume</code>. El orchestrator traduce el <code>StateSnapshot</code> de LangGraph a un <code>AgentExecution</code>: si <code>state.next</code> (hay un nodo pendiente) y el <code>status</code> no es terminal ⇒ es el <code>interrupt()</code><code>status = "awaiting_approval"</code> y <code>needs_human_for</code> = las acciones arriesgadas.</p>
</div>
</details>
</section>
<!-- ============================================================= -->
<section id="viaje">
<h2><span class="kicker">09</span> El viaje de UNA petición, de principio a fin</h2>
<p>Esta es la sección que conviene leer despacio: aquí se ve cómo encaja todo. Seguimos <code>POST /agents/incident_analyzer/invoke</code> con el escenario SIP — el que dispara HITL — y luego la aprobación.</p>
<figure class="diagram">
<svg viewBox="0 0 1120 802" role="img" aria-label="Diagrama de secuencia: el viaje de una petición">
<defs><marker id="ah6" markerWidth="9" markerHeight="9" refX="7" refY="4" orient="auto-start-reverse"><path d="M0,0 L8,4 L0,8 Z" fill="context-stroke"/></marker></defs>
<!-- actor headers -->
<rect class="dg-actor" x="18" y="18" width="144" height="42" rx="8"/><text class="dg-t sm" x="90" y="36" text-anchor="middle">Cliente</text><text class="dg-s" x="90" y="51" text-anchor="middle">dashboard / curl</text>
<rect class="dg-actor" x="218" y="18" width="148" height="42" rx="8"/><text class="dg-t sm" x="292" y="36" text-anchor="middle">router</text><text class="dg-s" x="292" y="51" text-anchor="middle">api/executions.py · +middleware</text>
<rect class="dg-actor" x="416" y="18" width="148" height="42" rx="8"/><text class="dg-t sm" x="490" y="36" text-anchor="middle">AgentOrchestrator</text><text class="dg-s" x="490" y="51" text-anchor="middle">runtime/orchestrator.py</text>
<rect class="dg-actor" x="612" y="18" width="140" height="42" rx="8"/><text class="dg-t sm" x="682" y="36" text-anchor="middle">Grafo LangGraph</text><text class="dg-s" x="682" y="51" text-anchor="middle">nodes + checkpointer</text>
<rect class="dg-actor" x="788" y="18" width="140" height="42" rx="8"/><text class="dg-t sm" x="858" y="36" text-anchor="middle">checkpoints</text><text class="dg-s" x="858" y="51" text-anchor="middle">data/checkpoints.sqlite</text>
<rect class="dg-actor" x="958" y="18" width="146" height="42" rx="8"/><text class="dg-t sm" x="1031" y="36" text-anchor="middle">logs / índice</text><text class="dg-s" x="1031" y="51" text-anchor="middle">executions.jsonl · index.json</text>
<!-- lifelines -->
<line class="dg-life" x1="90" y1="60" x2="90" y2="792"/>
<line class="dg-life" x1="292" y1="60" x2="292" y2="792"/>
<line class="dg-life" x1="490" y1="60" x2="490" y2="792"/>
<line class="dg-life" x1="682" y1="60" x2="682" y2="792"/>
<line class="dg-life" x1="858" y1="60" x2="858" y2="792"/>
<line class="dg-life" x1="1031" y1="60" x2="1031" y2="792"/>
<!-- ACT 1 label -->
<rect class="dg-box accent" x="18" y="72" width="220" height="22" rx="6"/><text class="dg-t sm" x="28" y="88" style="font-size:11.5px">ACTO 1 · invoke → awaiting_approval</text>
<!-- m1 -->
<line class="dg-edge accent" x1="90" y1="116" x2="292" y2="116" marker-end="url(#ah6)"/>
<text class="dg-el bg" x="91" y="108" style="text-anchor:start">POST /agents/incident_analyzer/invoke { input }</text>
<text class="dg-el bg" x="91" y="128" style="text-anchor:start;font-weight:400;fill:var(--muted)">TraceIdMiddleware genera trace_id = UUID y lo bind-ea al log</text>
<!-- m2 self -->
<path class="dg-edge" d="M 292 150 h 30 v 18 h -30" marker-end="url(#ah6)"/>
<text class="dg-el bg" x="330" y="156" style="text-anchor:start">registry.get_agent → AgentDefinition (v2 activa)</text>
<text class="dg-el bg" x="330" y="172" style="text-anchor:start;font-weight:400">policies.get_policy(agent.guardrails[0]) → PolicyDefinition</text>
<!-- m3 -->
<line class="dg-edge" x1="292" y1="196" x2="490" y2="196" marker-end="url(#ah6)"/>
<text class="dg-el bg" x="293" y="188" style="text-anchor:start">orchestrator.invoke(agent_def, policy, user_input)</text>
<!-- m4 -->
<line class="dg-edge" x1="490" y1="222" x2="682" y2="222" marker-end="url(#ah6)"/>
<text class="dg-el bg" x="491" y="214" style="text-anchor:start">graph.ainvoke(estado_inicial, config={thread_id: trace_id})</text>
<!-- m5 self -->
<path class="dg-edge" d="M 682 246 h 30 v 18 h -30" marker-end="url(#ah6)"/>
<text class="dg-el bg" x="720" y="252" style="text-anchor:start">validate_input → llm_reason → validate_output → propose_actions</text>
<text class="dg-el bg" x="720" y="268" style="text-anchor:start;font-weight:400">0 violaciones · MockProvider ve "sip" → respuesta canónica</text>
<!-- m6 self -->
<path class="dg-edge warn" d="M 682 292 h 30 v 18 h -30" marker-end="url(#ah6)"/>
<text class="dg-el bg" x="720" y="298" style="text-anchor:start;fill:var(--warn)">approve_gate: act-1 (risk 4, requires_approval) → interrupt()</text>
<text class="dg-el bg" x="720" y="314" style="text-anchor:start;font-weight:400">⇒ el grafo SE DETIENE aquí</text>
<!-- m7 -->
<line class="dg-edge warn" x1="682" y1="338" x2="858" y2="338" marker-end="url(#ah6)"/>
<text class="dg-el bg" x="683" y="330" style="text-anchor:start;fill:var(--warn)">persiste el estado pausado</text>
<!-- m8 return -->
<line class="dg-edge dashed" x1="682" y1="364" x2="490" y2="364" marker-end="url(#ah6)"/>
<text class="dg-el bg" x="488" y="356" style="text-anchor:end">aget_state → hay nodo pendiente</text>
<!-- m9 return -->
<line class="dg-edge dashed" x1="490" y1="390" x2="292" y2="390" marker-end="url(#ah6)"/>
<text class="dg-el bg" x="488" y="382" style="text-anchor:end">AgentExecution(status=awaiting_approval, needs_human_for=[act-1])</text>
<!-- m10 -->
<line class="dg-edge" x1="292" y1="416" x2="1031" y2="416" marker-end="url(#ah6)"/>
<text class="dg-el bg" x="293" y="408" style="text-anchor:start">execution_index.json[trace_id] = (incident_analyzer, v2) · (no es terminal → NO se escribe en executions.jsonl)</text>
<!-- m11 return -->
<line class="dg-edge accent dashed" x1="292" y1="442" x2="90" y2="442" marker-end="url(#ah6)"/>
<text class="dg-el bg" x="290" y="434" style="text-anchor:end">200 { status: awaiting_approval, trace_id, decision_path, … }</text>
<!-- divider -->
<rect class="dg-band" x="14" y="460" width="1092" height="34" rx="6"/>
<line class="dg-divider" x1="14" y1="460" x2="1106" y2="460"/><line class="dg-divider" x1="14" y1="494" x2="1106" y2="494"/>
<text class="dg-s" x="560" y="481" text-anchor="middle" style="font-style:italic">· · · más tarde — incluso tras reiniciar agentforge-core: el estado pausado sigue en checkpoints.sqlite · · ·</text>
<!-- ACT 2 label -->
<rect class="dg-box ok" x="18" y="504" width="232" height="22" rx="6"/><text class="dg-t sm" x="28" y="520" style="font-size:11.5px;fill:var(--ok)">ACTO 2 · el humano decide → completed</text>
<!-- n1 -->
<line class="dg-edge accent" x1="90" y1="548" x2="292" y2="548" marker-end="url(#ah6)"/>
<text class="dg-el bg" x="91" y="540" style="text-anchor:start">POST /executions/{trace_id}/approve { approved_action_ids: [act-1], comment }</text>
<!-- n2 -->
<line class="dg-edge" x1="292" y1="574" x2="1031" y2="574" marker-end="url(#ah6)"/>
<text class="dg-el bg" x="293" y="566" style="text-anchor:start">_resolve: lee execution_index.json → reconstruye agent_def + policy</text>
<!-- n3 -->
<line class="dg-edge" x1="292" y1="600" x2="490" y2="600" marker-end="url(#ah6)"/>
<text class="dg-el bg" x="293" y="592" style="text-anchor:start">_ensure_awaiting: snapshot → status == awaiting_approval ✓ (409 si no)</text>
<!-- n4 -->
<line class="dg-edge" x1="292" y1="626" x2="490" y2="626" marker-end="url(#ah6)"/>
<text class="dg-el bg" x="293" y="618" style="text-anchor:start">orchestrator.resume(trace_id, decision={approved_action_ids:[act-1], rejected:false})</text>
<!-- n5 -->
<line class="dg-edge" x1="490" y1="652" x2="858" y2="652" marker-end="url(#ah6)"/>
<text class="dg-el bg" x="491" y="644" style="text-anchor:start">reabre el checkpointer (mismo data_dir) — el estado pausado sigue ahí</text>
<!-- n6 -->
<line class="dg-edge" x1="490" y1="678" x2="682" y2="678" marker-end="url(#ah6)"/>
<text class="dg-el bg" x="491" y="670" style="text-anchor:start">graph.ainvoke(Command(resume=decision), config={thread_id: trace_id})</text>
<!-- n7 self -->
<path class="dg-edge ok" d="M 682 702 h 30 v 18 h -30" marker-end="url(#ah6)"/>
<text class="dg-el bg" x="720" y="708" style="text-anchor:start;fill:var(--ok)">approve_gate (interrupt() devuelve la decisión) → finalize</text>
<text class="dg-el bg" x="720" y="724" style="text-anchor:start;font-weight:400">final_output={…, approved_actions:[act-1]} · status = completed</text>
<!-- n8 return -->
<line class="dg-edge dashed" x1="682" y1="748" x2="490" y2="748" marker-end="url(#ah6)"/>
<line class="dg-edge dashed" x1="490" y1="748" x2="292" y2="748" marker-end="url(#ah6)"/>
<text class="dg-el bg" x="488" y="740" style="text-anchor:end">AgentExecution(status=completed, final_output)</text>
<!-- n9 -->
<line class="dg-edge" x1="292" y1="772" x2="1031" y2="772" marker-end="url(#ah6)"/>
<text class="dg-el bg" x="293" y="764" style="text-anchor:start">append → executions.jsonl · (status terminal)</text>
<!-- n10 return -->
<line class="dg-edge accent dashed" x1="292" y1="792" x2="90" y2="792" marker-end="url(#ah6)"/>
<text class="dg-el bg" x="290" y="786" style="text-anchor:end">200 { status: completed, final_output, … }</text>
</svg>
<figcaption><b>Figura 6.</b> Diagrama de secuencia. <b>Acto 1</b>: la petición entra, el middleware pone un <code>trace_id</code>, el router resuelve agente+política, el orchestrator lanza el grafo, éste recorre cuatro nodos y se pausa en <code>approve_gate</code> con un <code>interrupt()</code> — el estado va a SQLite, se anota el índice y se responde <code>awaiting_approval</code> (sin escribir aún en el log append-only). <b>Acto 2</b> (puede ser tras un reinicio): llega el <code>approve</code>, el router reconstruye la config desde el índice, el orchestrator reabre el checkpointer y reanuda con <code>Command(resume=...)</code>; el grafo termina, y como el status ya es terminal se escribe en <code>executions.jsonl</code>. Si en vez de <code>approve</code> llega <code>reject</code><code>finalize</code> ve <code>rejected: true</code><code>status = failed</code>, <code>error = rejected_by_human</code>.</figcaption>
</figure>
<p>El mismo recorrido, con <code>curl</code> (provider <code>mock</code>, sin claves):</p>
<div class="code">
<div class="hd"><span class="dot"></span><span class="fn">demo end-to-end con curl</span><span class="lang">bash</span></div>
<pre><span class="c"># 1) Invocar — el escenario SIP dispara HITL</span>
TRACE=$(curl -s localhost:8000/agents/incident_analyzer/invoke \
-H <span class="s">'content-type: application/json'</span> \
-d <span class="s">'{"input": "Caída de registros SIP tras desplegar la imagen 4.7.2 en CSCF aravaca-01"}'</span> \
| python -c <span class="s">'import sys,json; print(json.load(sys.stdin)["trace_id"])'</span>)
<span class="c"># → status: "awaiting_approval", needs_human_for: [act-1]</span>
<span class="c"># 2) Revisar la cola HITL</span>
curl -s localhost:8000/executions | python -m json.tool <span class="c"># incluye las awaiting_approval reconstruidas del índice + checkpointer</span>
<span class="c"># 3) Aprobar la acción segura act-1</span>
curl -s localhost:8000/executions/$TRACE/approve \
-H <span class="s">'content-type: application/json'</span> \
-d <span class="s">'{"approved_action_ids": ["act-1"], "comment": "rollback ok"}'</span>
<span class="c"># → status: "completed", final_output: { ..., "approved_actions": [act-1] } · ya escrito en executions.jsonl</span>
<span class="c"># (el dashboard hace exactamente esto desde las páginas "Ejecutar" y "Aprobaciones")</span></pre>
</div>
</section>
<!-- ============================================================= -->
<section id="versionado">
<h2><span class="kicker">10</span> Versionado tipo Git</h2>
<p>Un agente no es una fila en una BD: es una carpeta con un <code>index.yaml</code> (el "catálogo de commits") y <code>versions/v1.yaml</code>, <code>versions/v2.yaml</code>… (los "commits"). Cada versión tiene un <strong>hash SHA-256</strong> del contenido normalizado, y dos versiones son comparables con un <strong>diff unificado</strong> (<code>difflib</code>). Lo mismo para las políticas. Cambias un YAML → nueva versión; promueves cambiando <code>active_version</code>. Sin redeploy.</p>
<div class="cols">
<div>
<h4>El índice (<code>agents/incident_analyzer/index.yaml</code>)</h4>
<div class="code">
<div class="hd"><span class="dot"></span><span class="fn">index.yaml</span><span class="lang">yaml</span></div>
<pre><span class="y">name</span>: incident_analyzer
<span class="y">versions</span>:
- <span class="y">id</span>: v1
<span class="y">hash</span>: pending
<span class="y">author</span>: Juan
<span class="y">message</span>: Versión inicial; SIP/IMS y MOS básicos.
<span class="y">created_at</span>: 2026-04-12T10:00:00Z
- <span class="y">id</span>: v2
<span class="y">hash</span>: pending
<span class="y">author</span>: Juan
<span class="y">message</span>: Añade codec mismatch y refuerza prompts.
<span class="y">created_at</span>: 2026-05-01T12:00:00Z
<span class="y">active_version</span>: v2 <span class="c"># ← invoke usa esta si no pides otra</span></pre>
</div>
</div>
<div>
<h4>El "commit" activo (<code>versions/v2.yaml</code>)</h4>
<div class="code">
<div class="hd"><span class="dot"></span><span class="fn">versions/v2.yaml</span><span class="lang">yaml</span></div>
<pre><span class="y">name</span>: incident_analyzer
<span class="y">version</span>: v2
<span class="y">owner</span>: Juan
<span class="y">state</span>: active <span class="c"># draft | active | deprecated</span>
<span class="y">guardrails</span>: [default] <span class="c"># nombres de política</span>
<span class="y">llm</span>:
<span class="y">provider</span>: mock
<span class="y">model</span>: gpt-4o
<span class="y">temperature</span>: <span class="n">0.1</span>
<span class="y">max_tokens</span>: <span class="n">2000</span>
<span class="y">system_prompt</span>: |
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. …
<span class="y">output_schema</span>:
<span class="y">type</span>: object
<span class="y">required</span>: [severity, root_cause_hypothesis, proposed_actions]
<span class="y">risk_threshold_for_hitl</span>: <span class="n">4</span> <span class="c"># risk_score ≥ 4 (o requires_approval) → HITL</span></pre>
</div>
</div>
</div>
<div class="callout tip">
<div class="ct">🔎 La "magia tipo Git"</div>
<p><code>registry/versioning.py</code> tiene <code>compute_hash(yaml_text)</code> (SHA-256 del YAML con espacios al final recortados) y <code>unified_diff(a, b, label_a, label_b)</code>. El endpoint <code>GET /agents/{name}/versions/{v_from}/diff/{v_to}</code> devuelve un <code>DiffResult</code> con el <code>unified_diff</code>; el dashboard lo pinta coloreado (<code>+</code> verde, <code>-</code> rojo, <code>@@</code> azul) en la página <em>Registro</em>. (En los <code>index.yaml</code> de ejemplo el <code>hash</code> es <code>"pending"</code> y nadie lo valida al cargar — es un MVP.)</p>
</div>
</section>
<!-- ============================================================= -->
<section id="persistencia">
<h2><span class="kicker">11</span> Persistencia: cuatro formas, cuatro razones</h2>
<p>AgentForge no usa una sola base de datos; usa la herramienta adecuada para cada cosa.</p>
<figure class="diagram">
<svg viewBox="0 0 1020 420" role="img" aria-label="Mapa de persistencia: quién escribe y lee qué">
<defs><marker id="ah7" markerWidth="9" markerHeight="9" refX="7" refY="4" orient="auto-start-reverse"><path d="M0,0 L8,4 L0,8 Z" fill="context-stroke"/></marker></defs>
<!-- core in the middle -->
<rect class="dg-box violet" x="396" y="170" width="228" height="80" rx="12"/>
<text class="dg-t sm" x="510" y="196" text-anchor="middle">agentforge-core</text>
<text class="dg-s" x="510" y="214" text-anchor="middle">registry · orchestrator</text>
<text class="dg-s" x="510" y="230" text-anchor="middle">api/persistence · runtime/checkpointer</text>
<!-- YAML (top-left) -->
<rect class="dg-box" x="40" y="40" width="280" height="76" rx="11"/>
<text class="dg-t sm" x="56" y="64">YAML — agents/*.yaml · policies/*.yaml</text>
<text class="dg-s" x="56" y="82">lo escribe un humano (legible, comentable)</text>
<text class="dg-s" x="56" y="98">lo cataloga la máquina (index.yaml + versiones)</text>
<path class="dg-edge dashed" d="M 320 95 C 360 110, 380 150, 410 175" fill="none" marker-end="url(#ah7)"/>
<text class="dg-el bg" x="358" y="138" text-anchor="middle" style="font-weight:400">lee · diff · hash</text>
<!-- SQLite (top-right) -->
<rect class="dg-box warn" x="700" y="40" width="280" height="76" rx="11"/>
<text class="dg-t sm" x="716" y="64">SQLite — data/checkpoints.sqlite</text>
<text class="dg-s" x="716" y="82">estado intermedio del grafo, incluida la pausa HITL</text>
<text class="dg-s" x="716" y="98">vía LangGraph AsyncSqliteSaver · permite reanudar</text>
<path class="dg-edge warn" d="M 700 95 C 660 110, 640 150, 612 175" fill="none" marker-end="url(#ah7)" marker-start="url(#ah7)"/>
<text class="dg-el bg" x="660" y="138" text-anchor="middle">escribe/lee en cada transición</text>
<!-- JSON index (bottom-left) -->
<rect class="dg-box" x="40" y="300" width="280" height="76" rx="11"/>
<text class="dg-t sm" x="56" y="324">JSON — data/execution_index.json</text>
<text class="dg-s" x="56" y="342">{ trace_id: { agent_name, version } }</text>
<text class="dg-s" x="56" y="358">para reconstruir la config al reanudar un HITL</text>
<path class="dg-edge" d="M 410 240 C 380 270, 360 290, 320 322" fill="none" marker-end="url(#ah7)" marker-start="url(#ah7)"/>
<text class="dg-el bg" x="360" y="282" text-anchor="middle">_record_execution en cada invoke · _resolve lo lee</text>
<!-- JSONL executions (bottom-mid) -->
<rect class="dg-box ok" x="370" y="318" width="280" height="76" rx="11"/>
<text class="dg-t sm" x="386" y="342">JSONL append-only — data/executions.jsonl</text>
<text class="dg-s" x="386" y="360">un AgentExecution por línea · inmutable, auditable</text>
<text class="dg-s" x="386" y="376">se escribe si el status es terminal (y siempre tras approve/reject)</text>
<line class="dg-edge ok" x1="510" y1="250" x2="510" y2="318" marker-end="url(#ah7)"/>
<text class="dg-el bg" x="528" y="288" text-anchor="middle">append_execution</text>
<!-- JSONL violations (bottom-right) -->
<rect class="dg-box danger" x="700" y="300" width="280" height="76" rx="11"/>
<text class="dg-t sm" x="716" y="324">JSONL append-only — data/violations.jsonl</text>
<text class="dg-s" x="716" y="342">una GuardrailViolation por línea</text>
<text class="dg-s" x="716" y="358">se escribe una por violación de la ejecución</text>
<path class="dg-edge danger" d="M 612 240 C 650 270, 670 290, 700 322" fill="none" marker-end="url(#ah7)"/>
<text class="dg-el bg" x="668" y="282" text-anchor="middle">append_violation</text>
<!-- read endpoints note -->
<text class="dg-frame-lbl" x="40" y="160" fill="var(--muted)">GET /executions lee executions.jsonl + reconstruye las awaiting_approval del índice + checkpointer. GET /violations lee violations.jsonl.</text>
</svg>
<figcaption><b>Figura 7.</b> 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).</figcaption>
</figure>
<div class="tbl-wrap">
<table>
<thead><tr><th>Qué</th><th>Cómo</th><th>Por qué así</th></tr></thead>
<tbody>
<tr><td>Definiciones de agentes y políticas</td><td><strong>YAML</strong> por versión + <code>index.yaml</code></td><td>Lo escribe un humano (legible, comentable); lo cataloga la máquina.</td></tr>
<tr><td>Identidad de versiones / comparación</td><td><strong>hash SHA-256</strong> del contenido + <code>difflib</code></td><td>Versiones inmutables identificables y comparables — "tipo Git".</td></tr>
<tr><td>Mapa <code>trace_id → agente/versión</code></td><td><strong>JSON</strong> (<code>execution_index.json</code>)</td><td><code>/approve</code> y <code>/reject</code> solo reciben el <code>trace_id</code>: hay que reconstruir qué config lo ejecutó, y debe sobrevivir a reinicios.</td></tr>
<tr><td>Log de ejecuciones terminales y de violaciones</td><td><strong>JSONL append-only</strong></td><td>Inmutable, auditable, trivial de "shipear" a un sistema de logs.</td></tr>
<tr><td>Estado intermedio del grafo (incl. pausas HITL)</td><td><strong>SQLite</strong> (<code>checkpoints.sqlite</code>, vía LangGraph)</td><td>Es lo que LangGraph espera; permite reanudar una ejecución pausada entre reinicios del proceso.</td></tr>
</tbody>
</table>
</div>
</section>
<!-- ============================================================= -->
<section id="observabilidad">
<h2><span class="kicker">12</span> Trazabilidad: el hilo del <code>trace_id</code></h2>
<p>Cada petición lleva un <code>trace_id</code> (UUID) que recorre todo el sistema. <code>structlog</code> emite JSON, una línea por evento, con ese <code>trace_id</code> en el contexto. Y cada nodo del grafo deja un <code>DecisionStep</code> en el <code>decision_path</code> con su <code>duration_ms</code> — la "caja negra" de la ejecución.</p>
<div class="flow">
<div class="step"><div class="st">Request entra</div><div class="sd">¿trae X-Trace-Id? si no, uuid4()</div></div>
<div class="arr"></div>
<div class="step"><div class="st">TraceIdMiddleware</div><div class="sd">bind_trace_id(...) en structlog contextvars</div></div>
<div class="arr"></div>
<div class="step"><div class="st">Routers · Orchestrator</div><div class="sd">todos los logs llevan trace_id; thread_id del grafo = trace_id</div></div>
<div class="arr"></div>
<div class="step"><div class="st">Nodos del grafo</div><div class="sd">cada uno añade un DecisionStep {step, timestamp, duration_ms, detail}</div></div>
<div class="arr"></div>
<div class="step"><div class="st">Guardrails</div><div class="sd">cada GuardrailViolation lleva el trace_id</div></div>
<div class="arr"></div>
<div class="step"><div class="st">Persistencia</div><div class="sd">executions.jsonl · violations.jsonl · execution_index.json</div></div>
<div class="arr"></div>
<div class="step"><div class="st">Response sale</div><div class="sd">cabecera X-Trace-Id (= el mismo)</div></div>
</div>
<p class="muted">Detalles del logging: <code>observability/logging.py</code> configura <code>structlog</code> con <code>JSONRenderer</code>, <code>TimeStamper(iso)</code>, niveles, y <code>merge_contextvars</code>. <code>bind_trace_id</code> / <code>clear_trace_id</code> manejan el contexto por request.</p>
</section>
<!-- ============================================================= -->
<section id="estados">
<h2><span class="kicker">13</span> Estados de una ejecución</h2>
<p>El <code>status</code> de un <code>AgentExecution</code> es uno de cinco. Tres son terminales (<span class="pill ok">completed</span>, <span class="pill danger">failed</span>, <span class="pill danger">blocked_by_guardrail</span>); <span class="pill warn">awaiting_approval</span> es el único que persiste indefinidamente esperando a un humano.</p>
<figure class="diagram">
<svg viewBox="0 0 980 430" role="img" aria-label="Máquina de estados de una ejecución">
<defs><marker id="ah8" markerWidth="9" markerHeight="9" refX="7" refY="4" orient="auto-start-reverse"><path d="M0,0 L8,4 L0,8 Z" fill="context-stroke"/></marker></defs>
<circle cx="60" cy="200" r="9" fill="var(--text)"/>
<line class="dg-edge" x1="69" y1="200" x2="120" y2="200" marker-end="url(#ah8)"/>
<rect class="dg-box accent" x="120" y="172" width="150" height="56" rx="10"/>
<text class="dg-t sm" x="195" y="196" text-anchor="middle">running</text>
<text class="dg-s" x="195" y="214" text-anchor="middle">recorriendo el grafo</text>
<!-- to blocked -->
<rect class="dg-box danger" x="370" y="40" width="200" height="56" rx="10"/>
<text class="dg-t sm" x="470" y="64" text-anchor="middle">blocked_by_guardrail</text>
<text class="dg-s" x="470" y="82" text-anchor="middle">un validador con blocked=True</text>
<path class="dg-edge danger" d="M 230 172 C 260 110, 320 80, 370 70" fill="none" marker-end="url(#ah8)"/>
<text class="dg-el bg" x="300" y="118" text-anchor="middle">validate_input / validate_output</text>
<!-- to failed -->
<rect class="dg-box danger" x="370" y="344" width="200" height="56" rx="10"/>
<text class="dg-t sm" x="470" y="368" text-anchor="middle">failed</text>
<text class="dg-s" x="470" y="386" text-anchor="middle">llm_unavailable · output_schema_mismatch · internal_error · rejected_by_human</text>
<path class="dg-edge danger" d="M 230 228 C 260 300, 320 340, 370 360" fill="none" marker-end="url(#ah8)"/>
<text class="dg-el bg" x="295" y="305" text-anchor="middle">error del LLM / parseo / crash</text>
<!-- to awaiting -->
<rect class="dg-box warn" x="370" y="172" width="210" height="56" rx="10"/>
<text class="dg-t sm" x="475" y="196" text-anchor="middle">awaiting_approval</text>
<text class="dg-s" x="475" y="214" text-anchor="middle">pausado en interrupt() · estado en SQLite</text>
<line class="dg-edge warn" x1="270" y1="200" x2="370" y2="200" marker-end="url(#ah8)"/>
<text class="dg-el bg" x="320" y="192" text-anchor="middle">approve_gate: hay riesgo</text>
<!-- awaiting self-loop: survives restart -->
<path class="dg-edge warn dashed" d="M 560 178 C 600 150, 600 250, 560 222" fill="none" marker-end="url(#ah8)"/>
<text class="dg-el bg" x="620" y="200" style="text-anchor:start">sobrevive a reinicios</text>
<!-- to completed -->
<rect class="dg-box ok" x="730" y="100" width="200" height="56" rx="10"/>
<text class="dg-t sm" x="830" y="124" text-anchor="middle">completed</text>
<text class="dg-s" x="830" y="142" text-anchor="middle">final_output con approved_actions</text>
<!-- to failed from awaiting (reject) -->
<path class="dg-edge danger" d="M 540 228 C 580 300, 480 340, 470 344" fill="none" marker-end="url(#ah8)"/>
<text class="dg-el bg" x="585" y="270" style="text-anchor:start">/reject → rejected_by_human</text>
<!-- awaiting -> completed (approve) -->
<path class="dg-edge ok" d="M 580 188 C 660 160, 680 140, 730 132" fill="none" marker-end="url(#ah8)"/>
<text class="dg-el bg" x="660" y="150" text-anchor="middle">/approve</text>
<!-- running -> completed (no HITL) -->
<path class="dg-edge ok" d="M 270 188 C 420 140, 520 80, 680 80 C 740 80, 760 95, 760 100" fill="none" marker-end="url(#ah8)"/>
<text class="dg-el bg" x="520" y="74" text-anchor="middle">sin acciones arriesgadas → finalize directo</text>
<!-- terminal markers -->
<circle cx="470" cy="20" r="6" fill="none" stroke="var(--danger)" stroke-width="2"/><line x1="470" y1="26" x2="470" y2="40" class="dg-edge danger thin"/>
<circle cx="830" cy="180" r="7" fill="none" stroke="var(--ok)" stroke-width="2"/><circle cx="830" cy="180" r="3" fill="var(--ok)"/><line x1="830" y1="156" x2="830" y2="173" class="dg-edge ok thin" opacity="0"/>
</svg>
<figcaption><b>Figura 8.</b> Estados de un <code>AgentExecution</code>. <code>running</code> → puede acabar <em>directamente</em> en <code>completed</code> (si no hay acciones arriesgadas), o en <code>blocked_by_guardrail</code> / <code>failed</code>, o quedarse en <code>awaiting_approval</code> hasta que un humano hace <code>/approve</code> (→ <code>completed</code>) o <code>/reject</code> (→ <code>failed</code>). El estado <code>awaiting_approval</code> sobrevive a un reinicio del proceso. (Estados de un <em>agente</em>, en su YAML: <code>draft → active → deprecated</code> — concepto distinto.)</figcaption>
</figure>
</section>
<!-- ============================================================= -->
<section id="api">
<h2><span class="kicker">14</span> Referencia de la API REST</h2>
<p>El core expone una API limpia. Todos los endpoints pasan por <code>TraceIdMiddleware</code> (lee/crea <code>X-Trace-Id</code>, lo devuelve en la respuesta). Los <code>response_model</code> son los modelos del dominio. Es exactamente lo que llama el dashboard (vía <code>CoreClient</code>).</p>
<div class="tbl-wrap">
<table>
<thead><tr><th>Método · ruta</th><th>Qué hace</th><th>Errores</th></tr></thead>
<tbody>
<tr><td><span class="pill ok">GET</span> <code>/health</code></td><td><code>{"status": "ok"}</code> — usado por los healthchecks de Docker.</td><td></td></tr>
<tr><td><span class="pill ok">GET</span> <code>/agents</code></td><td>Lista de <code>AgentDefinition</code> (la versión activa de cada agente).</td><td></td></tr>
<tr><td><span class="pill ok">GET</span> <code>/agents/{name}</code></td><td>El agente (versión activa).</td><td><span class="pill warn">404</span> no existe</td></tr>
<tr><td><span class="pill ok">GET</span> <code>/agents/{name}/versions</code></td><td>Lista de <code>AgentVersionMeta</code> (id, hash, author, message, created_at).</td><td><span class="pill warn">404</span></td></tr>
<tr><td><span class="pill ok">GET</span> <code>/agents/{name}/versions/{version}</code></td><td>El <code>AgentDefinition</code> de esa versión concreta.</td><td><span class="pill warn">404</span></td></tr>
<tr><td><span class="pill ok">GET</span> <code>/agents/{name}/versions/{v_from}/diff/{v_to}</code></td><td><code>DiffResult</code> con el <code>unified_diff</code> entre dos versiones.</td><td></td></tr>
<tr><td><span class="pill accent">POST</span> <code>/agents/{name}/invoke</code></td><td><strong>Lanza una ejecución.</strong> Body <code>{input: str, version?: str}</code>. Resuelve agente+política, llama a <code>orchestrator.invoke</code>, anota el índice; si el status es terminal escribe en <code>executions.jsonl</code>; escribe las violaciones en <code>violations.jsonl</code>. Devuelve un <code>AgentExecution</code>.</td><td><span class="pill warn">404</span> agente · <span class="pill warn">422</span> sin política</td></tr>
<tr><td><span class="pill ok">GET</span> <code>/executions</code></td><td>Lista de <code>AgentExecutionSummary</code>: las terminales del JSONL <em>más</em> las <code>awaiting_approval</code> reconstruidas desde <code>execution_index.json</code> + el checkpointer.</td><td></td></tr>
<tr><td><span class="pill ok">GET</span> <code>/executions/{trace_id}</code></td><td>El <code>AgentExecution</code> completo (vía <code>orchestrator.snapshot</code>).</td><td><span class="pill warn">404</span> · <span class="pill warn">500</span> si la config referida falta</td></tr>
<tr><td><span class="pill accent">POST</span> <code>/executions/{trace_id}/approve</code></td><td>Reanuda un HITL. Body <code>{approved_action_ids: list[str], comment?: str}</code>. <code>resume(decision={...rejected:false})</code><code>finalize</code> filtra a las aprobadas → <code>completed</code>. Escribe en <code>executions.jsonl</code>.</td><td><span class="pill warn">404</span> · <span class="pill warn">409</span> si no está en <code>awaiting_approval</code></td></tr>
<tr><td><span class="pill accent">POST</span> <code>/executions/{trace_id}/reject</code></td><td>Rechaza un HITL. Body <code>{reason: str}</code>. <code>resume(decision={...rejected:true})</code><code>status = failed</code>, <code>error = rejected_by_human</code>. Escribe en <code>executions.jsonl</code>.</td><td><span class="pill warn">404</span> · <span class="pill warn">409</span></td></tr>
<tr><td><span class="pill ok">GET</span> <code>/policies</code></td><td>Lista de <code>PolicyDefinition</code> (validadores de entrada/salida y su config).</td><td></td></tr>
<tr><td><span class="pill ok">GET</span> <code>/policies/{name}/versions</code></td><td>Lista de <code>PolicyVersionMeta</code>.</td><td><span class="pill warn">404</span></td></tr>
<tr><td><span class="pill ok">GET</span> <code>/violations?trace_id=&severity=</code></td><td>Lista de <code>GuardrailViolation</code> desde <code>violations.jsonl</code>, con filtros opcionales por <code>trace_id</code> y <code>severity</code> (<code>info|warning|block</code>).</td><td></td></tr>
<tr><td colspan="3" class="muted">Además, FastAPI sirve <code>/docs</code> (Swagger UI) y <code>/openapi.json</code> automáticamente. El walkthrough que estás leyendo no es un endpoint: es un fichero (<code>docs/walkthrough.html</code>) — ver §<a href="#endpoint">19</a>.</td></tr>
</tbody>
</table>
</div>
<p class="muted">Helpers internos de <code>executions.py</code> alrededor del índice (<code>data/execution_index.json</code>): <code>_record_execution</code> (en cada <code>invoke</code>), <code>_resolve</code> (reconstruye <code>agent_def</code>/<code>policy</code>; 404/500), <code>_ensure_awaiting</code> (404/409).</p>
</section>
<!-- ============================================================= -->
<section id="agente">
<h2><span class="kicker">15</span> El agente de ejemplo: <code>incident_analyzer</code></h2>
<p>El repo trae un agente de operaciones de telco y un proveedor LLM <strong>mock determinista</strong> para que el demo funcione out-of-the-box, sin API keys ni red. <code>MockProvider</code> mira el texto de entrada y elige una de tres respuestas canónicas buscando subcadenas (<code>"sip"</code>, <code>"mos"</code>, <code>"hss"</code>, 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 <code>severity</code>, <code>root_cause_hypothesis</code> y <code>proposed_actions</code>.</p>
<div class="tbl-wrap">
<table>
<thead><tr><th>Escenario (<code>examples/*.txt</code>)</th><th>Keyword</th><th>Respuesta del mock — acciones propuestas</th><th>¿HITL?</th></tr></thead>
<tbody>
<tr>
<td><code>01_sip_registration_drop</code><br><span class="muted">caída de registros SIP tras desplegar imagen en CSCF</span></td>
<td><code>"sip"</code></td>
<td><code>act-1</code> <code>rollback_image</code> en <code>cscf-cluster-aravaca-01</code> · <span class="pill warn">risk 4</span> · <code>requires_approval</code> &nbsp;|&nbsp; <code>act-2</code> <code>drain_traffic_to_standby</code> · <span class="pill">risk 3</span></td>
<td><span class="pill warn"></span><code>act-1</code> (risk 4 ≥ umbral 4 + requires_approval)</td>
</tr>
<tr>
<td><code>02_mos_degradation_pool_sbc</code><br><span class="muted">degradación de MOS en pool SBC en pico horario</span></td>
<td><code>"mos"</code></td>
<td><code>act-1</code> <code>scale_out_sbc_pool</code> en <code>sbc-pool-borde-norte</code> · <span class="pill">risk 2</span> · no requiere aprobación</td>
<td><span class="pill ok">no</span> — completa directo</td>
</tr>
<tr>
<td><code>03_hss_capacity_active_active</code><br><span class="muted">saturación replicada en HSS active-active</span></td>
<td><code>"hss"</code></td>
<td><code>act-1</code> <code>isolate_replication_link</code> en <code>hss-pair-madrid</code> · <span class="pill danger">risk 5</span> · <code>requires_approval</code></td>
<td><span class="pill warn"></span><code>act-1</code> (risk 5)</td>
</tr>
</tbody>
</table>
</div>
<figure class="diagram">
<svg viewBox="0 0 1000 230" role="img" aria-label="Flujo del escenario SIP a través del mock">
<defs><marker id="ah9" markerWidth="9" markerHeight="9" refX="7" refY="4" orient="auto-start-reverse"><path d="M0,0 L8,4 L0,8 Z" fill="context-stroke"/></marker></defs>
<rect class="dg-box" x="20" y="80" width="180" height="64" rx="10"/><text class="dg-t sm" x="110" y="104" text-anchor="middle">input del incidente</text><text class="dg-s" x="110" y="122" text-anchor="middle">"…caída de registros SIP…"</text>
<rect class="dg-box accent" x="240" y="80" width="160" height="64" rx="10"/><text class="dg-t sm" x="320" y="104" text-anchor="middle">MockProvider</text><text class="dg-s" x="320" y="122" text-anchor="middle">¿contiene "sip"? → sí</text>
<rect class="dg-box" x="440" y="80" width="200" height="64" rx="10"/><text class="dg-t sm" x="540" y="100" text-anchor="middle">respuesta canónica SIP</text><text class="dg-s" x="540" y="118" text-anchor="middle">JSON: severity=high, hypothesis,</text><text class="dg-s" x="540" y="134" text-anchor="middle">proposed_actions=[act-1, act-2]</text>
<rect class="dg-box" x="680" y="42" width="160" height="50" rx="10"/><text class="dg-t sm" x="760" y="64" text-anchor="middle">propose_actions</text><text class="dg-s" x="760" y="80" text-anchor="middle">[act-1 risk4, act-2 risk3]</text>
<rect class="dg-box warn" x="680" y="120" width="300" height="64" rx="10"/><text class="dg-t sm" x="830" y="142" text-anchor="middle" fill="var(--warn)">approve_gate</text><text class="dg-s" x="830" y="160" text-anchor="middle">act-1.risk_score(4) ≥ risk_threshold_for_hitl(4)</text><text class="dg-s" x="830" y="176" text-anchor="middle">→ interrupt() → awaiting_approval</text>
<line class="dg-edge" x1="200" y1="112" x2="240" y2="112" marker-end="url(#ah9)"/>
<line class="dg-edge accent" x1="400" y1="112" x2="440" y2="112" marker-end="url(#ah9)"/>
<path class="dg-edge" d="M 640 100 C 660 90, 665 75, 680 67" fill="none" marker-end="url(#ah9)"/>
<path class="dg-edge warn" d="M 760 92 C 780 100, 800 110, 820 120" fill="none" marker-end="url(#ah9)"/>
</svg>
<figcaption><b>Figura 9.</b> El escenario SIP de punta a punta a través del mock. La palabra <code>"sip"</code> en el input selecciona la respuesta canónica; <code>act-1</code> tiene <code>risk_score = 4</code>, igual al umbral del agente (<code>risk_threshold_for_hitl = 4</code>) y además <code>requires_approval = true</code>, así que <code>approve_gate</code> pausa la ejecución.</figcaption>
</figure>
<p>Lo que <em>no</em> es código: <code>agents/incident_analyzer/</code> (el YAML del agente + <code>examples/*.txt</code>), <code>policies/default/</code> (la política), y <code>data/</code> (estado runtime gitignored: <code>checkpoints.sqlite</code>, <code>executions.jsonl</code>, <code>violations.jsonl</code>, <code>execution_index.json</code> — se crea sola). Para usar Azure OpenAI o OpenAI reales: edita <code>.env</code> (<code>LLM_PROVIDER=azure|openai</code> + las claves). Los providers reales tienen retry exponencial 1s/2s/4s.</p>
</section>
<!-- ============================================================= -->
<section id="dashboard">
<h2><span class="kicker">16</span> El dashboard (Streamlit)</h2>
<p>Una consola visual con cinco páginas. <strong>No contiene lógica de negocio</strong>: <code>client.py</code> (<code>CoreClient</code>, httpx síncrono con 2 retries) envuelve todos los endpoints del core y mapea 404/409/422 a <code>{"api_error": …}</code> para que las páginas puedan distinguir "la API rechazó la petición" de "ejecución sin error". <code>app.py</code> es la raíz: sidebar de branding + health-check del core. <strong>Nadie del core depende del dashboard.</strong></p>
<div class="tbl-wrap">
<table>
<thead><tr><th>Página</th><th>Qué muestra</th><th>Endpoints que usa</th></tr></thead>
<tbody>
<tr><td>🏛️ <strong>Registro</strong><br><code>pages/1_…_Registro.py</code></td><td>Catálogo de agentes: detalle (prompt, esquema, LLM, guardrails), tabla de versiones, <strong>diff coloreado v1↔v2</strong>.</td><td><code>/agents</code>, <code>/agents/{n}</code>, <code>/agents/{n}/versions</code>, <code>/agents/{n}/versions/{a}/diff/{b}</code></td></tr>
<tr><td>▶️ <strong>Ejecutar</strong><br><code>pages/2_…_Ejecutar.py</code></td><td>Lanza un agente: botones con los escenarios pregrabados, textarea, <em>invoke</em>, y render del status + output + violaciones + <em>timeline</em> del <code>decision_path</code>. Avisa si quedó en <code>awaiting_approval</code>.</td><td><code>/agents</code>, <code>/agents/{n}/invoke</code></td></tr>
<tr><td>🤝 <strong>Aprobaciones</strong><br><code>pages/3_…_Aprobaciones.py</code></td><td>La cola de HITL: lista las ejecuciones <code>awaiting_approval</code>, muestra cada acción propuesta (risk_score coloreado, target, rollback_plan) con un checkbox, y aprueba el subconjunto elegido o rechaza con motivo.</td><td><code>/executions</code> (filtra), <code>/executions/{t}</code>, <code>/executions/{t}/approve</code>, <code>/executions/{t}/reject</code></td></tr>
<tr><td>📜 <strong>Historial</strong><br><code>pages/4_…_Historial.py</code></td><td>Pestaña de ejecuciones (tabla + detalle por <code>trace_id</code>) y pestaña de violaciones (filtrable por severidad).</td><td><code>/executions</code>, <code>/executions/{t}</code>, <code>/violations</code></td></tr>
<tr><td>📐 <strong>Politicas</strong><br><code>pages/5_…_Politicas.py</code></td><td>Inventario de políticas: validadores de entrada/salida (cada uno expandible con su config) y versiones.</td><td><code>/policies</code>, <code>/policies/{n}/versions</code></td></tr>
</tbody>
</table>
</div>
<p class="muted">Componentes reutilizables de UI: <code>components/diff_view</code> (pinta <code>+</code>/<code>-</code>/<code>@@</code>), <code>components/trace_view</code> (el timeline del <code>decision_path</code>), <code>components/violation_view</code> (badges de severidad). El dashboard no tiene tests unitarios (mal coste/beneficio para Streamlit); su verificación es la checklist manual de <code>docs/manual_qa.md</code>. Demo guiada en 3 pasos: <strong>Registro</strong> → comparar v1 vs v2 · <strong>Ejecutar</strong> → escenario SIP → pausa en HITL · <strong>Aprobaciones</strong> → aprobar las seguras → ejecución completa.</p>
</section>
<!-- ============================================================= -->
<section id="arranque">
<h2><span class="kicker">17</span> Arranque y configuración</h2>
<p>Out-of-the-box, sin claves: <code>cp .env.example .env</code> y <code>docker compose up</code> → dashboard en <a href="http://localhost:8501">localhost:8501</a> y API en <code>localhost:8000</code>. (Este documento se consulta abriendo <code>docs/walkthrough.html</code> en el navegador — no requiere servidor.)</p>
<div class="cols">
<div>
<h4>El ciclo de vida del proceso core</h4>
<ol>
<li>Importar <code>agentforge_core.main</code> ejecuta <code>app = create_app()</code>: <code>Settings()</code><code>configure_logging(level)</code><code>FastAPI(...)</code><code>add_middleware(TraceIdMiddleware)</code> → registra <code>GET /health</code> → importa los routers → <code>include_router</code> (agents, executions ×2, policies, violations).</li>
<li>Las dependencias (<code>get_registry</code>, <code>get_policy_store</code>, <code>get_llm_provider</code>, <code>get_guardrail_engine</code>, <code>get_orchestrator</code>) <strong>no</strong> se construyen aún; se construyen y cachean en la <strong>primera request</strong> que las inyecta.</li>
<li>Cada request: <code>TraceIdMiddleware.dispatch</code> → router → resuelve <code>Depends(...)</code> (que puede disparar la construcción perezosa) → handler → respuesta con <code>X-Trace-Id</code>.</li>
</ol>
<p class="muted">En contenedores: <code>uvicorn agentforge_core.main:app --host 0.0.0.0 --port 8000</code>; <code>HEALTHCHECK</code><code>curl /health</code>; monta <code>./agents:ro</code>, <code>./policies:ro</code>, <code>./data:rw</code>; <code>DATA_DIR=/app/data</code>, etc. El dashboard depende de <code>core: service_healthy</code> y usa <code>AGENTFORGE_CORE_URL=http://core:8000</code>. La imagen del core instala <code>en_core_web_sm</code> de spaCy para Presidio.</p>
</div>
<div>
<h4>Variables de entorno (<code>config.py</code> · <code>.env</code>)</h4>
<div class="tbl-wrap">
<table>
<thead><tr><th>Var</th><th>Default</th><th>Lo usa</th></tr></thead>
<tbody>
<tr><td><code>LLM_PROVIDER</code></td><td><code>mock</code></td><td><code>build_llm_provider</code> (mock|azure|openai)</td></tr>
<tr><td><code>LLM_FALLBACK_PROVIDER</code></td><td><code>""</code></td><td>declarado; la factory aún no lo aplica</td></tr>
<tr><td><code>AZURE_OPENAI_*</code></td><td><code>""</code> / <code>2024-08-01-preview</code></td><td><code>AzureOpenAIProvider</code></td></tr>
<tr><td><code>OPENAI_API_KEY</code> · <code>OPENAI_MODEL</code></td><td><code>""</code> · <code>gpt-4o</code></td><td><code>OpenAIProvider</code></td></tr>
<tr><td><code>GUARDRAILS_NEMO_ENABLED</code></td><td><code>false</code></td><td><code>build_guardrail_engine</code> (añade NeMo al composite)</td></tr>
<tr><td><code>LOG_LEVEL</code></td><td><code>INFO</code></td><td><code>configure_logging</code></td></tr>
<tr><td><code>DATA_DIR</code></td><td><code>./data</code></td><td>checkpointer · índice · JSONL</td></tr>
<tr><td><code>AGENTS_DIR</code></td><td><code>./agents</code></td><td><code>FileSystemAgentRegistry</code></td></tr>
<tr><td><code>POLICIES_DIR</code></td><td><code>./policies</code></td><td><code>FileSystemPolicyStore</code></td></tr>
<tr><td><code>AGENTFORGE_CORE_URL</code></td><td><code>http://core:8000</code></td><td>el dashboard (<code>CoreClient</code>)</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<div class="callout warn">
<div class="ct">⚠️ Gotchas</div>
<p><code>detect_pii</code> se comporta distinto según el entorno: con <code>presidio-analyzer</code> instalado (imagen Docker) usa Presidio + <code>en_core_web_sm</code>; sin él (venv local típico), regex fallback. · <code>AsyncSqliteSaver</code> liga su conexión al event loop activo: por eso el checkpointer es un <em>async context manager</em> que el orchestrator abre y cierra en cada operación; requiere <code>aiosqlite&lt;0.21</code>. · <code>data/</code> debe existir y ser escribible por el usuario del contenedor (<code>agent</code>, uid 1000). · <code>build_llm_provider</code> usa <code>match</code> sin <code>case _</code> (exhaustivo sobre el <code>Literal</code> de 3 valores). · NeMo solo entra al composite si <code>GUARDRAILS_NEMO_ENABLED=true</code>.</p>
</div>
</section>
<!-- ============================================================= -->
<section id="tests">
<h2><span class="kicker">18</span> Tests</h2>
<p>Hay una suite de <strong>unit + integration</strong> (~90 tests). <code>make test</code> corre solo los unit; <code>make test-all</code> añade los de integración; <code>make lint</code> es <code>ruff</code> + <code>mypy</code> (estricto); <code>make smoke</code> levanta <code>docker compose</code> y hace <code>curl /health</code>.</p>
<ul>
<li><strong><code>tests/unit/</code></strong> — dominio (<code>agent</code>, <code>execution</code>, <code>guardrail</code>, <code>policy</code>), <code>config</code>, <code>llm</code> (base, mock, factory), <code>guardrails</code> (ai, composite), <code>registry</code> (repository, policy_store, versioning, factory), <code>runtime</code> (state-graph, nodes, orchestrator), <code>observability/logging</code>, y los routers de la API (<code>agents</code>, <code>executions</code>, <code>policies/violations</code>, <code>health</code>) — estos usan <code>tests/fixtures/</code> (versiones mínimas) y hacen <code>deps.*.cache_clear()</code> tras apuntar <code>DATA_DIR</code> a un <code>tmp_path</code>.</li>
<li><strong><code>tests/integration/</code></strong> — montan la app con <code>TestClient</code> contra los <em>assets reales</em> del repo (<code>agents/</code>, <code>policies/</code>): <code>test_invoke_happy_path</code>, <code>test_invoke_hitl</code> (el ciclo completo en ~40 líneas — el mejor sitio para entenderlo), <code>test_invoke_pii_block</code>, <code>test_invoke_resume_after_restart</code> (prueba que un <code>awaiting_approval</code> sobrevive a recrear el orchestrator).</li>
<li>El dashboard Streamlit no se prueba con unit tests; se verifica con la checklist de <code>docs/manual_qa.md</code>.</li>
</ul>
</section>
<!-- ============================================================= -->
<section id="endpoint">
<h2><span class="kicker">19</span> Este documento — un HTML autocontenido</h2>
<p>Lo que estás leyendo es un <strong>único fichero HTML autocontenido</strong><code>docs/walkthrough.html</code> 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 <strong>no necesita que ningún servidor esté corriendo</strong>: se abre directamente en cualquier navegador.</p>
<div class="code">
<div class="hd"><span class="dot"></span><span class="fn">abrir el walkthrough — no hace falta docker ni el core</span><span class="lang">bash</span></div>
<pre>xdg-open docs/walkthrough.html <span class="c"># Linux</span>
open docs/walkthrough.html <span class="c"># macOS</span>
<span class="c"># o arrástralo al navegador · o file://&lt;ruta-del-repo&gt;/docs/walkthrough.html</span></pre>
</div>
<div class="callout tip">
<div class="ct">🔌 ¿Servirlo desde la API?</div>
<p>Sería trivial si se quisiera: un router de FastAPI de ~10 líneas que devuelva este fichero como <code>HTMLResponse</code> (p. ej. bajo <code>GET /walkthrough</code>), montado aparte de los routers de negocio. En esta versión del repo no está cableado — el documento se consulta como fichero.</p>
</div>
</section>
<!-- ============================================================= -->
<section id="glosario">
<h2><span class="kicker">20</span> Glosario</h2>
<div class="tbl-wrap">
<table>
<thead><tr><th>Término</th><th>Significado en este proyecto</th></tr></thead>
<tbody>
<tr><td><strong>Agente</strong></td><td>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.</td></tr>
<tr><td><strong>Política (de guardrails)</strong></td><td>Un YAML con la lista de validadores de entrada y salida y su config, más <code>on_validator_error</code> (<code>fail_closed</code>/<code>fail_open</code>).</td></tr>
<tr><td><strong>Validador / guardrail</strong></td><td>Una función que mira el texto de entrada o la salida del LLM y devuelve cero o más <code>GuardrailViolation</code>. Ej.: <code>detect_pii</code>, <code>prompt_injection</code>, <code>schema_match</code>, <code>telco_safety_rules</code>.</td></tr>
<tr><td><strong>Violación</strong></td><td>El resultado de un validador: <code>stage</code> (input/output), <code>validator</code>, <code>severity</code> (<code>info</code>/<code>warning</code>/<code>block</code>), <code>message</code>, <code>blocked</code>.</td></tr>
<tr><td><strong>Ejecución (<code>AgentExecution</code>)</strong></td><td>El "expediente" de una invocación: <code>trace_id</code>, status, <code>decision_path</code>, <code>violations</code>, <code>proposed_actions</code>, <code>needs_human_for</code>, <code>final_output</code>, <code>error</code>.</td></tr>
<tr><td><strong><code>decision_path</code></strong></td><td>La lista de pasos por los que pasó el grafo, cada uno con <code>step</code>, <code>timestamp</code>, <code>duration_ms</code> y un <code>detail</code>. La "caja negra" de la ejecución.</td></tr>
<tr><td><strong>HITL</strong></td><td>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 <code>interrupt()</code> de LangGraph.</td></tr>
<tr><td><strong>Checkpointer</strong></td><td>El componente de LangGraph que persiste el estado del grafo (aquí, en SQLite). Lo que hace posible reanudar un HITL tras un reinicio.</td></tr>
<tr><td><strong><code>trace_id</code></strong></td><td>UUID que identifica una petición/ejecución y se propaga por middleware → logs → API → ficheros de auditoría.</td></tr>
<tr><td><strong>Orchestrator</strong></td><td>La única clase que habla con LangGraph (<code>invoke</code>/<code>resume</code>/<code>snapshot</code>); aísla al resto del sistema del runtime.</td></tr>
<tr><td><strong>Factory</strong></td><td>Función <code>build_X(settings)</code> que monta la implementación de <code>X</code> adecuada según la configuración.</td></tr>
<tr><td><strong>Status de ejecución</strong></td><td><code>running</code>, <code>awaiting_approval</code> (pausada en HITL), <code>blocked_by_guardrail</code>, <code>completed</code>, <code>failed</code>.</td></tr>
<tr><td><strong>Estados de un agente</strong></td><td><code>draft</code>, <code>active</code>, <code>deprecated</code> (metadato en su YAML).</td></tr>
</tbody>
</table>
</div>
</section>
<!-- ============================================================= -->
<section id="roadmap">
<h2><span class="kicker">21</span> Lo que aún no hace (a propósito)</h2>
<p>Es un MVP. En el roadmap (<code>docs/futuro.md</code>): <strong>NeMo Guardrails completo</strong> (Colang + KB embedding + dialog rails) · <strong>autenticación</strong> (OAuth2/OIDC, MSAL para Azure AD) · <strong>OpenTelemetry</strong> (spans por nodo, métricas de violaciones por validador) · <strong>multi-tenant</strong> (<code>tenant_id</code> aislado) · <strong>evaluadores LLM-as-judge</strong> (regression suite sobre escenarios canónicos, mide deriva) · <strong>UI de aprobación con SLA</strong> (cola Kanban, reasignación, alertas) · <strong>persistencia migrable a Postgres + pgvector</strong> · <strong>esquema de prompts mejorado</strong> (variables tipadas tipo Jinja, valores por entorno) · <strong>promoción explícita draft → active</strong> vía PR/aprobación. La detección de PII más fina (modelos grandes, español), también pendiente.</p>
</section>
<!-- ============================================================= -->
<section id="leer">
<h2><span class="kicker">22</span> Por dónde empezar a leer (ruta de ~1 hora)</h2>
<ol>
<li><code>domain/execution.py</code> y <code>domain/agent.py</code> — qué datos hay. <em><a href="#dominio">06</a>)</em></li>
<li><code>policies/default/versions/v1.yaml</code> y <code>agents/incident_analyzer/versions/v2.yaml</code> — cómo se declara todo. <em><a href="#guardrails">07</a>, §<a href="#versionado">10</a>)</em></li>
<li><code>runtime/nodes.py</code> y <code>runtime/graph.py</code> — el flujo de ejecución. <em><a href="#runtime">08</a>)</em></li>
<li><code>api/executions.py</code> — cómo se expone (invoke / approve / reject). <em><a href="#api">14</a>)</em></li>
<li><code>tests/integration/test_invoke_hitl.py</code> — el ciclo completo, en ~40 líneas. <em><a href="#tests">18</a>)</em></li>
<li>Levanta <code>docker compose up</code>, haz clic por las cinco páginas del dashboard <em><a href="#dashboard">16</a>)</em>, y abre <code>docs/walkthrough.html</code> en el navegador <em><a href="#endpoint">19</a>)</em>.</li>
</ol>
<div class="callout key">
<div class="ct">📚 Documentos hermanos</div>
<p><code>README.md</code> — quickstart y estado. · <code>ARCHITECTURE.md</code> — decisiones técnicas en bruto. · <code>docs/explicacion.md</code> — la narrativa de extremo a extremo (en prosa). · <code>docs/componentes.md</code> — la referencia de cableado a bajo nivel (firmas exactas, grafos de dependencias, cadenas de llamada por endpoint). · <code>docs/futuro.md</code> — roadmap. · <code>docs/manual_qa.md</code> — checklist de verificación manual del dashboard.</p>
</div>
</section>
<!-- ============================================================= -->
<section id="codificar">
<h2><span class="kicker">23</span> Ruta de codificación manual — de cero a sistema funcionando</h2>
<p class="lead">Si quieres <strong>entender el proyecto escribiéndolo</strong>, 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.</p>
<div class="callout key">
<div class="ct">Principio rector</div>
<p><code>domain/</code> 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: <strong>adentro → afuera, abstracto → concreto, sin dependencias → con dependencias</strong>.</p>
</div>
<h3>Fase 1 — El vocabulario: <code>domain/</code> <em class="muted">(~30 min)</em></h3>
<p>Estos cuatro ficheros no importan nada del proyecto — solo Pydantic. Son el vocabulario de todo lo demás. Escribirlos primero te obliga a responder <em>"¿qué maneja este sistema?"</em> antes de pensar en cómo lo procesa.</p>
<ol>
<li><code>domain/agent.py</code><code>AgentDefinition</code>, <code>LLMConfig</code>, <code>AgentVersionMeta</code>. Al terminar sabrás exactamente qué es "un agente" en este sistema: una declaración Pydantic, no código Python.</li>
<li><code>domain/guardrail.py</code><code>GuardrailViolation</code>. Qué devuelve un guardrail cuando detecta un problema: una violación tipada, no una excepción.</li>
<li><code>domain/policy.py</code><code>PolicyDefinition</code>. La lista de reglas que rigen un agente concreto.</li>
<li><code>domain/execution.py</code> — el modelo más rico: <code>AgentExecution</code>, <code>ProposedAction</code>, <code>ExecutionStatus</code>, <code>DecisionStep</code>. Contiene el estado completo de una ejecución. Fíjate en que <code>AgentExecution</code> tiene <em>definición</em> del agente separada de su <em>instancia en runtime</em>.</li>
</ol>
<div class="callout tip">
<div class="ct">Momento clave #1</div>
<p>Al escribir <code>AgentExecution</code> te das cuenta de que el sistema separa <em>definir</em> un agente (YAML → Pydantic) de <em>ejecutarlo</em> (runtime → JSONL). Son dos ciclos de vida distintos.</p>
</div>
<h3>Fase 2 — Las costuras: los dos <code>Protocol</code>s <em class="muted">(~15 min)</em></h3>
<p>Los <code>Protocol</code>s son las interfaces estructurales del sistema — el contrato que separa <em>"qué hace"</em> de <em>"cómo lo hace"</em>. El grafo solo importa estos dos ficheros, nunca OpenAI ni guardrails-ai directamente.</p>
<ol>
<li><code>llm/base.py</code><code>LLMProvider</code> (un método <code>async complete()</code>), <code>Message</code>, <code>CompletionResult</code>. Dos clases Pydantic y un Protocol de cuatro líneas.</li>
<li><code>guardrails/base.py</code><code>GuardrailEngine</code>: dos métodos (<code>validate_input</code> y <code>validate_output</code>), ambos devuelven <code>list[GuardrailViolation]</code>.</li>
</ol>
<div class="callout tip">
<div class="ct">Momento clave #2</div>
<p>Escribir estos dos Protocols antes que cualquier implementación te revela por qué cambiar el proveedor LLM de <code>mock</code> a <code>azure</code> no requiere tocar el grafo: el grafo nunca ve la implementación concreta.</p>
</div>
<h3>Fase 3 — Primera ejecución posible: mocks + YAML del agente <em class="muted">(~45 min)</em></h3>
<p>Con dominios e interfaces ya puedes ejecutar el sistema sin ninguna clave de API. Los mocks son tu andamio.</p>
<ol>
<li><code>llm/mock.py</code> — implementa <code>LLMProvider</code> devolviendo un JSON hardcodeado. ~30 líneas. Permite correr el grafo completo sin tocar OpenAI ni Azure.</li>
<li><code>guardrails/validators.py</code> — validadores keyword-based simples. Implementan <code>GuardrailEngine</code> sin dependencias externas.</li>
<li>El primer YAML del agente: <code>agents/incident_analyzer/versions/v1.yaml</code>. Escribe la definición declarativa (nombre, owner, purpose, guardrails, config LLM, system_prompt, output_schema).</li>
</ol>
<div class="callout tip">
<div class="ct">Momento clave #3</div>
<p>Al escribir el YAML del agente entiendes que un agente es <strong>pura configuración</strong>: no tiene código Python. Cambiar su comportamiento — modelo, temperatura, guardrails, threshold de HITL — es editar texto, no redeployar.</p>
</div>
<h3>Fase 4 — El corazón del sistema: estado → nodos → grafo <em class="muted">(~1.5 h)</em></h3>
<p>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.</p>
<ol>
<li>
<strong><code>runtime/state.py</code></strong> primero — <code>AgentState</code>: un <code>TypedDict</code> con todos los campos del estado mutable que fluye por el grafo.
El detalle a entender: <code>decision_path: Annotated[list, operator.add]</code> — no se sobreescribe, se acumula; LangGraph llama al reducer automáticamente cada vez que un nodo devuelve un <code>decision_path</code>.
</li>
<li>
<strong><code>runtime/nodes.py</code></strong> — los seis nodos, en el orden en que aparecerán en el grafo:
<ul>
<li><code>build_node_validate_input</code> — llama al engine, acumula violations, decide si poner <code>blocked_by_guardrail</code>.</li>
<li><code>build_node_llm_reason</code> — llama al provider, captura el fallo y lo transforma en <code>status="failed"</code>.</li>
<li><code>build_node_validate_output</code> — parsea el JSON del LLM y valida el output contra la política.</li>
<li><code>build_node_propose_actions</code> — extrae <code>proposed_actions</code> del output parseado.</li>
<li><code>build_node_approve_gate</code> — el nodo HITL: llama a <code>interrupt({"awaiting_actions": risky})</code> si hay acciones con riesgo ≥ threshold. La ejecución se pausa aquí hasta que llegue un <code>Command(resume=...)</code>.</li>
<li><code>build_node_finalize</code> — filtra acciones por las aprobadas, construye <code>final_output</code>.</li>
</ul>
<p>Nota el patrón: cada builder <em>captura sus dependencias por clausura</em> y devuelve una función <code>async (state) → dict</code>. Las dependencias no viajan en el estado — están fijas en el cierre.</p>
</li>
<li>
<strong><code>runtime/graph.py</code></strong> — conecta los nodos. Los tres routers condicionales (<code>_after_validate_input</code>, <code>_after_llm</code>, <code>_after_validate_output</code>) hacen que cualquier error cortocircuite hacia <code>END</code> sin pasar por nodos posteriores.
</li>
</ol>
<div class="callout tip">
<div class="ct">Momento clave #4</div>
<p>Al implementar <code>approve_gate</code> entiendes cómo funciona HITL en LangGraph: <code>interrupt()</code> 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 <code>Command(resume=...)</code>. El estado nunca se pierde entre las dos llamadas HTTP.</p>
</div>
<h3>Fase 5 — Orquestador y checkpointer <em class="muted">(~30 min)</em></h3>
<ol>
<li><code>runtime/checkpointer.py</code> — configura SQLite como backend de checkpoints LangGraph. Sin esto, <code>interrupt()</code> no puede persistir el estado entre la llamada a <code>invoke</code> y la llamada a <code>approve</code>.</li>
<li><code>runtime/orchestrator.py</code> — el objeto que la API usará. Recibe <code>AgentDefinition</code> + <code>PolicyDefinition</code> + providers ya construidos, compila el grafo y expone dos métodos: <code>invoke()</code> (nueva ejecución) y <code>approve()</code> (reanudar tras HITL).</li>
</ol>
<p>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 <code>decision_path</code> completo, sin API ni dashboard.</p>
<h3>Fase 6 — La API: de adentro hacia afuera <em class="muted">(~1 h)</em></h3>
<p>Dentro de <code>api/</code>, el orden también importa.</p>
<ol>
<li><code>api/deps.py</code> — el armario de cableado: los <code>@lru_cache</code> que construyen el registry, el policy store, el LLM provider y el guardrail engine. <strong>Escríbelo después de tener todo lo anterior</strong> — solo entonces sabrás exactamente qué estás conectando.</li>
<li><code>api/executions.py</code> — los tres endpoints críticos: <code>POST /executions/invoke</code>, <code>POST /executions/{trace_id}/approve</code>, <code>GET /executions/{trace_id}</code>.</li>
<li><code>api/agents.py</code> — CRUD del registry (listar agentes, leer versión concreta).</li>
<li><code>api/middlewares.py</code> — inyecta <code>X-Trace-Id</code> y bind-ea el contexto de structlog.</li>
<li><code>main.py</code> — monta los routers, configura CORS, arranca la app FastAPI.</li>
</ol>
<h3>Fase 7 — Registry y versionado <em class="muted">(~45 min)</em></h3>
<p>Si has estado usando el mock registry, ahora añades la persistencia real basada en YAML.</p>
<ol>
<li><code>registry/repository.py</code> — carga definiciones de agentes desde YAML, cachea en memoria.</li>
<li><code>registry/versioning.py</code> — hash SHA-256 + <code>index.yaml</code>: el sistema de versionado tipo-Git.</li>
<li><code>registry/policy_store.py</code> — análogo al repository, pero para políticas.</li>
<li><code>registry/factory.py</code> — el factory que elige qué implementación de registry usar según <code>Settings</code>.</li>
</ol>
<h3>Fase 8 — El dashboard <em class="muted">(~1.5 h)</em></h3>
<p>El dashboard es un cliente HTTP del core — puedes empezarlo en cualquier momento después de tener los endpoints.</p>
<ol>
<li><code>dashboard/client.py</code> — 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.</li>
<li>Las páginas por complejidad creciente:
<ul>
<li><code>pages/1_Registro.py</code> — solo lectura: lista agentes y versiones.</li>
<li><code>pages/5_Politicas.py</code> — solo lectura: muestra políticas activas.</li>
<li><code>pages/4_Historial.py</code> — lectura + filtros: lista ejecuciones pasadas.</li>
<li><code>pages/2_Ejecutar.py</code> — escritura: invoke + polling de estado + visualización del <code>decision_path</code>.</li>
<li><code>pages/3_Aprobaciones.py</code> — la más compleja: muestra acciones pendientes, permite aprobar o rechazar individual o masivamente.</li>
</ul>
</li>
</ol>
<div class="callout ok">
<div class="ct">Punto de control mínimo viable (tras Fase 5)</div>
<p>Con las fases 15 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 <code>decision_path</code> completo, y pausa-reanuda en el nodo HITL. No necesitas la API ni el dashboard para validar el núcleo.</p>
</div>
</section>
<div class="footer">
<p><strong>AgentForge · Walkthrough.</strong> Documento autocontenido (sin recursos externos). Acompaña a <code>README.md</code>, <code>ARCHITECTURE.md</code>, <code>docs/explicacion.md</code> (narrativa) y <code>docs/componentes.md</code> (referencia de cableado). Refleja el repo en <code>v0.1.0</code>.</p>
<p class="muted">Documento HTML autocontenido (<code>docs/walkthrough.html</code>) — sin recursos externos: ábrelo en cualquier navegador.</p>
</div>
</main>
</div>
<button id="totop" aria-label="Volver arriba"></button>
<script>
(function(){
// ---- theme toggle ----
function getTheme(){ return document.documentElement.getAttribute('data-theme') || 'dark'; }
function setTheme(t){ document.documentElement.setAttribute('data-theme', t); try{localStorage.setItem('af-theme', t);}catch(e){} }
function toggle(){ setTheme(getTheme()==='dark'?'light':'dark'); }
['themeBtn','themeBtnM'].forEach(function(id){ var b=document.getElementById(id); if(b) b.addEventListener('click', toggle); });
// ---- mobile sidebar ----
var sb=document.getElementById('sidebar'), scrim=document.getElementById('scrim'), mb=document.getElementById('menuBtn');
function openSb(){ sb.classList.add('open'); scrim.classList.add('show'); }
function closeSb(){ sb.classList.remove('open'); scrim.classList.remove('show'); }
if(mb) mb.addEventListener('click', openSb);
if(scrim) scrim.addEventListener('click', closeSb);
sb.addEventListener('click', function(e){ if(e.target.tagName==='A') closeSb(); });
// ---- back to top ----
var tt=document.getElementById('totop');
tt.addEventListener('click', function(){ window.scrollTo({top:0, behavior:'smooth'}); });
window.addEventListener('scroll', function(){ tt.classList.toggle('show', window.scrollY>600); }, {passive:true});
// ---- scrollspy ----
var links=Array.prototype.slice.call(document.querySelectorAll('#toc a'));
var map={}; links.forEach(function(a){ var id=a.getAttribute('href').slice(1); map[id]=a; });
var secs=Object.keys(map).map(function(id){ return document.getElementById(id); }).filter(Boolean);
var current=null;
var io=new IntersectionObserver(function(entries){
entries.forEach(function(en){
if(en.isIntersecting){
if(current) current.classList.remove('active');
current=map[en.target.id]; if(current){ current.classList.add('active');
// keep active link in view within the sidebar
var r=current.getBoundingClientRect(), pr=sb.getBoundingClientRect();
if(r.top<pr.top+50 || r.bottom>pr.bottom-20) current.scrollIntoView({block:'nearest'});
}
}
});
}, {rootMargin:'-12% 0px -78% 0px', threshold:0});
secs.forEach(function(s){ io.observe(s); });
// ---- copy buttons ----
document.querySelectorAll('.code').forEach(function(box){
var btn=document.createElement('button'); btn.className='cp'; btn.textContent='copiar';
var hd=box.querySelector('.hd'); if(!hd){ return; }
hd.appendChild(btn);
btn.addEventListener('click', function(){
var pre=box.querySelector('pre'); var txt=pre?pre.innerText:'';
navigator.clipboard.writeText(txt).then(function(){ btn.textContent='✓ copiado'; setTimeout(function(){btn.textContent='copiar';},1400); });
});
});
})();
</script>
</body>
</html>