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-8',
      max_tokens: 4096,
      output_config: { effort: 'xhigh' },
      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}`);
  }
}

Elección de modelo y nivel de esfuerzo

Desde la generación 4.6 los IDs de modelo no llevan sufijo de fecha (aunque siguen siendo snapshots pinneados): se escribe claude-opus-4-8, claude-sonnet-5 o claude-fable-5, nunca claude-opus-4-8-20260501. Los alias opus y sonnet apuntan hoy a Opus 4.8 y Sonnet 5 respectivamente. Para subagentes de menor coste, claude-sonnet-5 es la opción habitual.

El nivel de esfuerzo es GA (sin cabecera beta) y va anidado en output_config, no en el nivel superior del request:

const response = await client.messages.create({
  model: 'claude-opus-4-8',
  max_tokens: 4096,
  output_config: {
    effort: 'xhigh', // 'low' | 'medium' | 'high' | 'xhigh' | 'max'
  },
  system: systemPrompt,
  tools,
  messages,
});
  • El default es high en Opus 4.8 y Sonnet 5.
  • xhigh es el nivel recomendado para coding y agentes, que es exactamente el caso de un agentic loop con herramientas.

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; }
}

Task Budgets: presupuesto para todo el bucle agéntico

El iteration cap y max_tokens son controles del lado cliente. La API ofrece además un mecanismo específico para acotar un bucle agéntico completo: Task Budgets (beta task-budgets-2026-03-13), disponible en Fable 5, Sonnet 5, Opus 4.8 y Opus 4.7.

const response = await client.messages.create({
  model: 'claude-opus-4-8',
  max_tokens: 4096,
  output_config: {
    effort: 'xhigh',
    task_budget: { type: 'tokens', total: 200_000 }, // mínimo 20.000
  },
  system: systemPrompt,
  tools,
  messages,
}, {
  headers: { 'anthropic-beta': 'task-budgets-2026-03-13' },
});

Diferencia clave con max_tokens:

ControlAlcance¿El modelo lo ve?
max_tokensTope duro de la respuesta actualNo, es invisible para el modelo
output_config.task_budgetPresupuesto de todo el bucle agénticoSí, el modelo es consciente y administra su trabajo dentro del presupuesto
Iteration cap del clienteNúmero de vueltas del whileNo, es puramente defensivo

El mínimo es 20.000 tokens y conviene usarlo con streaming, porque un bucle con presupuesto amplio produce respuestas largas.


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-8',
    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-8',
    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 : '';
}

El coste real del fan-out: cuándo NO usar multi-agente

Lanzar subagentes en paralelo con Promise.all es barato de escribir y caro de ejecutar. La economía medida por Anthropic:

  • Un agente usa ~4x más tokens que una interacción de chat.
  • Un sistema multi-agente usa ~15x más tokens que esa misma interacción de chat.
  • El uso de tokens explica el 80% de la varianza de desempeño (95% sumando llamadas a herramientas y elección de modelo).

De ahí dos reglas operativas:

  1. Multi-agente solo se justifica cuando el valor de la tarea paga el sobrecoste: cuando se paraleliza en direcciones independientes y los requisitos de información exceden una sola ventana de contexto.
  2. Subir de modelo rinde más que duplicar el presupuesto de tokens. Antes de agregar subagentes, probar con un modelo mejor o con effort más alto.

Antipatrón: multi-agente cuando todos los agentes necesitan el mismo contexto o hay fuertes interdependencias entre subtareas — el caso de la mayoría de las tareas de código. Los agentes tienen dificultad para coordinarse y delegar en tiempo real.

flowchart TD
    Q1{¿La tarea se paraleliza en<br/>direcciones independientes?} -- No --> Single[Agente único]
    Q1 -- Sí --> Q2{¿Los requisitos de información<br/>exceden una ventana?}
    Q2 -- No --> Single
    Q2 -- Sí --> Q3{¿Hay fuertes interdependencias<br/>entre subtareas?}
    Q3 -- Sí --> Single
    Q3 -- No --> Multi[Coordinador + subagentes]
    Single --> Up[Si no alcanza: subir de modelo<br/>o de effort antes que fan-out]

El brief del subagente: qué debe contener

Inyectar contexto no basta. Instrucciones vagas al subagente producen trabajo duplicado, huecos y fallos. Cada brief debe incluir cuatro cosas:

  1. Objetivo concreto de la subtarea.
  2. Formato de salida esperado.
  3. Guía sobre herramientas y fuentes que debe usar.
  4. Límites claros de scope: qué queda explícitamente fuera.

Además, el coordinador necesita reglas explícitas de escalado de esfuerzo, para no lanzar diez subagentes por un dato trivial:

Tipo de tareaEscalado
Hecho simple, verificable1 agente, 3-10 llamadas a herramientas
Comparación acotada2-4 subagentes
Investigación compleja y abierta10+ subagentes

El líder lanza 3-5 subagentes en paralelo (no en serie) y cada subagente devuelve un resumen condensado de típicamente 1.000-2.000 tokens, no su transcripción completa.

interface SubAgentBrief {
  objective: string;          // qué debe lograr, sin ambigüedad
  outputFormat: string;       // forma exacta del retorno
  toolGuidance: string;       // qué herramientas y fuentes usar
  boundaries: string[];       // qué NO debe investigar ni tocar
  maxToolCalls: number;       // escalado de esfuerzo explícito
  summaryTokenTarget: number; // 1000-2000 típico
}

function renderBrief(b: SubAgentBrief): string {
  return [
    `## Objetivo\n${b.objective}`,
    `## Formato de salida\n${b.outputFormat}`,
    `## Herramientas y fuentes\n${b.toolGuidance}`,
    `## Fuera de scope\n${b.boundaries.map(x => `- ${x}`).join('\n')}`,
    `## Presupuesto\nMáximo ${b.maxToolCalls} llamadas a herramientas.`,
    `Devuelve un resumen condensado de ~${b.summaryTokenTarget} tokens, no la transcripción.`,
  ].join('\n\n');
}

Para resultados voluminosos, el patrón canónico es que el subagente guarde su trabajo en un sistema externo y pase al coordinador solo una referencia ligera.


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-8',
      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-8',
      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

  • 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_tokens en un turno anterior y el historial está truncado
  • Cambio de scope del usuario que invalida los pasos anteriores

La sesión como event log externo al contexto

El SessionManifest anterior guarda conversationHistory completo en disco y lo recarga como estado. Funciona para crash recovery simple, pero acopla la sesión al contexto del modelo. El patrón canónico para agentes de larga duración y orquestación desacopla tres cosas:

  • El cerebro: modelo + harness.
  • Las manos: el sandbox y las herramientas.
  • El log de sesión: un event log append-only externo al contexto.
flowchart LR
    H["Harness stateless<br/>(fuera del contenedor)"] -- "wake(sessionId)" --> S["Session event log<br/>(append-only)"]
    S -- "getEvents(desde, hasta)" --> H
    H -- "execute(name, input) → string" --> C["Sandbox / herramientas<br/>(las manos)"]
    C -- resultado --> H
    H -- llamada al modelo --> M[Claude]
    M -- respuesta --> H

Propiedades del patrón:

  • Event log append-only: nada se sobrescribe. getEvents() permite cortes posicionales o rebobinar la sesión a un punto anterior sin perder lo que vino después.
  • Harness stateless fuera del contenedor, con wake(sessionId) y getSession(id): cualquier proceso puede retomar cualquier sesión. Es el principio de cattle, not pets — los contenedores son reemplazables.
  • Interfaz uniforme de herramientas: execute(name, input) → string, una sola firma para todas.
  • Nunca exponer credenciales en el sandbox.
type SessionEvent =
  | { kind: 'user_message'; at: string; content: string }
  | { kind: 'assistant_message'; at: string; content: unknown }
  | { kind: 'tool_call'; at: string; name: string; input: unknown }
  | { kind: 'tool_result'; at: string; name: string; output: string };

interface SessionStore {
  append(sessionId: string, event: SessionEvent): Promise<void>;
  getEvents(sessionId: string, opts?: { until?: number }): Promise<SessionEvent[]>;
}

interface ToolRuntime {
  execute(name: string, input: unknown): Promise<string>; // interfaz uniforme
}

// El harness no guarda estado propio: lo reconstruye desde el log
async function wake(sessionId: string, store: SessionStore, tools: ToolRuntime) {
  const events = await store.getEvents(sessionId);
  const messages = projectToMessages(events); // el contexto es una PROYECCIÓN del log
  return runAgentLoopWithStore(sessionId, messages, store, tools);
}

La clave conceptual: el contexto que se envía al modelo es una proyección del log, no el log mismo. Se puede recortar, resumir o rebobinar el contexto sin destruir el historial real.

Antipatrones de sesión

  • Contenedor único acoplado: cerebro, manos y log viviendo en el mismo proceso o contenedor. Si muere, muere la sesión.
  • Destrucción irreversible de contexto: truncar o borrar sin conservar el original. «Es difícil saber qué tokens necesitarán los turnos futuros» — por eso el log es append-only y la poda ocurre solo en la proyección.
  • Provisión síncrona de contenedores: esperar a que arranque el sandbox antes de responder. Desacoplarla redujo el TTFT p50 alrededor de un 60% y el p95 más de un 90%.

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
      output_config.effort xhigh para agentes
      task_budget visible para el modelo
    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
      multi-agente cuesta ~15x
      brief con objetivo formato herramientas y límites
      retorno condensado de 1000-2000 tokens
    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
      event log append-only externo al contexto
      harness stateless con wake sessionId

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.

Las tres son manuales, del lado cliente. La API ofrece además gestión de contexto en servidor, que es la solución de primera clase para el mismo problema y debería evaluarse antes de escribir compresión propia.

Compaction: resumen automático en servidor

Beta compact-2026-01-12. Disponible en Fable 5, Opus 4.8/4.7/4.6, Sonnet 5 y Sonnet 4.6.

const response = await client.messages.create({
  model: 'claude-opus-4-8',
  max_tokens: 4096,
  context_management: {
    edits: [{ type: 'compact_20260112' }],
  },
  system: systemPrompt,
  tools,
  messages,
}, {
  headers: { 'anthropic-beta': 'compact-2026-01-12' },
});

// ✅ Obligatorio: reenviar response.content COMPLETO en el siguiente turno
messages.push({ role: 'assistant', content: response.content });

// ❌ MAL: quedarse solo con el texto rompe la compaction
const soloTexto = response.content.find(b => b.type === 'text')?.text;
messages.push({ role: 'assistant', content: soloTexto! });

El servidor resume el historial cuando hace falta. El requisito crítico es reenviar response.content completo, no solo el bloque de texto.

Context editing: borrar en vez de resumir

Beta context-management-2025-06-27. Dos tipos: clear_tool_uses_20250919 y clear_thinking_20251015.

const response = await client.messages.create({
  model: 'claude-opus-4-8',
  max_tokens: 4096,
  context_management: {
    edits: [
      {
        type: 'clear_tool_uses_20250919',
        trigger: { type: 'input_tokens', value: 100_000 }, // default 100.000
        keep: { type: 'tool_uses', value: 3 },             // default 3 pares tool_use/result
        exclude_tools: ['get_customer'],                   // nunca borrar estos
        clear_tool_inputs: false,                          // default false
      },
      { type: 'clear_thinking_20251015' },
    ],
  },
  system: systemPrompt,
  tools,
  messages,
}, {
  headers: { 'anthropic-beta': 'context-management-2025-06-27' },
});

Parámetros de clear_tool_uses_20250919: trigger (default 100.000 input tokens), keep (default 3 pares tool_use/tool_result recientes), clear_at_least, exclude_tools y clear_tool_inputs (default false).

No confundir compaction con context editing

Son dos mecanismos distintos, con cabeceras beta distintas:

MecanismoCabecera betaTipo de editQué hace
Compactioncompact-2026-01-12compact_20260112Resume el historial en servidor
Context editingcontext-management-2025-06-27clear_tool_uses_20250919, clear_thinking_20251015Borra tool results o bloques de thinking

Usar compact_20260112 bajo la cabecera de context-management da error.

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_result bloques (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:
    1. Agente A produce output
    2. Se crea una nueva instancia (nuevo client.messages.create) con SOLO el output de A + criterios de evaluación
    3. La nueva instancia evalúa sin conocer el razonamiento original
interface EvaluationResult {
  score: 1 | 2 | 3 | 4 | 5;
  issues: string[];
  suggestions: string[];
  approved: boolean;
}

const EVALUATION_SCHEMA = {
  type: 'object',
  properties: {
    score: { type: 'integer', minimum: 1, maximum: 5 },
    issues: { type: 'array', items: { type: 'string' } },
    suggestions: { type: 'array', items: { type: 'string' } },
    approved: { type: 'boolean' },
  },
  required: ['score', 'issues', 'suggestions', 'approved'],
  additionalProperties: false,
} as const;

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-8',
    max_tokens: 1024,
    output_config: {
      format: { type: 'json_schema', schema: EVALUATION_SCHEMA },
    },
    system: `Eres un evaluador independiente. Evalúa el output sin conocer el razonamiento que lo produjo.
Marca ÚNICAMENTE gaps que afecten la corrección o los requisitos declarados.
No reportes mejoras estilísticas ni oportunidades de refactor.
Si el trabajo es correcto, devuelve issues vacío.`,
    messages: [{
      role: 'user',
      content: `## Tarea original
${originalPrompt}

## Output a evaluar
${output}

## Criterios de evaluación
${evaluationCriteria.map((c, i) => `${i + 1}. ${c}`).join('\n')}

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;
}

Structured outputs es GA: la forma vigente es output_config.format con {"type":"json_schema","schema":{…}}, sin cabecera beta. El antiguo output_format y la cabecera structured-outputs-2025-11-13 solo funcionan durante un periodo de transición. Pedir JSON por prompt y parsear texto libre ya no es necesario.

Complementariamente, para forzar que los argumentos de una herramienta cumplan su esquema, strict: true va como campo top-level de la definición de la tool, y exige additionalProperties: false y required:

const strictTool: Anthropic.Tool = {
  name: 'process_refund',
  description: 'Procesa un reembolso.',
  strict: true, // top-level, no dentro de input_schema
  input_schema: {
    type: 'object',
    properties: {
      order_id: { type: 'string' },
      amount: { type: 'number' },
      customer_id: { type: 'string' },
    },
    required: ['order_id', 'amount', 'customer_id'],
    additionalProperties: false,
  },
};

El sesgo inverso: el evaluador que siempre encuentra algo

El self-review tiene sesgo de confirmación, pero el evaluador independiente tiene el sesgo opuesto: «A reviewer prompted to find gaps will usually report some, even when the work is sound… Chasing every finding leads to over-engineering».

La mitigación es instruir explícitamente al evaluador para que solo marque gaps que afecten corrección o requisitos declarados — como en el system prompt del ejemplo anterior — y tratar el resto de hallazgos como informativos, no como bloqueantes.

Handoff en flujos de larga duración

Para agentes que trabajan durante muchas sesiones, el patrón recomendado hoy no es compactar indefinidamente sino resetear el contexto con artefactos de handoff:

  • Al cerrar una unidad de trabajo, el agente escribe un artefacto de handoff con el estado alcanzado, lo pendiente y las decisiones tomadas.
  • La siguiente sesión arranca con contexto limpio más ese artefacto.
  • Separar generador de evaluador: «afinar un evaluador escéptico independiente es mucho más tratable que hacer que un generador critique su propio trabajo».
  • Usar JSON y no Markdown para el archivo de tracking de progreso: el modelo lo sobrescribe menos.
interface HandoffArtifact {
  completedFeatures: Array<{ id: string; status: 'passing' | 'failing'; commit: string }>;
  pendingFeatures: Array<{ id: string; description: string }>;
  decisions: Array<{ topic: string; choice: string; rationale: string }>;
  blockers: string[];
}

// JSON, no Markdown: el modelo lo sobrescribe menos
async function writeHandoff(a: HandoffArtifact, path: string): Promise<void> {
  await fs.writeFile(path, JSON.stringify(a, null, 2), 'utf-8');
}
  • 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<br/>(contexto limpio)"]
    EvalCriteria[Criterios de evaluación] --> NewInstance

    NewInstance --> Eval{"score >= 4<br/>y sin issues?"}
    Eval -->|approved: true| Deliver[Entregar al usuario]
    Eval -->|approved: false| Refine["Agente A recibe feedback<br/>y 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<br/>con 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:

  • stop_reason: "stop_sequence" — Claude encontró una de las secuencias definidas en stop_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-8',
    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-8',
    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-8',
    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-8',
  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-8',
  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