Cap 23: Agentic Loop Avanzado y Orquestación
Este tutorial cubre el Domain 1 del examen Claude Certified Architect – Foundations: Agentic Architecture & Orchestration. Todo el código es real y ejecutable con @anthropic-ai/sdk.
1. El agentic loop en código real
El corazón de cualquier agente es un loop que se mantiene activo mientras el modelo necesite ejecutar herramientas. La señal de control es stop_reason.
stop_reason: la fuente de verdad
| Valor | Significado |
|---|---|
"tool_use" | El modelo quiere ejecutar una o más herramientas. El agente DEBE procesarlas y continuar. |
"end_turn" | El modelo terminó. El agente DEBE detenerse. |
"max_tokens" | Se agotó el límite de tokens. Manejar como error. |
"stop_sequence" | Se encontró una secuencia de parada definida. |
Implementación completa del loop
import Anthropic from '@anthropic-ai/sdk';
const client = new Anthropic();
async function runAgentLoop(
systemPrompt: string,
userMessage: string,
tools: Anthropic.Tool[],
toolExecutor: (name: string, input: Record<string, unknown>) => Promise<unknown>
): Promise<string> {
const messages: Anthropic.MessageParam[] = [
{ role: 'user', content: userMessage }
];
while (true) {
const response = await client.messages.create({
model: 'claude-opus-4-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
highen Opus 4.8 y Sonnet 5. xhighes 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:
| Control | Alcance | ¿El modelo lo ve? |
|---|---|---|
max_tokens | Tope duro de la respuesta actual | No, es invisible para el modelo |
output_config.task_budget | Presupuesto de todo el bucle agéntico | Sí, el modelo es consciente y administra su trabajo dentro del presupuesto |
| Iteration cap del cliente | Número de vueltas del while | No, 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:
- 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.
- Subir de modelo rinde más que duplicar el presupuesto de tokens. Antes de agregar subagentes, probar con un modelo mejor o con
effortmá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:
- Objetivo concreto de la subtarea.
- Formato de salida esperado.
- Guía sobre herramientas y fuentes que debe usar.
- 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 tarea | Escalado |
|---|---|
| Hecho simple, verificable | 1 agente, 3-10 llamadas a herramientas |
| Comparación acotada | 2-4 subagentes |
| Investigación compleja y abierta | 10+ 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
| Criterio | Fixed Sequential | Adaptive Dynamic |
|---|---|---|
| Scope conocido | ✅ | ❌ |
| Pasos predecibles | ✅ | ❌ |
| Investigación abierta | ❌ | ✅ |
| Hallazgos generan nuevas tareas | ❌ | ✅ |
| Reproducibilidad requerida | ✅ | ❌ |
Riesgo de decomposición demasiado estrecha (overly narrow): si las subtareas son muy específicas, el agente puede ignorar aspectos relevantes fuera de su scope definido, resultando en cobertura incompleta. El examen pregunta sobre esto: la solución es definir subtareas con scope ligeramente solapado y un paso de consolidación que detecte gaps.
6. Error propagation en sistemas multi-agente
Taxonomía de errores
flowchart TD
E[Error] --> T[Transient]
E --> V[Validation]
E --> B[Business]
E --> P[Permission]
T -- "Reintentar con backoff" --> R1[Recuperar localmente]
V -- "Corregir input" --> R2[Propagar al coordinador]
B -- "Lógica de negocio violada" --> R3[Propagar al coordinador]
P -- "Sin autorización" --> R4[Escalar inmediatamente]
Estructura de error rico
interface AgentError {
errorCategory: 'transient' | 'validation' | 'business' | 'permission';
isRetryable: boolean;
message: string;
partialResults?: unknown;
context?: Record<string, unknown>;
}
function classifyError(err: Error): AgentError {
if (err.message.includes('ECONNRESET') || err.message.includes('timeout')) {
return { errorCategory: 'transient', isRetryable: true, message: err.message };
}
if (err.message.includes('PREREQUISITE_FAILED') || err.message.includes('invalid')) {
return { errorCategory: 'validation', isRetryable: false, message: err.message };
}
if (err.message.includes('SECURITY_ERROR') || err.message.includes('unauthorized')) {
return { errorCategory: 'permission', isRetryable: false, message: err.message };
}
return { errorCategory: 'business', isRetryable: false, message: err.message };
}
Recuperación local en subagente vs propagación
async function runSubAgentWithRetry(
config: SubAgentConfig,
maxRetries = 3
): Promise<{ result?: string; error?: AgentError }> {
let lastError: AgentError | undefined;
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
const result = await runSubAgent(config);
return { result };
} catch (err) {
const agentError = classifyError(err instanceof Error ? err : new Error(String(err)));
if (!agentError.isRetryable) {
// Propagar inmediatamente al coordinador
return { error: agentError };
}
lastError = agentError;
// Backoff exponencial para errores transient
await new Promise(r => setTimeout(r, 1000 * Math.pow(2, attempt)));
}
}
return { error: lastError };
}
Flujo de propagación
sequenceDiagram
participant C as Coordinador
participant S as Subagente
participant T as Tool
C->>S: lanzar tarea
S->>T: ejecutar herramienta
T-->>S: Error transient
S->>S: Reintentar (max 3)
T-->>S: OK
S-->>C: resultado
S->>T: otra herramienta
T-->>S: Error permission
Note over S: No reintentar
S-->>C: {errorCategory: "permission", isRetryable: false}
C->>C: Decidir: abortar o continuar sin este subagente
7. Session management: resume, fork, fresh start
Cuándo usar cada estrategia
flowchart TD
Start[¿Reanudar sesión?] --> Q1{¿Contexto sigue válido?}
Q1 -- Sí --> Q2{¿Work in progress?}
Q2 -- Sí --> Resume[resume: continuar sesión existente]
Q2 -- No --> Q3{¿Explorar alternativas?}
Q3 -- Sí --> Fork[fork: crear rama de la sesión]
Q3 -- No --> Fresh[fresh start: sesión limpia]
Q1 -- No --> Check{¿Tool results obsoletos o contexto stale?}
Check -- Sí --> Fresh
Check -- No --> Resume
Implementación de session manifest
Antes de operaciones largas, exportar estado estructurado para crash recovery:
interface SessionManifest {
sessionId: string;
createdAt: string;
lastCheckpoint: string;
conversationHistory: Anthropic.MessageParam[];
completedSteps: string[];
pendingSteps: string[];
metadata: Record<string, unknown>;
}
async function saveManifest(
manifest: SessionManifest,
path: string
): Promise<void> {
await fs.writeFile(path, JSON.stringify(manifest, null, 2), 'utf-8');
}
async function loadManifest(path: string): Promise<SessionManifest | null> {
try {
const data = await fs.readFile(path, 'utf-8');
return JSON.parse(data);
} catch {
return null;
}
}
async function runWithRecovery(
task: string,
manifestPath: string
): Promise<string> {
let manifest = await loadManifest(manifestPath);
const messages: Anthropic.MessageParam[] = manifest
? manifest.conversationHistory // resume
: [{ role: 'user', content: task }]; // fresh start
const completedSteps = manifest?.completedSteps ?? [];
// Guardar checkpoint antes de cada iteración
const onBeforeIteration = async () => {
await saveManifest({
sessionId: manifest?.sessionId ?? crypto.randomUUID(),
createdAt: manifest?.createdAt ?? new Date().toISOString(),
lastCheckpoint: new Date().toISOString(),
conversationHistory: messages,
completedSteps,
pendingSteps: [],
metadata: {},
}, manifestPath);
};
return runAgentLoopWithCheckpoint(messages, onBeforeIteration);
}
Fork de sesión
function forkSession(manifest: SessionManifest): SessionManifest {
return {
...manifest,
sessionId: crypto.randomUUID(),
createdAt: new Date().toISOString(),
// Copiar historial hasta el punto de divergencia
conversationHistory: [...manifest.conversationHistory],
completedSteps: [...manifest.completedSteps],
pendingSteps: [],
metadata: {
...manifest.metadata,
forkedFrom: manifest.sessionId,
forkPoint: manifest.lastCheckpoint,
},
};
}
Señales para fresh start obligatorio
- Tool results contienen timestamps obsoletos (datos de inventario, precios)
- El contexto referencia recursos que ya no existen (archivos eliminados, APIs deprecadas)
- El modelo llegó a
max_tokensen un turno anterior y el historial está truncado - Cambio de scope del usuario que invalida los pasos anteriores
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)ygetSession(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_reason | Significado | Acción |
|---|---|---|
"end_turn" | Claude terminó voluntariamente | Detener el loop, retornar resultado |
"tool_use" | Claude quiere ejecutar herramientas | Ejecutar y continuar |
"max_tokens" | Cortado por límite de tokens | Manejar como problema crítico |
Estrategias de recuperación
- Truncar tool results viejos: eliminar los resultados más antiguos del historial, conservando solo los últimos N. Es la más rápida pero puede perder contexto relevante.
- Comprimir con summarización: hacer un call separado que resuma el historial completo en un texto compacto, luego reemplazar el historial por ese resumen.
- Fresh start con resumen de estado: descartar el historial y comenzar de nuevo con un mensaje que describa el estado actual alcanzado.
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:
| Mecanismo | Cabecera beta | Tipo de edit | Qué hace |
|---|---|---|---|
| Compaction | compact-2026-01-12 | compact_20260112 | Resume el historial en servidor |
| Context editing | context-management-2025-06-27 | clear_tool_uses_20250919, clear_thinking_20251015 | Borra 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_resultbloques (estado actual de las herramientas) - El último mensaje del assistant (evita inconsistencia de roles)
Árbol de decisión para max_tokens
flowchart TD
A[stop_reason = max_tokens] --> B{¿Hay resultados parciales útiles?}
B -- No --> C[Fresh start con resumen de estado]
B -- Sí --> D{¿Historial > umbral compresión?}
D -- No --> E[Truncar: eliminar tool_results viejos]
D -- Sí --> F[Comprimir historial con call de summarización]
E --> G[Continuar loop con historial reducido]
F --> G
C --> H[Nuevo loop con estado inyectado en system]
9. Timeout handling en herramientas
Una herramienta que llama a una API externa puede bloquearse indefinidamente. Sin timeout, el agente queda colgado.
Wrapper con AbortController
async function executeWithTimeout<T>(
fn: (signal: AbortSignal) => Promise<T>,
timeoutMs: number,
toolName: string
): Promise<T> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
return await Promise.race([
fn(controller.signal),
new Promise<never>((_, reject) =>
controller.signal.addEventListener('abort', () =>
reject(new Error(`TIMEOUT: ${toolName} superó ${timeoutMs}ms`))
)
),
]);
} finally {
clearTimeout(timer);
}
}
Qué devolver al modelo cuando hay timeout
El modelo debe recibir un tool_result descriptivo que le permita decidir si reintentar o escalar:
toolResults.push({
type: 'tool_result',
tool_use_id: block.id,
content: JSON.stringify({
error: `TIMEOUT: la herramienta ${block.name} no respondió en ${TIMEOUT_MS}ms`,
isRetryable: true,
suggestion: 'Puedes reintentar con parámetros más simples o escalar a un humano.',
}),
is_error: true,
});
Flujo timeout → retry → fallback → error
flowchart TD
A[Ejecutar herramienta] --> B{¿Responde antes del timeout?}
B -- Sí --> C[Retornar resultado]
B -- No --> D[AbortController.abort]
D --> E{¿intento < maxRetries?}
E -- Sí --> F[Esperar backoff exponencial]
F --> A
E -- No --> G{¿Existe fallback local?}
G -- Sí --> H[Retornar resultado degradado del fallback]
G -- No --> I[tool_result con isError=true e isRetryable=false]
I --> J[Modelo decide escalar o abandonar subtarea]
10. Anti-patterns con código ejecutable
Anti-pattern 1: Parsear texto natural para terminar
// ❌ MAL: falla cuando Claude responde en otro idioma o con variaciones
while (true) {
const response = await client.messages.create({ model, max_tokens: 4096, messages });
messages.push({ role: 'assistant', content: response.content });
const textBlock = response.content.find(b => b.type === 'text');
const text = textBlock?.text ?? '';
// Frágil: "Terminé con éxito el trabajo", "I'm done", "Task completed" → todos fallan
if (text.includes('terminé') || text.includes('done') || text.includes('complete')) break;
// Ejecutar tools...
}
// ✅ BIEN: stop_reason es la única fuente de verdad
while (true) {
const response = await client.messages.create({ model, max_tokens: 4096, messages });
messages.push({ role: 'assistant', content: response.content });
if (response.stop_reason === 'end_turn') break;
if (response.stop_reason === 'tool_use') { /* ejecutar tools */ continue; }
throw new Error(`stop_reason inesperado: ${response.stop_reason}`);
}
Anti-pattern 2: Iteration cap como único mecanismo de parada
// ❌ MAL: corta el trabajo a la mitad si el modelo todavía necesita herramientas
for (let i = 0; i < 10; i++) {
const response = await client.messages.create({ model, max_tokens: 4096, messages });
messages.push({ role: 'assistant', content: response.content });
// Al llegar a i=10, el loop termina aunque stop_reason sea 'tool_use'
}
// ✅ BIEN: stop_reason controla, iteration cap es sólo salvaguarda de seguridad
const MAX_ITERATIONS = 50;
let iterations = 0;
while (iterations++ < MAX_ITERATIONS) {
const response = await client.messages.create({ model, max_tokens: 4096, messages });
messages.push({ role: 'assistant', content: response.content });
if (response.stop_reason === 'end_turn') break;
if (response.stop_reason === 'tool_use') {
const toolResults = await executeAllTools(response.content);
messages.push({ role: 'user', content: toolResults });
continue;
}
}
if (iterations >= MAX_ITERATIONS) console.warn('Safety cap alcanzado — revisar agente');
Anti-pattern 3: No agregar tool results al historial
// ❌ MAL: el siguiente turno de Claude no sabe qué devolvió la herramienta
const response = await client.messages.create({ model, max_tokens: 4096, messages });
messages.push({ role: 'assistant', content: response.content });
if (response.stop_reason === 'tool_use') {
for (const block of response.content) {
if (block.type !== 'tool_use') continue;
await executeToolSomewhere(block.name, block.input);
// ❌ BUG: el resultado se ejecutó pero NO se agregó al historial
// Claude asume en el siguiente turno que la herramienta nunca respondió
}
// Continuar sin tool_results → Claude alucina o repite la llamada
}
// ✅ BIEN: siempre agregar tool_result vinculado por tool_use_id
if (response.stop_reason === 'tool_use') {
const toolResults: Anthropic.ToolResultBlockParam[] = [];
for (const block of response.content) {
if (block.type !== 'tool_use') continue;
const result = await executeToolSomewhere(block.name, block.input as Record<string, unknown>);
toolResults.push({
type: 'tool_result',
tool_use_id: block.id, // enlace obligatorio
content: JSON.stringify(result),
});
}
messages.push({ role: 'user', content: toolResults }); // turno user con resultados
}
11. Backpressure y circuit breaker
Cuando una herramienta falla repetidamente, el agente puede entrar en un loop infinito de reintentos que desperdicia tokens y tiempo.
Estados del circuit breaker
stateDiagram-v2
[*] --> CLOSED
CLOSED --> OPEN : failures >= threshold
OPEN --> HALF_OPEN : cooldown expirado
HALF_OPEN --> CLOSED : llamada exitosa
HALF_OPEN --> OPEN : llamada falla
note right of CLOSED : Operación normal
note right of OPEN : Rechaza llamadas inmediatamente
note right of HALF_OPEN : Permite una llamada de prueba
Implementación
type CircuitState = 'CLOSED' | 'OPEN' | 'HALF_OPEN';
class CircuitBreaker {
private state: CircuitState = 'CLOSED';
private failures = 0;
private lastFailureTime = 0;
constructor(
private readonly threshold: number,
private readonly cooldownMs: number
) {}
async execute<T>(fn: () => Promise<T>, toolName: string): Promise<T> {
if (this.state === 'OPEN') {
const elapsed = Date.now() - this.lastFailureTime;
if (elapsed < this.cooldownMs) {
throw new Error(`CIRCUIT_OPEN: ${toolName} está bloqueado por ${this.cooldownMs - elapsed}ms`);
}
this.state = 'HALF_OPEN';
}
try {
const result = await fn();
this.onSuccess();
return result;
} catch (err) {
this.onFailure();
throw err;
}
}
private onSuccess(): void {
this.failures = 0;
this.state = 'CLOSED';
}
private onFailure(): void {
this.failures++;
this.lastFailureTime = Date.now();
if (this.failures >= this.threshold) {
this.state = 'OPEN';
}
}
get isOpen(): boolean { return this.state === 'OPEN'; }
}
// Uso en el tool executor
const breakers = new Map<string, CircuitBreaker>();
function getBreaker(toolName: string): CircuitBreaker {
if (!breakers.has(toolName)) {
breakers.set(toolName, new CircuitBreaker(3, 30_000)); // 3 fallos → 30s cooldown
}
return breakers.get(toolName)!;
}
async function executeWithCircuitBreaker(
toolName: string,
fn: () => Promise<unknown>
): Promise<{ result?: unknown; error?: string }> {
const breaker = getBreaker(toolName);
try {
const result = await breaker.execute(fn, toolName);
return { result };
} catch (err) {
return { error: err instanceof Error ? err.message : String(err) };
}
}
Qué devolver al modelo cuando el circuito está abierto
Igual que con timeout: un tool_result con is_error: true e información suficiente para que el modelo decida continuar sin esa herramienta o escalar:
{
type: 'tool_result',
tool_use_id: block.id,
content: JSON.stringify({
error: `CIRCUIT_OPEN: ${block.name} falló ${threshold} veces consecutivas. Disponible en ~${cooldownMs / 1000}s.`,
isRetryable: false,
suggestion: 'Usa una herramienta alternativa o escala a un humano.',
}),
is_error: true,
}
12. Self-evaluation pattern
El examen pregunta sobre agentes que evalúan su propio trabajo. El problema: el agente retiene su razonamiento y tiende a confirmar su propio output.
- Por qué self-review tiene sesgo: el contexto contiene el razonamiento original → el modelo tiende a validar lo que ya decidió (confirmation bias)
- Patrón recomendado: instancia independiente para review con contexto limpio, sin historial del agente original
- Implementación:
- Agente A produce output
- Se crea una nueva instancia (nuevo
client.messages.create) con SOLO el output de A + criterios de evaluación - La nueva instancia evalúa sin conocer el razonamiento original
interface EvaluationResult {
score: 1 | 2 | 3 | 4 | 5;
issues: string[];
suggestions: string[];
approved: boolean;
}
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
| Escenario | Patrón recomendado |
|---|---|
| Eliminar datos de producción | Patrón 1 — gate explícito |
| Chatbot de soporte con baja confianza | Patrón 2 — confidence routing |
| Migración masiva de registros | Patrón 3 — batch review |
| Deploy a producción | Patrón 1 — gate explícito |
| Clasificación de tickets con alta confianza | Patrón 2 — auto-ejecutar |
| Actualización bulk de precios | Patrón 3 — batch review |
14. Stop sequences como mecanismo de control
stop_sequences es el cuarto mecanismo de parada, además de stop_reason:
stop_reason: "stop_sequence"— Claude encontró una de las secuencias definidas enstop_sequences
Casos de uso
// Caso 1: extraer respuesta entre tags XML estructurados
async function extractStructuredAnswer(question: string): Promise<string> {
const response = await client.messages.create({
model: 'claude-opus-4-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_reason | Cuándo ocurre | Acción |
|---|---|---|
"end_turn" | Claude decidió terminar | Retornar texto |
"tool_use" | Claude llama una herramienta | Ejecutar y continuar |
"max_tokens" | Límite de tokens alcanzado | Manejar como error |
"stop_sequence" | Se encontró una de las secuencias en stop_sequences | Extraer texto hasta ese punto |
Siguiente: CI/CD con Claude Code