State machine en agentes IA: sin más bucles infinitos
Construyes el agente. Funciona en local. Lo llevas a producción y tres días después alguien te dice que lleva 40 iteraciones dando vueltas a lo mismo.
No es un problema del modelo. Es un problema de diseño.
Los agentes IA sin estado explícito son bucles mientras alguien no los frene. Y en producción, ese "alguien" suele ser el timeout de la petición, el límite de tokens o el coste de la factura del mes.
El anti-patrón: el bucle que nadie puede parar
La implementación más común de un agente es algo así:
while (!done) {
const response = await llm.complete(messages);
if (response.toolCalls) {
const results = await executeTools(response.toolCalls);
messages.push(...results);
} else {
done = true;
}
}
Este código tiene un problema fundamental: no hay estados, solo condiciones booleanas. El agente no sabe en qué fase está de su tarea. No sabe si está recopilando información, razonando sobre una decisión o ejecutando una herramienta. Solo sabe si terminó o no.
Las consecuencias en producción son predecibles:
- El agente llama a la misma herramienta tres veces porque no hay registro de que ya ejecutó ese paso
- Cuando falla una tool, no hay forma de retomar desde donde estaba — toca empezar de cero
- El human-in-the-loop se implementa como un
ifdentro del bucle, sin contexto de qué estado pausó la ejecución - Los logs son una lista plana de acciones sin estructura — debugging imposible
Qué es una state machine y por qué los agentes la necesitan
Una state machine es un modelo con un conjunto finito de estados y transiciones explícitas entre ellos. El sistema solo puede estar en un estado a la vez, y el paso de un estado a otro ocurre por eventos definidos.
Para un agente IA, los estados naturales son:
| Estado | Qué hace el agente |
|---|---|
idle | Esperando activación |
collecting | Recopilando contexto antes de razonar |
reasoning | Analizando el contexto y decidiendo qué hacer |
acting | Ejecutando una herramienta o llamada externa |
waiting_human | Pausado, esperando aprobación humana |
error | Manejando un fallo con estrategia definida |
done | Tarea completada |
Con este modelo, cada iteración del agente no es "¿terminé?", sino "¿en qué estado estoy y qué evento me mueve al siguiente?".
Implementación en TypeScript: estado explícito y transiciones tipadas
Sin necesidad de XState ni ninguna librería externa, el patrón se implementa limpio en TypeScript puro:
type AgentState =
| 'idle'
| 'collecting'
| 'reasoning'
| 'acting'
| 'waiting_human'
| 'error'
| 'done';
type AgentEvent =
| { type: 'START'; input: string }
| { type: 'CONTEXT_READY'; context: Context }
| { type: 'TOOL_CALL'; tool: string; args: unknown }
| { type: 'TOOL_RESULT'; result: unknown }
| { type: 'NEEDS_HUMAN'; question: string }
| { type: 'HUMAN_APPROVED'; decision: string }
| { type: 'COMPLETE'; output: string }
| { type: 'ERROR'; reason: string }
| { type: 'RETRY' };
function transition(state: AgentState, event: AgentEvent): AgentState {
switch (state) {
case 'idle':
if (event.type === 'START') return 'collecting';
break;
case 'collecting':
if (event.type === 'CONTEXT_READY') return 'reasoning';
break;
case 'reasoning':
if (event.type === 'TOOL_CALL') return 'acting';
if (event.type === 'NEEDS_HUMAN') return 'waiting_human';
if (event.type === 'COMPLETE') return 'done';
break;
case 'acting':
if (event.type === 'TOOL_RESULT') return 'reasoning';
if (event.type === 'ERROR') return 'error';
break;
case 'waiting_human':
if (event.type === 'HUMAN_APPROVED') return 'reasoning';
break;
case 'error':
if (event.type === 'RETRY') return 'collecting';
if (event.type === 'NEEDS_HUMAN') return 'waiting_human';
break;
case 'done':
break;
}
throw new Error(`Transición no válida: ${state} + ${event.type}`);
}
El tipo AgentEvent hace que TypeScript rechace en compilación cualquier transición que no hayas definido. Sin sorpresas en runtime.
El bucle principal: ahora con historia
Con la state machine, el loop del agente cambia de forma:
async function runAgent(input: string) {
let state: AgentState = 'idle';
const history: Array<{ state: AgentState; event: AgentEvent; ts: number }> = [];
function emit(event: AgentEvent) {
const next = transition(state, event);
history.push({ state, event, ts: Date.now() });
state = next;
return next;
}
emit({ type: 'START', input });
while (state !== 'done' && state !== 'waiting_human') {
if (state === 'collecting') {
const context = await gatherContext(input);
emit({ type: 'CONTEXT_READY', context });
}
if (state === 'reasoning') {
const decision = await llm.reason(context, history);
if (decision.needsHuman) {
emit({ type: 'NEEDS_HUMAN', question: decision.question });
} else if (decision.toolCall) {
emit({ type: 'TOOL_CALL', tool: decision.toolCall.name, args: decision.toolCall.args });
} else {
emit({ type: 'COMPLETE', output: decision.output });
}
}
if (state === 'acting') {
try {
const result = await executeTool(/* ... */);
emit({ type: 'TOOL_RESULT', result });
} catch (err) {
emit({ type: 'ERROR', reason: String(err) });
}
}
if (state === 'error') {
const canRetry = history.filter(h => h.event.type === 'RETRY').length < 3;
if (canRetry) {
emit({ type: 'RETRY' });
} else {
emit({ type: 'NEEDS_HUMAN', question: 'Máximo de reintentos alcanzado' });
}
}
}
return { state, history };
}
Fíjate en el cambio más importante: history es la fuente de verdad. Si el agente se pausa para esperar aprobación humana, puedes serializar state + history en base de datos, y reanudar exactamente donde lo dejaste cuando llega la respuesta — sin perder contexto, sin volver a empezar.
Human-in-the-loop como ciudadano de primera clase
Con la state machine, el human-in-the-loop deja de ser un if (isAmbiguous) askUser() metido a presión dentro del loop.
El estado waiting_human es tan legítimo como acting. Cuando el agente llega ahí:
- El estado completo se persiste (base de datos, Redis, lo que uses)
- Se lanza una notificación al humano con la pregunta concreta
- La sesión del agente se cierra limpiamente
- Cuando el humano responde, se crea una nueva sesión con el estado persistido y se emite
HUMAN_APPROVED - El agente continúa desde
reasoningcon el contexto completo intacto
Este patrón es central en los agentes de automatización que construimos en DAILYMP: procesos de aprobación de presupuestos, validación de contratos, escalado de incidencias — flujos que necesitan supervisión humana sin perder el hilo del proceso.
Qué ganas en producción
La diferencia no es teórica. Con estado explícito:
Debugging real: el history de transiciones te dice exactamente qué pasó, en qué orden y en cuánto tiempo. Cuando un agente falla a las 3 AM, no revisas logs planos — lees la secuencia de estados.
Retry inteligente: no empiezas de cero. El agente vuelve al estado anterior al error, no al principio del flujo.
Límite de iteraciones tipado: si state === 'reasoning' aparece más de N veces en el history sin un COMPLETE, hay una regla explícita para escalarlo. No un timeout arbitrario.
Visibilidad para el equipo: cualquiera puede leer el diagrama de estados y entender qué hace el agente sin revisar el código. Esto importa cuando hay que explicarle a un CTO o a un cliente cómo funciona el proceso.
Si estás construyendo agentes con integración en sistemas existentes — Odoo, CRMs, herramientas internas — la state machine es lo que hace que esa integración sea mantenible a 6 meses vista, no solo funcional el día del deploy.
El error más caro que puedes cometer
Subestimar la complejidad del flujo.
Un agente que "solo tiene que hacer tres cosas" acaba teniendo siete casos edge: qué pasa si la tool tarda demasiado, qué pasa si el LLM devuelve un JSON malformado, qué pasa si el usuario cancela a mitad, qué pasa si hay dos agentes corriendo en paralelo sobre el mismo recurso.
Sin estados explícitos, cada uno de esos casos se convierte en un if nuevo dentro del loop. En seis meses tienes un bucle de 300 líneas que nadie entiende y que falla de formas que nadie predice.
Con una state machine, cada caso edge es una transición nueva. Está en el diagrama. Está en los tipos. Está en el log.
¿Estás implementando un agente para un proceso crítico de tu empresa y no quieres pagar el precio de aprenderlo a golpe de incidencias en producción?
Hablamos de la arquitectura antes de que el problema exista →