Cap 23: Agentic Loop Avanzado y Orquestación

Por: Artiko
claude-codeagenticsdkorchestrationarquitecturacertifications

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

ValorSignificado
"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

CriterioFixed SequentialAdaptive 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


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_reasonSignificadoAcción
"end_turn"Claude terminó voluntariamenteDetener el loop, retornar resultado
"tool_use"Claude quiere ejecutar herramientasEjecutar y continuar
"max_tokens"Cortado por límite de tokensManejar como problema crítico

Estrategias de recuperación

  1. 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.
  2. Comprimir con summarización: hacer un call separado que resuma el historial completo en un texto compacto, luego reemplazar el historial por ese resumen.
  3. 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

Á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.

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;
}
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
EscenarioPatrón recomendado
Eliminar datos de producciónPatrón 1 — gate explícito
Chatbot de soporte con baja confianzaPatrón 2 — confidence routing
Migración masiva de registrosPatrón 3 — batch review
Deploy a producciónPatrón 1 — gate explícito
Clasificación de tickets con alta confianzaPatrón 2 — auto-ejecutar
Actualización bulk de preciosPatrón 3 — batch review

14. Stop sequences como mecanismo de control

stop_sequences es el cuarto mecanismo de parada, además de stop_reason:

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_reasonCuándo ocurreAcción
"end_turn"Claude decidió terminarRetornar texto
"tool_use"Claude llama una herramientaEjecutar y continuar
"max_tokens"Límite de tokens alcanzadoManejar como error
"stop_sequence"Se encontró una de las secuencias en stop_sequencesExtraer texto hasta ese punto

Siguiente: CI/CD con Claude Code