Cap 23: Agentic Loop Avanzado y Orquestación
Este tutorial cubre el Domain 1 del examen Claude Certified Architect – Foundations: Agentic Architecture & Orchestration. Todo el código es real y ejecutable con @anthropic-ai/sdk.
1. El agentic loop en código real
El corazón de cualquier agente es un loop que se mantiene activo mientras el modelo necesite ejecutar herramientas. La señal de control es stop_reason.
stop_reason: la fuente de verdad
| Valor | Significado |
|---|---|
"tool_use" | El modelo quiere ejecutar una o más herramientas. El agente DEBE procesarlas y continuar. |
"end_turn" | El modelo terminó. El agente DEBE detenerse. |
"max_tokens" | Se agotó el límite de tokens. Manejar como error. |
"stop_sequence" | Se encontró una secuencia de parada definida. |
Implementación completa del loop
import Anthropic from '@anthropic-ai/sdk';
const client = new Anthropic();
async function runAgentLoop(
systemPrompt: string,
userMessage: string,
tools: Anthropic.Tool[],
toolExecutor: (name: string, input: Record<string, unknown>) => Promise<unknown>
): Promise<string> {
const messages: Anthropic.MessageParam[] = [
{ role: 'user', content: userMessage }
];
while (true) {
const response = await client.messages.create({
model: 'claude-opus-4-5',
max_tokens: 4096,
system: systemPrompt,
tools,
messages,
});
// Agregar respuesta del assistant al historial
messages.push({ role: 'assistant', content: response.content });
if (response.stop_reason === 'end_turn') {
// Extraer texto final
const textBlock = response.content.find(b => b.type === 'text');
return textBlock ? textBlock.text : '';
}
if (response.stop_reason === 'tool_use') {
// Recolectar resultados de todas las tool_use del turno
const toolResults: Anthropic.ToolResultBlockParam[] = [];
for (const block of response.content) {
if (block.type !== 'tool_use') continue;
let result: unknown;
let isError = false;
try {
result = await toolExecutor(block.name, block.input as Record<string, unknown>);
} catch (err) {
result = err instanceof Error ? err.message : String(err);
isError = true;
}
toolResults.push({
type: 'tool_result',
tool_use_id: block.id,
content: JSON.stringify(result),
is_error: isError,
});
}
// Agregar resultados como turno del user
messages.push({ role: 'user', content: toolResults });
continue;
}
// stop_reason inesperado
throw new Error(`stop_reason inesperado: ${response.stop_reason}`);
}
}
Diagrama del loop
flowchart TD
A[Usuario envía mensaje] --> B[messages.push usuario]
B --> C[client.messages.create]
C --> D{stop_reason?}
D -- end_turn --> E[Extraer texto final y retornar]
D -- tool_use --> F[Ejecutar herramientas]
F --> G[Construir tool_results]
G --> H[messages.push tool_results como user]
H --> C
D -- max_tokens --> I[Lanzar error]
Anti-patterns CRÍTICOS del examen
// ❌ MAL: parsear señales de texto natural
const text = response.content[0].text;
if (text.includes('terminé') || text.includes('listo')) {
break; // NUNCA hacer esto
}
// ❌ MAL: usar sólo iteration cap como mecanismo de parada
for (let i = 0; i < 10; i++) {
// Si el modelo siempre devuelve tool_use, se corta el trabajo a mitad
}
// ❌ MAL: verificar contenido de texto para detectar completitud
const isDone = response.content.some(b => b.type === 'text' && b.text.length > 0);
// ✅ CORRECTO: stop_reason como única fuente de verdad
if (response.stop_reason === 'end_turn') break;
if (response.stop_reason === 'tool_use') { /* ejecutar tools */ continue; }
Un iteration cap puede coexistir como salvaguarda de seguridad, pero nunca como lógica primaria:
const MAX_ITERATIONS = 50; // sólo para prevenir loops infinitos en bugs
let iterations = 0;
while (iterations++ < MAX_ITERATIONS) {
const response = await client.messages.create({ /* ... */ });
if (response.stop_reason === 'end_turn') break;
if (response.stop_reason === 'tool_use') { /* ... */ continue; }
}
2. Tool results en el conversation history
El modelo razona en base a lo que ve en el historial. Si los resultados de las herramientas no están correctamente estructurados, el modelo no puede proceder.
Estructura requerida
sequenceDiagram
participant A as Agent
participant M as Model
A->>M: messages=[{role:user, content:"..."}]
M->>A: {role:assistant, content:[{type:tool_use, id:"tu_01", name:"get_customer"}]}
Note over A: messages.push(assistant response)
A->>A: Ejecutar get_customer()
A->>M: messages=[..., {role:user, content:[{type:tool_result, tool_use_id:"tu_01"}]}]
M->>A: {role:assistant, content:[{type:text, text:"El cliente es..."}]}
Por qué el historial completo importa
El modelo NO tiene memoria propia entre llamadas. Cada llamada a messages.create debe incluir el historial completo desde el inicio de la conversación. Sin los tool_result correctamente enlazados mediante tool_use_id, el modelo no sabe qué devolvieron las herramientas y puede alucinar o detenerse.
Tool executor con historial
interface ToolResult {
toolUseId: string;
result: unknown;
isError: boolean;
}
async function executeTools(
toolUseBlocks: Anthropic.ToolUseBlock[],
handlers: Record<string, (input: Record<string, unknown>) => Promise<unknown>>
): Promise<ToolResult[]> {
return Promise.all(
toolUseBlocks.map(async (block) => {
const handler = handlers[block.name];
if (!handler) {
return {
toolUseId: block.id,
result: `Herramienta desconocida: ${block.name}`,
isError: true,
};
}
try {
const result = await handler(block.input as Record<string, unknown>);
return { toolUseId: block.id, result, isError: false };
} catch (err) {
return {
toolUseId: block.id,
result: err instanceof Error ? err.message : String(err),
isError: true,
};
}
})
);
}
function buildToolResultMessage(results: ToolResult[]): Anthropic.MessageParam {
return {
role: 'user',
content: results.map((r) => ({
type: 'tool_result' as const,
tool_use_id: r.toolUseId,
content: JSON.stringify(r.result),
is_error: r.isError,
})),
};
}
3. Herramientas personalizadas: escenario Customer Support
El examen usa frecuentemente este escenario. El agente debe manejar reembolsos con prerrequisitos programáticos.
Definición de tools con JSON Schema
const CUSTOMER_SUPPORT_TOOLS: Anthropic.Tool[] = [
{
name: 'get_customer',
description: 'Busca datos del cliente por ID. Debe llamarse antes de cualquier operación.',
input_schema: {
type: 'object',
properties: {
customer_id: { type: 'string', description: 'ID único del cliente' },
},
required: ['customer_id'],
},
},
{
name: 'lookup_order',
description: 'Busca datos de un pedido por ID.',
input_schema: {
type: 'object',
properties: {
order_id: { type: 'string', description: 'ID del pedido' },
},
required: ['order_id'],
},
},
{
name: 'process_refund',
description: 'Procesa un reembolso. Requiere customer_id verificado previamente.',
input_schema: {
type: 'object',
properties: {
order_id: { type: 'string' },
amount: { type: 'number', description: 'Monto en centavos' },
customer_id: { type: 'string', description: 'ID del cliente ya verificado' },
},
required: ['order_id', 'amount', 'customer_id'],
},
},
{
name: 'escalate_to_human',
description: 'Escala el caso a un agente humano.',
input_schema: {
type: 'object',
properties: {
reason: { type: 'string' },
context: { type: 'string', description: 'Resumen del caso' },
},
required: ['reason', 'context'],
},
},
];
Prerrequisitos programáticos: bloquear process_refund
El prerrequisito no se delega al modelo (no es confiable). Se implementa en el dispatcher:
interface CustomerSupportState {
verifiedCustomerId: string | null;
}
function createCustomerSupportHandlers(state: CustomerSupportState) {
return {
get_customer: async (input: Record<string, unknown>) => {
const customerId = input.customer_id as string;
// Simular llamada a base de datos
const customer = await db.customers.findById(customerId);
if (customer) {
state.verifiedCustomerId = customerId; // registrar verificación
}
return customer ?? { error: 'Cliente no encontrado' };
},
lookup_order: async (input: Record<string, unknown>) => {
return db.orders.findById(input.order_id as string);
},
process_refund: async (input: Record<string, unknown>) => {
// ✅ Prerrequisito programático: no depender del modelo
if (!state.verifiedCustomerId) {
throw new Error('PREREQUISITE_FAILED: debe llamarse get_customer primero');
}
if (state.verifiedCustomerId !== input.customer_id) {
throw new Error('SECURITY_ERROR: customer_id no coincide con el verificado');
}
return db.refunds.create({
orderId: input.order_id as string,
amount: input.amount as number,
customerId: state.verifiedCustomerId,
});
},
escalate_to_human: async (input: Record<string, unknown>) => {
await queue.push({
reason: input.reason,
context: input.context,
timestamp: new Date().toISOString(),
});
return { escalated: true, ticket_id: generateTicketId() };
},
};
}
4. Patrón coordinador-subagente
En sistemas multi-agente, el coordinador orquesta subagentes especializados. Cada subagente opera con su propio historial aislado.
Regla fundamental
Los subagentes NO heredan el historial del coordinador. Todo el contexto relevante debe inyectarse explícitamente en el prompt del subagente.
Flujo de información
flowchart LR
U[Usuario] --> C[Coordinador]
C -- "prompt con contexto inyectado" --> S1[Subagente A]
C -- "prompt con contexto inyectado" --> S2[Subagente B]
C -- "prompt con contexto inyectado" --> S3[Subagente C]
S1 -- resultado --> C
S2 -- resultado --> C
S3 -- resultado --> C
C -- respuesta consolidada --> U
Coordinador que lanza subagentes en paralelo
interface SubAgentConfig {
name: string;
systemPrompt: string;
task: string;
tools: Anthropic.Tool[];
toolHandlers: Record<string, (input: Record<string, unknown>) => Promise<unknown>>;
}
async function runSubAgent(config: SubAgentConfig): Promise<string> {
return runAgentLoop(
config.systemPrompt,
config.task,
config.tools,
config.toolHandlers
);
}
async function runCoordinator(userRequest: string): Promise<string> {
// 1. Coordinador decide la descomposición
const decompositionResponse = await client.messages.create({
model: 'claude-opus-4-5',
max_tokens: 1024,
system: 'Eres un coordinador. Devuelve JSON con las tareas para subagentes.',
messages: [{ role: 'user', content: userRequest }],
});
const tasks = parseDecomposition(decompositionResponse);
// 2. Lanzar subagentes en paralelo con contexto inyectado
const contextSummary = `Contexto del proyecto: ${userRequest}`;
const subAgentConfigs: SubAgentConfig[] = tasks.map((task) => ({
name: task.name,
systemPrompt: `${task.systemPrompt}\n\nContexto global: ${contextSummary}`,
task: task.description,
tools: task.tools,
toolHandlers: buildHandlersForTask(task),
}));
const results = await Promise.all(subAgentConfigs.map(runSubAgent));
// 3. Coordinador consolida resultados
const consolidationResponse = await client.messages.create({
model: 'claude-opus-4-5',
max_tokens: 2048,
system: 'Consolida los resultados de los subagentes en una respuesta coherente.',
messages: [{
role: 'user',
content: [
`Solicitud original: ${userRequest}`,
...results.map((r, i) => `Resultado subagente ${i + 1}: ${r}`),
].join('\n\n'),
}],
});
const textBlock = consolidationResponse.content.find(b => b.type === 'text');
return textBlock ? textBlock.text : '';
}
5. Task decomposition strategies
Fixed sequential (prompt chaining)
Cada paso recibe el output del anterior. Predecible, ideal para checklists lineales.
async function runSequentialChain(
steps: Array<{ system: string; userTemplate: (prev: string) => string }>
): Promise<string> {
let previousOutput = '';
for (const step of steps) {
const response = await client.messages.create({
model: 'claude-opus-4-5',
max_tokens: 2048,
system: step.system,
messages: [{ role: 'user', content: step.userTemplate(previousOutput) }],
});
const block = response.content.find(b => b.type === 'text');
previousOutput = block ? block.text : '';
}
return previousOutput;
}
// Ejemplo: code review checklist paso a paso
const codeReviewChain = [
{
system: 'Analiza seguridad del código.',
userTemplate: (_: string) => `Revisa este código: ${sourceCode}`,
},
{
system: 'Analiza performance.',
userTemplate: (prev: string) => `Dado este análisis de seguridad:\n${prev}\nRevisa performance.`,
},
{
system: 'Genera reporte consolidado.',
userTemplate: (prev: string) => `Con este análisis previo:\n${prev}\nGenera reporte final.`,
},
];
Adaptive dynamic
El agente genera subtareas según lo que descubre. Para investigación abierta o cuando el scope es desconocido.
interface SubTask {
id: string;
description: string;
dependsOn: string[];
result?: string;
}
async function runAdaptiveResearch(initialQuery: string): Promise<SubTask[]> {
const completedTasks: SubTask[] = [];
const pendingTasks: SubTask[] = [
{ id: 'initial', description: initialQuery, dependsOn: [] }
];
while (pendingTasks.length > 0) {
const task = pendingTasks.shift()!;
// Ejecutar tarea
const context = completedTasks
.filter(t => task.dependsOn.includes(t.id))
.map(t => `${t.description}: ${t.result}`)
.join('\n');
const response = await client.messages.create({
model: 'claude-opus-4-5',
max_tokens: 2048,
system: `Investiga el tema. Si necesitas subtareas, inclúyelas en JSON al final con formato:
SUBTASKS: [{"id":"...","description":"...","dependsOn":["..."]}]`,
messages: [{
role: 'user',
content: `Tarea: ${task.description}\nContexto previo:\n${context}`,
}],
});
const block = response.content.find(b => b.type === 'text');
const text = block ? block.text : '';
// Extraer subtareas dinámicas
const subtaskMatch = text.match(/SUBTASKS:\s*(\[.*?\])/s);
if (subtaskMatch) {
const newTasks: SubTask[] = JSON.parse(subtaskMatch[1]);
pendingTasks.push(...newTasks);
}
completedTasks.push({ ...task, result: text.replace(/SUBTASKS:.*$/s, '').trim() });
}
return completedTasks;
}
Fixed vs Adaptive
flowchart LR
subgraph Fixed["Fixed Sequential"]
direction TB
F1[Paso 1] --> F2[Paso 2] --> F3[Paso 3] --> F4[Resultado]
end
subgraph Adaptive["Adaptive Dynamic"]
direction TB
A1[Tarea inicial]
A1 --> A2[Subtarea A]
A1 --> A3[Subtarea B]
A2 --> A4[Subtarea A1 generada dinámicamente]
A3 --> A5[Resultado]
A4 --> A5
end
Cuándo usar cada approach
| Criterio | Fixed Sequential | Adaptive Dynamic |
|---|---|---|
| Scope conocido | ✅ | ❌ |
| Pasos predecibles | ✅ | ❌ |
| Investigación abierta | ❌ | ✅ |
| Hallazgos generan nuevas tareas | ❌ | ✅ |
| Reproducibilidad requerida | ✅ | ❌ |
Riesgo de decomposición demasiado estrecha (overly narrow): si las subtareas son muy específicas, el agente puede ignorar aspectos relevantes fuera de su scope definido, resultando en cobertura incompleta. El examen pregunta sobre esto: la solución es definir subtareas con scope ligeramente solapado y un paso de consolidación que detecte gaps.
6. Error propagation en sistemas multi-agente
Taxonomía de errores
flowchart TD
E[Error] --> T[Transient]
E --> V[Validation]
E --> B[Business]
E --> P[Permission]
T -- "Reintentar con backoff" --> R1[Recuperar localmente]
V -- "Corregir input" --> R2[Propagar al coordinador]
B -- "Lógica de negocio violada" --> R3[Propagar al coordinador]
P -- "Sin autorización" --> R4[Escalar inmediatamente]
Estructura de error rico
interface AgentError {
errorCategory: 'transient' | 'validation' | 'business' | 'permission';
isRetryable: boolean;
message: string;
partialResults?: unknown;
context?: Record<string, unknown>;
}
function classifyError(err: Error): AgentError {
if (err.message.includes('ECONNRESET') || err.message.includes('timeout')) {
return { errorCategory: 'transient', isRetryable: true, message: err.message };
}
if (err.message.includes('PREREQUISITE_FAILED') || err.message.includes('invalid')) {
return { errorCategory: 'validation', isRetryable: false, message: err.message };
}
if (err.message.includes('SECURITY_ERROR') || err.message.includes('unauthorized')) {
return { errorCategory: 'permission', isRetryable: false, message: err.message };
}
return { errorCategory: 'business', isRetryable: false, message: err.message };
}
Recuperación local en subagente vs propagación
async function runSubAgentWithRetry(
config: SubAgentConfig,
maxRetries = 3
): Promise<{ result?: string; error?: AgentError }> {
let lastError: AgentError | undefined;
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
const result = await runSubAgent(config);
return { result };
} catch (err) {
const agentError = classifyError(err instanceof Error ? err : new Error(String(err)));
if (!agentError.isRetryable) {
// Propagar inmediatamente al coordinador
return { error: agentError };
}
lastError = agentError;
// Backoff exponencial para errores transient
await new Promise(r => setTimeout(r, 1000 * Math.pow(2, attempt)));
}
}
return { error: lastError };
}
Flujo de propagación
sequenceDiagram
participant C as Coordinador
participant S as Subagente
participant T as Tool
C->>S: lanzar tarea
S->>T: ejecutar herramienta
T-->>S: Error transient
S->>S: Reintentar (max 3)
T-->>S: OK
S-->>C: resultado
S->>T: otra herramienta
T-->>S: Error permission
Note over S: No reintentar
S-->>C: {errorCategory: "permission", isRetryable: false}
C->>C: Decidir: abortar o continuar sin este subagente
7. Session management: resume, fork, fresh start
Cuándo usar cada estrategia
flowchart TD
Start[¿Reanudar sesión?] --> Q1{¿Contexto sigue válido?}
Q1 -- Sí --> Q2{¿Work in progress?}
Q2 -- Sí --> Resume[resume: continuar sesión existente]
Q2 -- No --> Q3{¿Explorar alternativas?}
Q3 -- Sí --> Fork[fork: crear rama de la sesión]
Q3 -- No --> Fresh[fresh start: sesión limpia]
Q1 -- No --> Check{¿Tool results obsoletos o contexto stale?}
Check -- Sí --> Fresh
Check -- No --> Resume
Implementación de session manifest
Antes de operaciones largas, exportar estado estructurado para crash recovery:
interface SessionManifest {
sessionId: string;
createdAt: string;
lastCheckpoint: string;
conversationHistory: Anthropic.MessageParam[];
completedSteps: string[];
pendingSteps: string[];
metadata: Record<string, unknown>;
}
async function saveManifest(
manifest: SessionManifest,
path: string
): Promise<void> {
await fs.writeFile(path, JSON.stringify(manifest, null, 2), 'utf-8');
}
async function loadManifest(path: string): Promise<SessionManifest | null> {
try {
const data = await fs.readFile(path, 'utf-8');
return JSON.parse(data);
} catch {
return null;
}
}
async function runWithRecovery(
task: string,
manifestPath: string
): Promise<string> {
let manifest = await loadManifest(manifestPath);
const messages: Anthropic.MessageParam[] = manifest
? manifest.conversationHistory // resume
: [{ role: 'user', content: task }]; // fresh start
const completedSteps = manifest?.completedSteps ?? [];
// Guardar checkpoint antes de cada iteración
const onBeforeIteration = async () => {
await saveManifest({
sessionId: manifest?.sessionId ?? crypto.randomUUID(),
createdAt: manifest?.createdAt ?? new Date().toISOString(),
lastCheckpoint: new Date().toISOString(),
conversationHistory: messages,
completedSteps,
pendingSteps: [],
metadata: {},
}, manifestPath);
};
return runAgentLoopWithCheckpoint(messages, onBeforeIteration);
}
Fork de sesión
function forkSession(manifest: SessionManifest): SessionManifest {
return {
...manifest,
sessionId: crypto.randomUUID(),
createdAt: new Date().toISOString(),
// Copiar historial hasta el punto de divergencia
conversationHistory: [...manifest.conversationHistory],
completedSteps: [...manifest.completedSteps],
pendingSteps: [],
metadata: {
...manifest.metadata,
forkedFrom: manifest.sessionId,
forkPoint: manifest.lastCheckpoint,
},
};
}
Señales para fresh start obligatorio
- Tool results contienen timestamps obsoletos (datos de inventario, precios)
- El contexto referencia recursos que ya no existen (archivos eliminados, APIs deprecadas)
- El modelo llegó a
max_tokensen un turno anterior y el historial está truncado - Cambio de scope del usuario que invalida los pasos anteriores
Resumen del Domain 1
mindmap
root((Agentic Architecture))
Agentic Loop
stop_reason como fuente de verdad
tool_use → ejecutar → continuar
end_turn → terminar
iteration cap sólo como salvaguarda
Tool Results
historial completo en cada llamada
tool_use_id enlaza blocks
is_error para errores explícitos
Herramientas
JSON Schema estricto
prerrequisitos programáticos
dispatcher con estado
Coordinador-Subagente
subagentes sin historial del coordinador
contexto inyectado explícitamente
paralelo con Promise.all
Task Decomposition
fixed sequential para scope conocido
adaptive dynamic para investigación
riesgo de overly narrow
Error Propagation
transient → retry local
validation/business → propagar
permission → escalar
Session Management
resume cuando contexto válido
fresh start cuando stale
fork para explorar alternativas
manifest para crash recovery
8. Manejo de max_tokens — cuando el contexto se llena
Cuando stop_reason es "max_tokens", Claude fue interrumpido en medio de una respuesta. El historial creció tanto que no caben más tokens de salida.
Las tres señales de parada comparadas
| stop_reason | Significado | Acción |
|---|---|---|
"end_turn" | Claude terminó voluntariamente | Detener el loop, retornar resultado |
"tool_use" | Claude quiere ejecutar herramientas | Ejecutar y continuar |
"max_tokens" | Cortado por límite de tokens | Manejar como problema crítico |
Estrategias de recuperación
- Truncar tool results viejos: eliminar los resultados más antiguos del historial, conservando solo los últimos N. Es la más rápida pero puede perder contexto relevante.
- Comprimir con summarización: hacer un call separado que resuma el historial completo en un texto compacto, luego reemplazar el historial por ese resumen.
- Fresh start con resumen de estado: descartar el historial y comenzar de nuevo con un mensaje que describa el estado actual alcanzado.
Implementación: truncación inteligente
async function handleMaxTokens(
messages: Anthropic.MessageParam[],
model: string,
keepLastN = 10
): Promise<Anthropic.MessageParam[]> {
// Preservar siempre: primer mensaje del user (tarea original)
// y los últimos N mensajes (contexto reciente)
const firstUserMessage = messages[0];
const recentMessages = messages.slice(-keepLastN);
// Comprimir el tramo descartado
const discardedMessages = messages.slice(1, messages.length - keepLastN);
if (discardedMessages.length === 0) {
return messages; // no hay nada que comprimir
}
const summaryResponse = await client.messages.create({
model,
max_tokens: 512,
system: 'Resume el historial de conversación en un párrafo conciso preservando decisiones clave y resultados de herramientas.',
messages: [{ role: 'user', content: JSON.stringify(discardedMessages) }],
});
const summaryBlock = summaryResponse.content.find(b => b.type === 'text');
const summary = summaryBlock ? summaryBlock.text : '';
return [
firstUserMessage,
{
role: 'user',
content: `[Resumen del historial comprimido]\n${summary}`,
},
...recentMessages,
];
}
Qué mensajes preservar siempre
- El mensaje del usuario con la tarea original (contexto del objetivo)
- Los últimos
tool_resultbloques (estado actual de las herramientas) - El último mensaje del assistant (evita inconsistencia de roles)
Árbol de decisión para max_tokens
flowchart TD
A[stop_reason = max_tokens] --> B{¿Hay resultados parciales útiles?}
B -- No --> C[Fresh start con resumen de estado]
B -- Sí --> D{¿Historial > umbral compresión?}
D -- No --> E[Truncar: eliminar tool_results viejos]
D -- Sí --> F[Comprimir historial con call de summarización]
E --> G[Continuar loop con historial reducido]
F --> G
C --> H[Nuevo loop con estado inyectado en system]
9. Timeout handling en herramientas
Una herramienta que llama a una API externa puede bloquearse indefinidamente. Sin timeout, el agente queda colgado.
Wrapper con AbortController
async function executeWithTimeout<T>(
fn: (signal: AbortSignal) => Promise<T>,
timeoutMs: number,
toolName: string
): Promise<T> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
return await Promise.race([
fn(controller.signal),
new Promise<never>((_, reject) =>
controller.signal.addEventListener('abort', () =>
reject(new Error(`TIMEOUT: ${toolName} superó ${timeoutMs}ms`))
)
),
]);
} finally {
clearTimeout(timer);
}
}
Qué devolver al modelo cuando hay timeout
El modelo debe recibir un tool_result descriptivo que le permita decidir si reintentar o escalar:
toolResults.push({
type: 'tool_result',
tool_use_id: block.id,
content: JSON.stringify({
error: `TIMEOUT: la herramienta ${block.name} no respondió en ${TIMEOUT_MS}ms`,
isRetryable: true,
suggestion: 'Puedes reintentar con parámetros más simples o escalar a un humano.',
}),
is_error: true,
});
Flujo timeout → retry → fallback → error
flowchart TD
A[Ejecutar herramienta] --> B{¿Responde antes del timeout?}
B -- Sí --> C[Retornar resultado]
B -- No --> D[AbortController.abort]
D --> E{¿intento < maxRetries?}
E -- Sí --> F[Esperar backoff exponencial]
F --> A
E -- No --> G{¿Existe fallback local?}
G -- Sí --> H[Retornar resultado degradado del fallback]
G -- No --> I[tool_result con isError=true e isRetryable=false]
I --> J[Modelo decide escalar o abandonar subtarea]
10. Anti-patterns con código ejecutable
Anti-pattern 1: Parsear texto natural para terminar
// ❌ MAL: falla cuando Claude responde en otro idioma o con variaciones
while (true) {
const response = await client.messages.create({ model, max_tokens: 4096, messages });
messages.push({ role: 'assistant', content: response.content });
const textBlock = response.content.find(b => b.type === 'text');
const text = textBlock?.text ?? '';
// Frágil: "Terminé con éxito el trabajo", "I'm done", "Task completed" → todos fallan
if (text.includes('terminé') || text.includes('done') || text.includes('complete')) break;
// Ejecutar tools...
}
// ✅ BIEN: stop_reason es la única fuente de verdad
while (true) {
const response = await client.messages.create({ model, max_tokens: 4096, messages });
messages.push({ role: 'assistant', content: response.content });
if (response.stop_reason === 'end_turn') break;
if (response.stop_reason === 'tool_use') { /* ejecutar tools */ continue; }
throw new Error(`stop_reason inesperado: ${response.stop_reason}`);
}
Anti-pattern 2: Iteration cap como único mecanismo de parada
// ❌ MAL: corta el trabajo a la mitad si el modelo todavía necesita herramientas
for (let i = 0; i < 10; i++) {
const response = await client.messages.create({ model, max_tokens: 4096, messages });
messages.push({ role: 'assistant', content: response.content });
// Al llegar a i=10, el loop termina aunque stop_reason sea 'tool_use'
}
// ✅ BIEN: stop_reason controla, iteration cap es sólo salvaguarda de seguridad
const MAX_ITERATIONS = 50;
let iterations = 0;
while (iterations++ < MAX_ITERATIONS) {
const response = await client.messages.create({ model, max_tokens: 4096, messages });
messages.push({ role: 'assistant', content: response.content });
if (response.stop_reason === 'end_turn') break;
if (response.stop_reason === 'tool_use') {
const toolResults = await executeAllTools(response.content);
messages.push({ role: 'user', content: toolResults });
continue;
}
}
if (iterations >= MAX_ITERATIONS) console.warn('Safety cap alcanzado — revisar agente');
Anti-pattern 3: No agregar tool results al historial
// ❌ MAL: el siguiente turno de Claude no sabe qué devolvió la herramienta
const response = await client.messages.create({ model, max_tokens: 4096, messages });
messages.push({ role: 'assistant', content: response.content });
if (response.stop_reason === 'tool_use') {
for (const block of response.content) {
if (block.type !== 'tool_use') continue;
await executeToolSomewhere(block.name, block.input);
// ❌ BUG: el resultado se ejecutó pero NO se agregó al historial
// Claude asume en el siguiente turno que la herramienta nunca respondió
}
// Continuar sin tool_results → Claude alucina o repite la llamada
}
// ✅ BIEN: siempre agregar tool_result vinculado por tool_use_id
if (response.stop_reason === 'tool_use') {
const toolResults: Anthropic.ToolResultBlockParam[] = [];
for (const block of response.content) {
if (block.type !== 'tool_use') continue;
const result = await executeToolSomewhere(block.name, block.input as Record<string, unknown>);
toolResults.push({
type: 'tool_result',
tool_use_id: block.id, // enlace obligatorio
content: JSON.stringify(result),
});
}
messages.push({ role: 'user', content: toolResults }); // turno user con resultados
}
11. Backpressure y circuit breaker
Cuando una herramienta falla repetidamente, el agente puede entrar en un loop infinito de reintentos que desperdicia tokens y tiempo.
Estados del circuit breaker
stateDiagram-v2
[*] --> CLOSED
CLOSED --> OPEN : failures >= threshold
OPEN --> HALF_OPEN : cooldown expirado
HALF_OPEN --> CLOSED : llamada exitosa
HALF_OPEN --> OPEN : llamada falla
note right of CLOSED : Operación normal
note right of OPEN : Rechaza llamadas inmediatamente
note right of HALF_OPEN : Permite una llamada de prueba
Implementación
type CircuitState = 'CLOSED' | 'OPEN' | 'HALF_OPEN';
class CircuitBreaker {
private state: CircuitState = 'CLOSED';
private failures = 0;
private lastFailureTime = 0;
constructor(
private readonly threshold: number,
private readonly cooldownMs: number
) {}
async execute<T>(fn: () => Promise<T>, toolName: string): Promise<T> {
if (this.state === 'OPEN') {
const elapsed = Date.now() - this.lastFailureTime;
if (elapsed < this.cooldownMs) {
throw new Error(`CIRCUIT_OPEN: ${toolName} está bloqueado por ${this.cooldownMs - elapsed}ms`);
}
this.state = 'HALF_OPEN';
}
try {
const result = await fn();
this.onSuccess();
return result;
} catch (err) {
this.onFailure();
throw err;
}
}
private onSuccess(): void {
this.failures = 0;
this.state = 'CLOSED';
}
private onFailure(): void {
this.failures++;
this.lastFailureTime = Date.now();
if (this.failures >= this.threshold) {
this.state = 'OPEN';
}
}
get isOpen(): boolean { return this.state === 'OPEN'; }
}
// Uso en el tool executor
const breakers = new Map<string, CircuitBreaker>();
function getBreaker(toolName: string): CircuitBreaker {
if (!breakers.has(toolName)) {
breakers.set(toolName, new CircuitBreaker(3, 30_000)); // 3 fallos → 30s cooldown
}
return breakers.get(toolName)!;
}
async function executeWithCircuitBreaker(
toolName: string,
fn: () => Promise<unknown>
): Promise<{ result?: unknown; error?: string }> {
const breaker = getBreaker(toolName);
try {
const result = await breaker.execute(fn, toolName);
return { result };
} catch (err) {
return { error: err instanceof Error ? err.message : String(err) };
}
}
Qué devolver al modelo cuando el circuito está abierto
Igual que con timeout: un tool_result con is_error: true e información suficiente para que el modelo decida continuar sin esa herramienta o escalar:
{
type: 'tool_result',
tool_use_id: block.id,
content: JSON.stringify({
error: `CIRCUIT_OPEN: ${block.name} falló ${threshold} veces consecutivas. Disponible en ~${cooldownMs / 1000}s.`,
isRetryable: false,
suggestion: 'Usa una herramienta alternativa o escala a un humano.',
}),
is_error: true,
}
12. Self-evaluation pattern
El examen pregunta sobre agentes que evalúan su propio trabajo. El problema: el agente retiene su razonamiento y tiende a confirmar su propio output.
- Por qué self-review tiene sesgo: el contexto contiene el razonamiento original → el modelo tiende a validar lo que ya decidió (confirmation bias)
- Patrón recomendado: instancia independiente para review con contexto limpio, sin historial del agente original
- Implementación:
- Agente A produce output
- Se crea una nueva instancia (nuevo
client.messages.create) con SOLO el output de A + criterios de evaluación - La nueva instancia evalúa sin conocer el razonamiento original
interface EvaluationResult {
score: 1 | 2 | 3 | 4 | 5;
issues: string[];
suggestions: string[];
approved: boolean;
}
async function runSelfEvaluationPattern(
originalPrompt: string,
output: string,
evaluationCriteria: string[]
): Promise<EvaluationResult> {
// Nueva instancia: NO recibe el historial del agente que generó el output
const response = await client.messages.create({
model: 'claude-opus-4-5',
max_tokens: 1024,
system: 'Eres un evaluador independiente. Evalúa el output sin conocer el razonamiento que lo produjo. Devuelve SOLO JSON.',
messages: [{
role: 'user',
content: `## Tarea original
${originalPrompt}
## Output a evaluar
${output}
## Criterios de evaluación
${evaluationCriteria.map((c, i) => `${i + 1}. ${c}`).join('\n')}
Devuelve JSON: {"score": 1-5, "issues": [], "suggestions": [], "approved": boolean}
approved=true solo si score >= 4 y no hay issues críticos.`
}]
});
const text = response.content.find(b => b.type === 'text')?.text ?? '{}';
return JSON.parse(text) as EvaluationResult;
}
- Cuándo usar: decisiones de alta importancia, outputs que van a producción, cambios irreversibles
flowchart TD
User[Prompt del usuario] --> AgentA[Agente A — produce output]
AgentA --> Output[Output generado]
Output --> NewInstance["Nueva instancia independiente\n(contexto limpio)"]
EvalCriteria[Criterios de evaluación] --> NewInstance
NewInstance --> Eval{score >= 4\ny sin issues?}
Eval -->|approved: true| Deliver[Entregar al usuario]
Eval -->|approved: false| Refine[Agente A recibe feedback\ny refina]
Refine --> Output
13. Human-in-the-loop patterns
Cuándo y cómo incorporar revisión humana en flujos agenticos.
Patrón 1: Gate explícito antes de acción irreversible
El agente propone la acción, espera aprobación, y solo entonces ejecuta.
// El tool handler pausa el loop y notifica al humano
const HUMAN_TOOLS: Anthropic.Tool[] = [
{
name: 'request_human_approval',
description: 'Pausa el loop y solicita aprobación humana antes de ejecutar una acción irreversible.',
input_schema: {
type: 'object' as const,
properties: {
action: { type: 'string', description: 'Acción que se quiere ejecutar' },
context: { type: 'string', description: 'Por qué es necesaria esta acción' },
risk_level: { type: 'string', enum: ['low', 'medium', 'high'] }
},
required: ['action', 'context', 'risk_level']
}
}
];
async function handleApprovalGate(
input: { action: string; context: string; risk_level: string }
): Promise<{ approved: boolean; reason?: string }> {
// Notificar al humano (email, Slack, webhook)
await notifyHuman({ ...input, timestamp: new Date().toISOString() });
// Esperar respuesta (polling, webhook callback, etc.)
return waitForHumanResponse(input.action);
}
Patrón 2: Confidence-based routing
interface HandoffContext {
customer_id: string;
issue_summary: string;
recommended_action: string;
attempted_solutions: string[];
root_cause: string;
}
async function routeByConfidence(
confidence: number,
threshold: number,
action: () => Promise<unknown>,
handoff: HandoffContext
): Promise<unknown> {
if (confidence >= threshold) {
// Ejecutar automáticamente
return action();
}
// Escalar con contexto completo — el agente NO transfiere solo el mensaje del cliente
return escalateToHuman(handoff);
}
async function escalateToHuman(ctx: HandoffContext): Promise<{ escalated: true; ticket_id: string }> {
const ticket = await queue.push({
...ctx,
escalated_at: new Date().toISOString()
});
return { escalated: true, ticket_id: ticket.id };
}
Patrón 3: Human review de batch results antes de commit
interface ProposedChange {
entity_id: string;
field: string;
current_value: unknown;
proposed_value: unknown;
reason: string;
}
function generateChangeProposal(changes: ProposedChange[]): string {
const lines = changes.map(c =>
`- [${c.entity_id}] ${c.field}: ${JSON.stringify(c.current_value)} → ${JSON.stringify(c.proposed_value)}\n Razón: ${c.reason}`
);
return `## Cambios propuestos (${changes.length} total)\n\n${lines.join('\n')}`;
}
// Flujo: agente procesa batch → genera reporte → humano aprueba → se aplican
async function runBatchWithHumanReview(
items: unknown[],
processFn: (item: unknown) => Promise<ProposedChange>,
applyFn: (changes: ProposedChange[]) => Promise<void>
): Promise<void> {
const changes = await Promise.all(items.map(processFn));
const proposal = generateChangeProposal(changes);
const { approved } = await requestHumanApproval(proposal);
if (approved) await applyFn(changes);
}
flowchart LR
subgraph P1["Patrón 1: Gate explícito"]
G1[Agente propone] --> G2[request_human_approval]
G2 --> G3{Humano aprueba}
G3 -->|Sí| G4[Ejecutar]
G3 -->|No| G5[Cancelar]
end
subgraph P2["Patrón 2: Confidence routing"]
C1[Agente actúa] --> C2{confidence >= threshold?}
C2 -->|Sí| C3[Auto-ejecutar]
C2 -->|No| C4[escalate_to_human\ncon HandoffContext]
end
subgraph P3["Patrón 3: Batch review"]
B1[Procesar batch] --> B2[generateChangeProposal]
B2 --> B3{Humano revisa}
B3 -->|Aprueba| B4[Aplicar cambios]
B3 -->|Rechaza| B5[Descartar]
end
| Escenario | Patrón recomendado |
|---|---|
| Eliminar datos de producción | Patrón 1 — gate explícito |
| Chatbot de soporte con baja confianza | Patrón 2 — confidence routing |
| Migración masiva de registros | Patrón 3 — batch review |
| Deploy a producción | Patrón 1 — gate explícito |
| Clasificación de tickets con alta confianza | Patrón 2 — auto-ejecutar |
| Actualización bulk de precios | Patrón 3 — batch review |
14. Stop sequences como mecanismo de control
stop_sequences es el cuarto mecanismo de parada, además de stop_reason:
stop_reason: "stop_sequence"— Claude encontró una de las secuencias definidas enstop_sequences
Casos de uso
// Caso 1: extraer respuesta entre tags XML estructurados
async function extractStructuredAnswer(question: string): Promise<string> {
const response = await client.messages.create({
model: 'claude-opus-4-5',
max_tokens: 1024,
stop_sequences: ['</answer>'],
messages: [{
role: 'user',
content: `${question}\n\nResponde entre tags: <answer>TU_RESPUESTA</answer>`
}]
});
const text = response.content.find(b => b.type === 'text')?.text ?? '';
// stop_sequences detiene ANTES de escribir </answer>
// el texto contiene: "<answer>respuesta aquí" (sin el closing tag)
return text.replace('<answer>', '').trim();
}
// Caso 2: forzar secciones en el output
async function generateSectionedReport(topic: string): Promise<{ analysis: string; recommendation: string }> {
const response = await client.messages.create({
model: 'claude-opus-4-5',
max_tokens: 2048,
stop_sequences: ['[END_ANALYSIS]'],
messages: [{
role: 'user',
content: `Analiza: ${topic}\n\nEscribe tu análisis y termina con [END_ANALYSIS]`
}]
});
const analysisText = response.content.find(b => b.type === 'text')?.text ?? '';
// Segunda llamada para la recomendación
const recResponse = await client.messages.create({
model: 'claude-opus-4-5',
max_tokens: 1024,
stop_sequences: ['[END_REC]'],
messages: [{
role: 'user',
content: `Dado este análisis:\n${analysisText}\n\nDa tu recomendación y termina con [END_REC]`
}]
});
return {
analysis: analysisText,
recommendation: recResponse.content.find(b => b.type === 'text')?.text ?? ''
};
}
Qué no hacer
// ❌ ANTI-PATTERN: usar stop_sequences para detectar "terminé" en texto libre
// Es equivalente a parsear texto natural — igual de frágil
const response = await client.messages.create({
model: 'claude-opus-4-5',
max_tokens: 4096,
stop_sequences: ['He terminado', 'Tarea completada', 'Done'], // NUNCA hacer esto
messages: [{ role: 'user', content: task }]
});
// ✅ CORRECTO: stop_sequences para delimitadores estructurales predecibles
const response2 = await client.messages.create({
model: 'claude-opus-4-5',
max_tokens: 4096,
stop_sequences: ['</result>'], // tag XML definido en el prompt
messages: [{ role: 'user', content: `${task}\n\nEscribe tu resultado en <result>...</result>` }]
});
stop_reason | Cuándo ocurre | Acción |
|---|---|---|
"end_turn" | Claude decidió terminar | Retornar texto |
"tool_use" | Claude llama una herramienta | Ejecutar y continuar |
"max_tokens" | Límite de tokens alcanzado | Manejar como error |
"stop_sequence" | Se encontró una de las secuencias en stop_sequences | Extraer texto hasta ese punto |
Siguiente: CI/CD con Claude Code