Cap 25: Sistema Multi-Agente de Investigación
Este tutorial cubre el Scenario 3 (Multi-Agent Research System) y Scenario 4 (Developer Productivity) del examen Claude Certified Architect – Foundations, con foco en Domain 1 (Agentic Architecture) y Domain 2 (Tool Use & Integration).
1. Arquitectura del sistema de investigación
El escenario del examen presenta un sistema con un coordinador central y cuatro subagentes especializados que operan en topología hub-and-spoke.
graph TB
User([Usuario]) --> Coord[Coordinador]
Coord -->|"query + constraints"| WS[web-search-agent]
Coord -->|"query + doc_ids"| DA[document-analysis-agent]
Coord -->|"findings[]"| SY[synthesis-agent]
Coord -->|"synthesis + citations"| RP[report-agent]
WS -->|"WebFinding[]"| Coord
DA -->|"DocFinding[]"| Coord
SY -->|"SynthesisResult"| Coord
RP -->|"FinalReport"| Coord
WS --- T1[/"WebSearch<br/>WebFetch"/]
DA --- T2[/"Read<br/>Grep"/]
SY --- T3[/"(sin tools externas)"/]
RP --- T4[/"Write"/]
Cada agente tiene un conjunto acotado de herramientas. El coordinador agrega resultados y decide el flujo, sin exponer su historial completo a los subagentes.
Cuándo se justifica el patrón multi-agente
Antes de diseñar la topología conviene medir el coste. Las cifras publicadas por Anthropic sobre su propio research system:
| Métrica | Valor |
|---|---|
| Consumo de un agente vs. una interacción de chat | ~4x más tokens |
| Consumo de un sistema multi-agente | ~15x más tokens |
| Varianza de desempeño explicada por el uso de tokens | 80% |
| Varianza sumando llamadas a herramientas y elección de modelo | 95% |
De ahí se derivan dos reglas operativas:
- Multi-agente solo si el valor de la tarea paga el sobrecoste. Un pipeline de 15x tokens necesita justificar ese gasto con el valor del resultado.
- Subir de modelo rinde más que duplicar el presupuesto de tokens. Antes de añadir subagentes, probar con un modelo más capaz.
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.
Patrón: usarlo cuando la tarea se paraleliza en direcciones independientes y los requisitos de información exceden una sola ventana de contexto. La investigación es justamente ese caso: cada subagente explora una rama distinta y devuelve un resumen condensado.
Tipos base del sistema
// types.ts
export interface WebFinding {
claim: string;
source_url: string;
date_accessed: string;
confidence: 'high' | 'medium' | 'low';
excerpt: string;
}
export interface DocFinding {
claim: string;
document_id: string;
page: number;
excerpt: string;
confidence: 'high' | 'medium' | 'low';
}
export type Finding = WebFinding | DocFinding;
export interface Conflict {
claim_a: string;
claim_b: string;
sources: string[];
resolution: 'UNRESOLVED' | 'A_PREFERRED' | 'B_PREFERRED';
}
export interface SynthesisResult {
summary: string;
findings: Finding[];
conflicts: Conflict[];
gaps: string[];
}
export interface FinalReport {
title: string;
body: string;
citations: Citation[];
}
export interface Citation {
id: string;
source: string;
accessed?: string;
page?: number;
}
2. Coordinador inteligente — flujo dinámico
El coordinador no siempre rutea a todos los agentes. Analiza la query y decide qué agentes invocar según el contexto.
flowchart TD
Q[Query del usuario] --> Analyze{Análisis de query}
Analyze -->|"solo texto, sin docs ni web"| Simple[synthesis + report]
Analyze -->|"necesita datos actuales"| Web[web-search + synthesis + report]
Analyze -->|"documentos subidos"| Doc[document-analysis + synthesis + report]
Analyze -->|"compleja / multifuente"| All[todos en paralelo]
Simple --> Report[FinalReport]
Web --> Report
Doc --> Report
All --> Report
// coordinator.ts
import Anthropic from '@anthropic-ai/sdk';
import type { Finding, SynthesisResult, FinalReport } from './types';
const client = new Anthropic();
type ResearchPlan = {
useWebSearch: boolean;
useDocAnalysis: boolean;
documentIds: string[];
};
async function analyzeQuery(query: string, documentIds: string[]): Promise<ResearchPlan> {
const response = await client.messages.create({
model: 'claude-opus-4-8',
max_tokens: 256,
output_config: {
format: {
type: 'json_schema',
schema: {
type: 'object',
properties: {
useWebSearch: { type: 'boolean' },
useDocAnalysis: { type: 'boolean' }
},
required: ['useWebSearch', 'useDocAnalysis'],
additionalProperties: false
}
}
},
messages: [{
role: 'user',
content: `Analiza esta query.
Query: "${query}"
Documentos disponibles: ${documentIds.length}
useWebSearch=true si la query pide datos actuales, noticias o hechos verificables en internet.
useDocAnalysis=true si hay documentos cargados (${documentIds.length} > 0).`
}]
});
// Buscar el bloque de texto POR TIPO: con thinking adaptativo el primer
// bloque de `content` puede ser un bloque `thinking`, no texto.
const text = response.content.find(b => b.type === 'text')?.text ?? '{}';
const parsed = JSON.parse(text);
return { ...parsed, documentIds };
}
export async function runResearch(query: string, documentIds: string[] = []): Promise<FinalReport> {
const plan = await analyzeQuery(query, documentIds);
// Lanzar agentes en paralelo según el plan
const tasks: Promise<Finding[]>[] = [];
if (plan.useWebSearch) tasks.push(runWebSearchAgent(query));
if (plan.useDocAnalysis) tasks.push(runDocAnalysisAgent(query, plan.documentIds));
const findingsArrays = await Promise.all(tasks);
const allFindings = findingsArrays.flat();
const synthesis = await runSynthesisAgent(query, allFindings);
return runReportAgent(query, synthesis);
}
Structured outputs en vez de «Devuelve SOLO JSON»
Instruir el formato por prompt y luego JSON.parse a mano es frágil: el modelo puede envolver el JSON en prosa o en un bloque de código. Structured outputs es GA y garantiza el esquema a nivel de API:
// Sin cabecera beta: output_config.format con json_schema
output_config: {
format: {
type: 'json_schema',
schema: { /* JSON Schema con required y additionalProperties: false */ }
}
}
Notas de compatibilidad:
- El antiguo
output_formaty la cabecerastructured-outputs-2025-11-13solo funcionan durante un periodo de transición; lo vigente esoutput_config.format. - Para definiciones de herramientas, el campo equivalente es
strict: truea nivel top-level de la tool (no dentro deinput_schema), y exigeadditionalProperties: falsemásrequired. - El prefill del turno assistant final —el truco clásico de arrancar la respuesta con
{— devuelve 400 en Fable 5, Opus 4.6/4.7/4.8 y Sonnet 4.6/5. Structured outputs es el sustituto.
En este capítulo conviene tipar con esquema los tres contratos entre agentes: WebFinding[], SynthesisResult y FinalReport.
Leer siempre el bloque de texto por tipo
Con los modelos vigentes (Sonnet 5 con thinking adaptativo activado por defecto, Opus 4.8, Fable 5) la respuesta puede empezar con bloques thinking. Asumir que content[0] es texto hace que el código caiga en el fallback '{}' de forma silenciosa:
// ❌ Frágil: content[0] puede ser un bloque thinking
const text = response.content[0].type === 'text' ? response.content[0].text : '{}';
// ✅ Búsqueda por tipo
const text = response.content.find(b => b.type === 'text')?.text ?? '{}';
Detalle relacionado: thinking.display es "omitted" por defecto en Fable 5, Opus 4.8/4.7 y Sonnet 5, así que esos bloques llegan vacíos salvo que se pida explícitamente thinking: { type: 'adaptive', display: 'summarized' }. Vacíos, pero presentes en el array.
Paralelismo en dos niveles
Promise.all(tasks) cubre solo el primer nivel. El patrón canónico de un research system tiene dos:
flowchart TD
Lead[Agente líder] -->|paralelo| S1[Subagente 1]
Lead -->|paralelo| S2[Subagente 2]
Lead -->|paralelo| S3[Subagente 3]
Lead -->|paralelo| S4[Subagente 4]
S1 --> T1["3+ herramientas<br/>en paralelo"]
S2 --> T2["3+ herramientas<br/>en paralelo"]
S3 --> T3["3+ herramientas<br/>en paralelo"]
S4 --> T4["3+ herramientas<br/>en paralelo"]
T1 --> Agg[Resúmenes condensados]
T2 --> Agg
T3 --> Agg
T4 --> Agg
- Nivel líder: lanzar 3-5 subagentes en paralelo, nunca en serie.
- Nivel subagente: cada uno invoca 3 o más herramientas en paralelo.
Esa combinación redujo el tiempo de investigación hasta un 90% en el sistema de Anthropic. En su evaluación interna, el sistema multi-agente (líder Opus + subagentes Sonnet) superó al agente único en un 90,2%.
Regla de escalado de esfuerzo, que el coordinador debe aplicar explícitamente:
| Complejidad de la query | Asignación |
|---|---|
| Hecho simple y verificable | 1 agente, 3-10 llamadas a herramientas |
| Comparativa acotada | 2-4 subagentes |
| Investigación compleja multifuente | 10+ subagentes |
Cada subagente devuelve un resumen condensado de típicamente 1.000-2.000 tokens, no su transcripción completa. Eso es lo que mantiene la ventana del coordinador viable.
3. Inyección de contexto explícita en subagentes
Los subagentes no heredan el historial del coordinador. El coordinador construye un prompt limpio con solo lo necesario.
Qué inyectar vs qué omitir
| Inyectar (CRÍTICO) | Omitir |
|---|---|
| Query original del usuario | Historial completo del coordinador |
| Findings previos de otros agentes | Raw tool calls intermedios |
| Constraints (idioma, fecha límite, etc.) | Conversación interna de coordinación |
| Metadata de documentos/fuentes | Tokens de debug o trazas internas |
// context-builder.ts
import type { Finding } from './types';
interface SubagentContext {
query: string;
priorFindings: Finding[];
constraints: Record<string, string>;
}
export function buildSubagentPrompt(ctx: SubagentContext): string {
const findingsSummary = ctx.priorFindings.length === 0
? 'No hay hallazgos previos.'
: ctx.priorFindings.map((f, i) => {
const src = 'source_url' in f ? f.source_url : `doc:${f.document_id}:p${f.page}`;
return `[${i + 1}] "${f.claim}" (fuente: ${src}, confianza: ${f.confidence})`;
}).join('\n');
const constraintsText = Object.entries(ctx.constraints)
.map(([k, v]) => `- ${k}: ${v}`)
.join('\n');
return `## Query original
${ctx.query}
## Hallazgos previos de otros agentes
${findingsSummary}
## Constraints
${constraintsText}
Tu tarea: investigar la query con las herramientas disponibles.
El esquema de salida está garantizado por \`output_config.format\`: no lo repitas en prosa.`;
}
Las cuatro piezas obligatorias de una delegación
Antipatrón de delegación: instrucciones vagas al subagente («investiga la escasez de semiconductores») producen trabajo duplicado entre subagentes, huecos de cobertura y fallos silenciosos. El líder es el único que conoce el objetivo global, así que debe traducirlo.
Cada delegación debe llevar, como mínimo:
| Pieza | Contenido |
|---|---|
| Objetivo | Qué pregunta concreta responde este subagente, no el tema general |
| Formato de salida | El esquema exacto que debe devolver (WebFinding[], DocFinding[]…) |
| Guía sobre herramientas y fuentes | Qué herramientas usar y qué tipo de fuentes priorizar |
| Límites | Qué queda fuera de su alcance, para no solapar con otros subagentes |
buildSubagentPrompt cubre objetivo (query), formato y constraints; los límites son los que suelen faltar y los que generan el trabajo duplicado.
Implementación del web-search-agent
// web-search-agent.ts
import Anthropic from '@anthropic-ai/sdk';
import { buildSubagentPrompt } from './context-builder';
import type { WebFinding } from './types';
const client = new Anthropic();
const WEB_TOOLS: Anthropic.Tool[] = [
{
name: 'web_search',
description: 'Busca información en internet',
strict: true, // campo TOP-LEVEL de la tool, no dentro de input_schema
input_schema: {
type: 'object' as const,
properties: {
query: { type: 'string', description: 'Términos de búsqueda' }
},
required: ['query'],
additionalProperties: false // obligatorio con strict: true
}
},
{
name: 'web_fetch',
description: 'Obtiene el contenido de una URL',
strict: true,
input_schema: {
type: 'object' as const,
properties: {
url: { type: 'string', description: 'URL a obtener' }
},
required: ['url'],
additionalProperties: false
}
}
];
// Structured outputs exige un objeto en la raíz: se envuelve el array.
const WEB_FINDINGS_SCHEMA = {
type: 'object',
properties: {
findings: {
type: 'array',
items: {
type: 'object',
properties: {
claim: { type: 'string' },
source_url: { type: 'string' },
date_accessed: { type: 'string' },
confidence: { enum: ['high', 'medium', 'low'] },
excerpt: { type: 'string' }
},
required: ['claim', 'source_url', 'date_accessed', 'confidence', 'excerpt'],
additionalProperties: false
}
}
},
required: ['findings'],
additionalProperties: false
} as const;
export async function runWebSearchAgent(query: string): Promise<WebFinding[]> {
const prompt = buildSubagentPrompt({
query,
priorFindings: [],
constraints: {
date_accessed: new Date().toISOString(),
limites: 'No analices documentos locales: eso corresponde a document-analysis-agent.'
}
});
const response = await client.messages.create({
model: 'claude-haiku-4-5',
max_tokens: 2048,
tools: WEB_TOOLS,
output_config: {
format: { type: 'json_schema', schema: WEB_FINDINGS_SCHEMA }
},
system: 'Eres un agente de búsqueda web. Usa varias herramientas en paralelo por ronda.',
messages: [{ role: 'user', content: prompt }]
});
const text = response.content.find(b => b.type === 'text')?.text ?? '{"findings":[]}';
return (JSON.parse(text).findings ?? []) as WebFinding[];
}
4. Provenance tracking entre agentes
El rastreo de procedencia debe sobrevivir al paso por múltiples agentes. Cada transformación preserva el link al origen.
flowchart LR
WS["web-search-agent<br/>{claim, source_url, date}"]
DA["document-analysis-agent<br/>{claim, doc_id, page, excerpt}"]
SY["synthesis-agent<br/>Preserva claim-source mappings"]
RP["report-agent<br/>Citas inline por ID"]
WS -->|WebFinding| SY
DA -->|DocFinding| SY
SY -->|SynthesisResult con findings| RP
RP -->|"FinalReport con Citation[]"| User([Usuario])
La regla fundamental del synthesis-agent: nunca colapsar un hallazgo a texto plano si tiene fuente. Siempre preservar la referencia estructurada.
// synthesis-agent.ts
import Anthropic from '@anthropic-ai/sdk';
import type { Finding, SynthesisResult, Conflict } from './types';
const client = new Anthropic();
function detectConflicts(findings: Finding[]): Conflict[] {
// En producción: usar embeddings para detectar contradicciones semánticas.
// Aquí: placeholder estructural que el modelo puede expandir.
return [];
}
export async function runSynthesisAgent(
query: string,
findings: Finding[]
): Promise<SynthesisResult> {
const conflicts = detectConflicts(findings);
const prompt = `Query original: "${query}"
Hallazgos a sintetizar (${findings.length} total):
${JSON.stringify(findings, null, 2)}
Conflictos pre-detectados:
${JSON.stringify(conflicts, null, 2)}
Instrucciones:
1. Resume los hallazgos en relación a la query.
2. Mantén TODOS los findings con sus fuentes originales (no colapses).
3. Si dos findings se contradicen, anota el conflicto con resolution: "UNRESOLVED".
4. Lista los gaps (preguntas sin responder).`;
const response = await client.messages.create({
model: 'claude-sonnet-5',
max_tokens: 4096,
// Sin tools: synthesis-agent no accede a web ni archivos
output_config: {
format: { type: 'json_schema', schema: SYNTHESIS_SCHEMA }
},
system: 'Eres un agente de síntesis. Solo recibes datos de otros agentes.',
messages: [{ role: 'user', content: prompt }]
});
// Sonnet 5 trae thinking adaptativo activado por defecto: buscar por tipo.
const text = response.content.find(b => b.type === 'text')?.text ?? '{}';
return JSON.parse(text) as SynthesisResult;
}
El esquema que garantiza la forma de SynthesisResult:
// synthesis-schema.ts
const SYNTHESIS_SCHEMA = {
type: 'object',
properties: {
summary: { type: 'string' },
findings: { type: 'array', items: { type: 'object' } },
conflicts: {
type: 'array',
items: {
type: 'object',
properties: {
claim_a: { type: 'string' },
claim_b: { type: 'string' },
sources: { type: 'array', items: { type: 'string' } },
resolution: { enum: ['UNRESOLVED', 'A_PREFERRED', 'B_PREFERRED'] }
},
required: ['claim_a', 'claim_b', 'sources', 'resolution'],
additionalProperties: false
}
},
gaps: { type: 'array', items: { type: 'string' } }
},
required: ['summary', 'findings', 'conflicts', 'gaps'],
additionalProperties: false
} as const;
5. Detección y manejo de conflictos entre fuentes
Cuando dos agentes reportan claims contradictorios, el sistema no elige automáticamente. Anota el conflicto y lo escala.
flowchart TD
F1["Finding A: 'X es verdad'<br/>Fuente: web"] --> Compare{¿Contradicción?}
F2["Finding B: 'X es falso'<br/>Fuente: doc"] --> Compare
Compare -->|No| Merge[Merge normal en synthesis]
Compare -->|Sí| Conflict["Conflict:<br/>claim_a: 'X es verdad'<br/>claim_b: 'X es falso'<br/>sources: [...]<br/>resolution: 'UNRESOLVED'"]
Conflict --> SynthAgent[synthesis-agent lo recibe explícito]
SynthAgent --> ReportConflict["Reportar al usuario:<br/>'Fuente A dice X, fuente B dice lo contrario.<br/>Se requiere verificación manual.'"]
// conflict-detector.ts
import type { Finding, Conflict } from './types';
interface ConflictCandidate {
a: Finding;
b: Finding;
}
// Regla simple: si dos findings del mismo tema tienen confianza alta
// pero vienen de tipos de fuente distintos, marcar para revisión.
function sameTopicDifferentSource(a: Finding, b: Finding): boolean {
const aIsWeb = 'source_url' in a;
const bIsWeb = 'source_url' in b;
return aIsWeb !== bIsWeb; // tipos distintos de fuente
}
export function detectConflicts(findings: Finding[]): Conflict[] {
const conflicts: Conflict[] = [];
for (let i = 0; i < findings.length; i++) {
for (let j = i + 1; j < findings.length; j++) {
const a = findings[i];
const b = findings[j];
if (!sameTopicDifferentSource(a, b)) continue;
if (a.confidence !== 'high' || b.confidence !== 'high') continue;
const srcA = 'source_url' in a ? a.source_url : `doc:${a.document_id}`;
const srcB = 'source_url' in b ? b.source_url : `doc:${b.document_id}`;
conflicts.push({
claim_a: a.claim,
claim_b: b.claim,
sources: [srcA, srcB],
resolution: 'UNRESOLVED'
});
}
}
return conflicts;
}
6. Deadlock detection en sistemas multi-agente
Un deadlock ocurre cuando dos agentes se esperan mutuamente. En research puede suceder cuando:
document-analysis-agentnecesita URLs encontradas porweb-search-agentweb-search-agentnecesita keywords extraídas pordocument-analysis-agent
flowchart LR
DA["document-analysis-agent<br/>⏳ espera URLs de WS"]
WS["web-search-agent<br/>⏳ espera keywords de DA"]
DA -->|"necesita"| WS
WS -->|"necesita"| DA
style DA fill:#ff9999
style WS fill:#ff9999
Detección con dependency graph
// deadlock-detector.ts
interface Task {
id: string;
dependsOn: string[];
}
export function detectCircularDependency(tasks: Task[]): string[] | null {
const graph = new Map<string, string[]>(
tasks.map(t => [t.id, t.dependsOn])
);
const visited = new Set<string>();
const stack = new Set<string>();
function dfs(node: string, path: string[]): string[] | null {
if (stack.has(node)) return [...path, node]; // ciclo encontrado
if (visited.has(node)) return null;
visited.add(node);
stack.add(node);
for (const dep of graph.get(node) ?? []) {
const cycle = dfs(dep, [...path, node]);
if (cycle) return cycle;
}
stack.delete(node);
return null;
}
for (const task of tasks) {
const cycle = dfs(task.id, []);
if (cycle) return cycle;
}
return null;
}
// Uso en el coordinador:
// const cycle = detectCircularDependency([
// { id: 'web-search', dependsOn: ['document-analysis'] },
// { id: 'document-analysis', dependsOn: ['web-search'] }
// ]);
// if (cycle) throw new Error(`Deadlock detectado: ${cycle.join(' → ')}`);
Resolución: romper la dependencia circular
flowchart TD
Start[Coordinator planifica tasks] --> Check{detectCircularDependency}
Check -->|null| Proceed[Ejecutar en paralelo]
Check -->|ciclo detectado| Break[Romper dependencia]
Break --> SetDefault["Dar valor default al agente bloqueado<br/>(keywords vacías o URL placeholder)"]
SetDefault --> Timeout[Ejecutar con timeout independiente]
Timeout --> Merge[Merge partial results]
7. Timeout handling en herramientas
Una herramienta que no responde puede bloquear todo el pipeline. La estrategia: AbortController + partial_results como fallback.
// timeout-handler.ts
export interface ToolResult<T> {
data: T | null;
partial: boolean;
error?: string;
}
export async function executeWithTimeout<T>(
toolFn: (signal: AbortSignal) => Promise<T>,
timeoutMs: number,
fallback: T
): Promise<ToolResult<T>> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const data = await toolFn(controller.signal);
clearTimeout(timer);
return { data, partial: false };
} catch (err) {
clearTimeout(timer);
if (controller.signal.aborted) {
// Timeout: retry es posible en el siguiente ciclo
console.warn(`Tool timeout después de ${timeoutMs}ms. Usando fallback.`);
return { data: fallback, partial: true, error: 'timeout' };
}
// Error de negocio: no reintentar
const message = err instanceof Error ? err.message : String(err);
return { data: null, partial: true, error: message };
}
}
Distinguir timeout vs error de negocio
| Tipo | partial | error | ¿Reintentar? |
|---|---|---|---|
| Timeout | true | "timeout" | Sí, con backoff |
| Error HTTP 4xx | true | "http_4xx" | No |
| Error de parsing | true | "parse_error" | No |
| Éxito | false | — | N/A |
// Ejemplo de uso en web-search-agent:
async function searchWithTimeout(query: string) {
return executeWithTimeout(
async (_signal) => {
// llamada real a la API de búsqueda
return await mockWebSearch(query);
},
5000, // 5 segundos máximo
[] // fallback: array vacío (partial result)
);
}
async function mockWebSearch(_query: string): Promise<WebFinding[]> {
return []; // placeholder
}
interface WebFinding {
claim: string;
source_url: string;
date_accessed: string;
confidence: 'high' | 'medium' | 'low';
excerpt: string;
}
Presupuesto del bucle agéntico: Task Budgets
El timeout acota una herramienta; max_tokens acota una respuesta. Ninguno de los dos acota el bucle completo, que es lo que se desborda en un pipeline de investigación largo. Para eso están los 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-sonnet-5',
max_tokens: 4096,
output_config: {
task_budget: { type: 'tokens', total: 200_000 } // mínimo 20.000
},
tools: WEB_TOOLS,
messages
}, {
headers: { 'anthropic-beta': 'task-budgets-2026-03-13' }
});
La diferencia importa:
| Mecanismo | Alcance | ¿El modelo lo conoce? |
|---|---|---|
max_tokens | Una respuesta | No — es un corte duro e invisible |
output_config.task_budget | Todo el bucle agéntico | Sí — el modelo administra su gasto |
Timeout / AbortController | Una llamada a herramienta | No |
El mínimo del task_budget es 20.000 tokens y conviene usarlo con streaming.
Gestión de contexto en servidor
Cuando el bucle acumula demasiados pares tool_use/tool_result, hay dos mecanismos distintos que no se deben confundir:
| Mecanismo | Cabecera beta | Configuración | Qué hace |
|---|---|---|---|
| Compaction | compact-2026-01-12 | context_management: { edits: [{ type: 'compact_20260112' }] } | Resume el historial en servidor |
| Context editing | context-management-2025-06-27 | context_management: { edits: [{ type: 'clear_tool_uses_20250919' }] } | Borra resultados de herramientas antiguos |
Usar compact_20260112 bajo la cabecera de context-management da error: cada tipo va con su propia beta. Además, con compaction hay que reenviar response.content completo, no solo el texto.
8. Distribución de herramientas entre agentes
Principio del examen: demasiadas tools → menor confiabilidad. Cada agente recibe solo las herramientas necesarias para su rol.
| Agente | Tools permitidas | Justificación |
|---|---|---|
web-search-agent | WebSearch, WebFetch | Solo acceso a internet |
document-analysis-agent | Read, Grep | Solo acceso a archivos locales |
synthesis-agent | (ninguna) | Solo procesa datos recibidos |
report-agent | Write | Solo genera el artefacto final |
coordinator | Ninguna externa | Llama a subagentes vía código |
graph LR
subgraph "Herramientas por agente"
WS["web-search-agent"] --- T1["WebSearch<br/>WebFetch"]
DA["document-analysis-agent"] --- T2["Read<br/>Grep"]
SY["synthesis-agent"] --- T3["(sin tools externas)"]
RP["report-agent"] --- T4["Write"]
end
Por qué synthesis-agent NO debe tener WebSearch
Si synthesis-agent tiene acceso a WebSearch, puede:
- Hacer búsquedas adicionales fuera del scope de la query original
- Traer información sin pasar por
provenance tracking - Generar hallazgos sin el control de calidad del
web-search-agent
El síntesis recibe datos; no los recolecta. Darle WebSearch viola la separación de responsabilidades y crea hallazgos sin trazabilidad.
Acotar tools NO es una frontera de seguridad
Un matiz que el examen no debe dejar ambiguo: en Claude Code, «subagents run in the same process as the parent session and use the same sandbox configuration». Los subagentes no son un límite de seguridad. Su valor real es doble:
- Aislamiento de contexto: cada uno trabaja en una ventana limpia y devuelve solo el resumen.
- Restricción de herramientas: reduce la ambigüedad de elección y por tanto los errores.
El enforcement duro se hace en otra capa:
| Objetivo | Mecanismo correcto |
|---|---|
| Impedir de verdad el uso de una herramienta | permissions.deny |
| Deshabilitar un subagente completo | permissions.deny: ["Agent(NombreAgente)"] |
| Contener escrituras y red | Sandboxing OS-level (/sandbox) |
| Reducir superficie de elección del modelo | Campo tools del subagente |
Nota relacionada, porque se confunden: en un subagente el campo tools sí funciona como whitelist, mientras que allowed-tools de una skill pre-aprueba herramientas durante el turno que la invoca y no restringe nada (para restringir en una skill se usa disallowed-tools).
// report-agent.ts
import Anthropic from '@anthropic-ai/sdk';
import type { SynthesisResult, FinalReport, Citation } from './types';
const client = new Anthropic();
const REPORT_TOOLS: Anthropic.Tool[] = [
{
name: 'write_file',
description: 'Escribe el reporte final a disco',
input_schema: {
type: 'object' as const,
properties: {
filename: { type: 'string' },
content: { type: 'string' }
},
required: ['filename', 'content']
}
}
];
export async function runReportAgent(
query: string,
synthesis: SynthesisResult
): Promise<FinalReport> {
const citations: Citation[] = synthesis.findings.map((f, i) => ({
id: `ref-${i + 1}`,
source: 'source_url' in f ? f.source_url : `${f.document_id}:p${f.page}`,
accessed: 'date_accessed' in f ? f.date_accessed : undefined,
page: 'page' in f ? f.page : undefined,
}));
const prompt = `Genera un reporte sobre: "${query}"
Síntesis: ${synthesis.summary}
Conflictos a reportar:
${synthesis.conflicts.map(c =>
`- "${c.claim_a}" vs "${c.claim_b}" — ${c.resolution}`
).join('\n')}
Gaps identificados: ${synthesis.gaps.join(', ')}
Citas disponibles:
${citations.map(c => `[${c.id}] ${c.source}`).join('\n')}
Usa [ref-N] inline para citar fuentes. Si hay conflictos UNRESOLVED, indícalos explícitamente.`;
const response = await client.messages.create({
model: 'claude-sonnet-5',
max_tokens: 4096,
tools: REPORT_TOOLS,
output_config: {
format: {
type: 'json_schema',
schema: {
type: 'object',
properties: {
title: { type: 'string' },
body: { type: 'string' },
citations: { type: 'array', items: { type: 'object' } }
},
required: ['title', 'body', 'citations'],
additionalProperties: false
}
}
},
system: 'Eres un agente de generación de reportes. Solo escribes, no investigas.',
messages: [{ role: 'user', content: prompt }]
});
const text = response.content.find(b => b.type === 'text')?.text ?? '{}';
return JSON.parse(text) as FinalReport;
}
9. Scenario 4: Developer Productivity
El escenario presenta un agente que ayuda a ingenieros a trabajar con codebases desconocidos. El principio central: explorar antes de actuar.
Flujo de onboarding a un codebase
flowchart TD
Start([Ingeniero pide ayuda]) --> Interview["Interview:<br/>¿Qué quieres hacer?<br/>¿Conoces el codebase?"]
Interview --> Explore[Exploración incremental]
Explore --> Grep["Grep: buscar<br/>patrones relevantes"]
Grep --> Read["Read: leer archivos<br/>clave encontrados"]
Read --> Trace["Trazar flujos:<br/>Glob → función → callers"]
Trace --> Understand{¿Suficiente contexto?}
Understand -->|No| Grep
Understand -->|Sí| Plan[Plan de acción]
Plan --> Boilerplate["Autocompletar boilerplate<br/>con patrones del proyecto"]
Plan --> MCP["MCP servers:<br/>sistemas internos"]
Boilerplate --> Confirm[Confirmar con el ingeniero]
MCP --> Confirm
Confirm --> Execute[Ejecutar cambio]
Patrón: interview antes de actuar
// dev-productivity-agent.ts
import Anthropic from '@anthropic-ai/sdk';
const client = new Anthropic();
const DEV_TOOLS: Anthropic.Tool[] = [
{
name: 'grep_codebase',
description: 'Busca patrones en el codebase',
input_schema: {
type: 'object' as const,
properties: {
pattern: { type: 'string' },
path: { type: 'string', description: 'Directorio raíz (opcional)' }
},
required: ['pattern']
}
},
{
name: 'read_file',
description: 'Lee el contenido de un archivo',
input_schema: {
type: 'object' as const,
properties: { path: { type: 'string' } },
required: ['path']
}
},
{
name: 'glob_files',
description: 'Lista archivos por patrón glob',
input_schema: {
type: 'object' as const,
properties: { pattern: { type: 'string' } },
required: ['pattern']
}
}
];
const SYSTEM_PROMPT = `Eres un asistente de productividad para desarrolladores.
ANTES de hacer cualquier cambio:
1. Haz preguntas de clarificación si la solicitud es ambigua.
2. Explora el codebase con grep/read/glob para entender los patrones existentes.
3. Presenta un plan y pide confirmación.
SIEMPRE:
- Adapta el código nuevo a los patrones del proyecto (naming, estructura, estilo).
- Cita los archivos que revisaste para justificar tus decisiones.
- Si encuentras múltiples formas de hacer algo, explica el trade-off.`;
export async function runDevAgent(userRequest: string): Promise<string> {
const messages: Anthropic.MessageParam[] = [
{ role: 'user', content: userRequest }
];
let iterations = 0;
const MAX_ITERATIONS = 10;
while (iterations < MAX_ITERATIONS) {
iterations++;
const response = await client.messages.create({
model: 'claude-opus-4-8', // alias `opus`; los IDs desde 4.6 no llevan sufijo de fecha
max_tokens: 4096,
system: SYSTEM_PROMPT,
tools: DEV_TOOLS,
messages
});
if (response.stop_reason === 'end_turn') {
const text = response.content.find(b => b.type === 'text')?.text ?? '';
return text;
}
if (response.stop_reason !== 'tool_use') break;
// Procesar tool calls
const toolResults: Anthropic.ToolResultBlockParam[] = [];
for (const block of response.content) {
if (block.type !== 'tool_use') continue;
const result = await executeDevTool(block.name, block.input as Record<string, string>);
toolResults.push({
type: 'tool_result',
tool_use_id: block.id,
content: result
});
}
messages.push({ role: 'assistant', content: response.content });
messages.push({ role: 'user', content: toolResults });
}
return 'Máximo de iteraciones alcanzado.';
}
async function executeDevTool(name: string, input: Record<string, string>): Promise<string> {
// En producción: implementar las llamadas reales a filesystem/shell
switch (name) {
case 'grep_codebase': return `[grep results for: ${input.pattern}]`;
case 'read_file': return `[content of: ${input.path}]`;
case 'glob_files': return `[files matching: ${input.pattern}]`;
default: return '[unknown tool]';
}
}
Integración con MCP servers
Para acceso a sistemas internos (Jira, bases de datos internas, CI/CD), el agente de productividad se conecta mediante MCP servers. La ventaja sobre tools hardcodeadas: el servidor MCP puede actualizarse sin cambiar el agente.
En Claude Code la configuración va en .mcp.json en la raíz del repositorio (scope project, versionado y compartido con el equipo):
{
"mcpServers": {
"internal-jira": {
"command": "node",
"args": ["./mcp-servers/jira-server.js"],
"env": { "JIRA_TOKEN": "..." }
},
"internal-db": {
"type": "http",
"url": "https://db-mcp.interno.example.com/mcp"
}
}
}
También se registran desde la CLI con claude mcp add o claude mcp add-json.
Scopes y precedencia
| Scope | Dónde vive | Alcance |
|---|---|---|
local (default) | ~/.claude.json, bajo la ruta del proyecto | Solo tú, solo este proyecto |
project | .mcp.json en la raíz del repo | Compartido con el equipo |
user | ~/.claude.json | Todos tus proyectos |
Precedencia, usando la definición completa de la fuente ganadora (sin merge de campos): local > project > user > servidores de plugins > conectores de claude.ai. Los tres scopes hacen match por nombre; plugins y conectores, por endpoint (URL o comando).
Transportes
| Transporte | Estado |
|---|---|
stdio | Default si el entry no declara type |
http | Streamable HTTP — recomendado para servidores remotos |
sse | Deprecado |
ws | WebSocket; solo configurable por .mcp.json o claude mcp add-json |
Un entry con url pero sin type es error de configuración: el servidor se omite.
Tool search: por qué ya no hay que racionar servidores
Las definiciones de herramientas MCP se difieren por defecto y solo se cargan bajo demanda mediante la herramienta ToolSearch. Al inicio de la sesión entran únicamente los nombres de las herramientas y las instrucciones del servidor, así que no hay tope fijo de herramientas por servidor: el límite práctico es el presupuesto de contexto.
Controles disponibles:
| Ajuste | Efecto |
|---|---|
ENABLE_TOOL_SEARCH sin definir | Todo diferido |
ENABLE_TOOL_SEARCH=auto | Carga upfront si las definiciones caben en el 10% de la ventana; difiere el resto |
ENABLE_TOOL_SEARCH=auto:N | Umbral porcentual custom |
ENABLE_TOOL_SEARCH=true / false | Todo diferido / todo upfront |
permissions.deny: ["ToolSearch"] | Desactiva el mecanismo |
"alwaysLoad": true en el entry del servidor | Exime a ese servidor del diferido |
Consecuencia para el diseño: el argumento «conecta pocos servidores para no quemar contexto» ya no aplica. El riesgo residual de conectar muchos servidores es de superficie de ataque y permisos, no de tokens: «Verify you trust each server before connecting it. Servers that fetch external content can expose you to prompt injection risk.» En un sistema de investigación, donde los subagentes procesan contenido web ajeno, esa advertencia es el factor decisivo, no el coste.
10. Iterative refinement en research
La investigación no es lineal. El agente debe identificar gaps y refinar.
flowchart TD
Query[Query inicial] --> Round1[Ronda 1: investigación amplia]
Round1 --> Synth1[Síntesis parcial]
Synth1 --> Gaps{¿Gaps identificados?}
Gaps -->|Sí, quedan preguntas| Round2[Ronda N: investigación específica]
Round2 --> SynthN[Síntesis acumulativa]
SynthN --> Gaps
Gaps -->|No, todas respondidas| Done[Reporte final]
Synth1 --> StopCheck{¿Over-investigation?}
StopCheck -->|iterations > MAX o confianza alta| Done
Criterios de completitud
// iterative-research.ts
import type { SynthesisResult, Finding, FinalReport } from './types';
interface ResearchState {
query: string;
allFindings: Finding[];
iterations: number;
synthesis: SynthesisResult | null;
}
const MAX_ITERATIONS = 3;
const MIN_FINDINGS_PER_GAP = 2;
function isComplete(state: ResearchState): boolean {
if (state.iterations >= MAX_ITERATIONS) return true;
if (!state.synthesis) return false;
// Completo si no hay gaps sin resolver
const hasGaps = state.synthesis.gaps.length > 0;
if (!hasGaps) return true;
// Completo si ya investigamos suficiente cada gap
const avgFindingsPerGap = state.allFindings.length / Math.max(state.synthesis.gaps.length, 1);
return avgFindingsPerGap >= MIN_FINDINGS_PER_GAP;
}
export async function iterativeResearch(
query: string,
searchFn: (q: string) => Promise<Finding[]>,
synthesisFn: (q: string, findings: Finding[]) => Promise<SynthesisResult>,
reportFn: (q: string, s: SynthesisResult) => Promise<FinalReport>
): Promise<FinalReport> {
const state: ResearchState = {
query,
allFindings: [],
iterations: 0,
synthesis: null
};
while (!isComplete(state)) {
state.iterations++;
// En iteraciones posteriores, refinar con los gaps identificados
const searchQuery = state.synthesis?.gaps.length
? `${query} — específicamente: ${state.synthesis.gaps[0]}`
: query;
const newFindings = await searchFn(searchQuery);
state.allFindings.push(...newFindings);
state.synthesis = await synthesisFn(query, state.allFindings);
}
return reportFn(query, state.synthesis!);
}
Evitar over-investigation
| Señal | Acción |
|---|---|
iterations >= MAX_ITERATIONS | Detener, reportar con gaps abiertos |
| Nuevos findings duplican claims ya conocidos | Detener (rendimiento decreciente) |
| Todos los gaps respondidos | Detener (completitud alcanzada) |
Confianza promedio >= high en todos los claims | Detener |
El reporte final debe incluir explícitamente los gaps que quedaron sin resolver, para que el usuario decida si requiere investigación adicional.
Cómo evaluar el sistema
Un criterio de parada no dice si el resultado es bueno. La guía canónica de Anthropic para research systems:
- Empezar con ~20 queries representativas, no esperar a tener evals a gran escala. Con un sistema no determinista, 20 casos ya revelan los modos de falla dominantes.
- LLM-as-judge con rúbrica sobre cinco ejes: exactitud factual, exactitud de las citas, completitud, calidad de las fuentes y eficiencia en el uso de herramientas.
- Evaluar el estado final, no los pasos exactos. Dos ejecuciones válidas pueden tomar rutas distintas; exigir una secuencia concreta de llamadas penaliza comportamiento correcto.
flowchart LR
Q["~20 queries<br/>representativas"] --> Run[Ejecutar el sistema]
Run --> Judge["LLM-as-judge<br/>con rúbrica"]
Judge --> R1[Exactitud factual]
Judge --> R2[Exactitud de citas]
Judge --> R3[Completitud]
Judge --> R4[Calidad de fuentes]
Judge --> R5[Eficiencia de herramientas]
R1 --> Score["Evaluar el ESTADO FINAL,<br/>no los pasos exactos"]
R2 --> Score
R3 --> Score
R4 --> Score
R5 --> Score
Operación en producción
Los agentes de larga duración son stateful: un fallo a mitad de camino no se puede tratar como una request HTTP fallida.
| Práctica | Motivo |
|---|---|
| Reanudar desde el punto de fallo, no reiniciar | Reiniciar tira todo el trabajo ya pagado en tokens |
| Tracing completo del pipeline | Sin traza no se puede depurar un sistema no determinista |
| Avisar al agente cuando una herramienta falla | El modelo puede adaptarse; el fallo silencioso lo lleva a conclusiones erróneas |
| Despliegues «rainbow» | Actualizar sin interrumpir agentes en ejecución |
| Subagentes que guardan su trabajo en sistemas externos | Pasan referencias ligeras al coordinador en vez de payloads completos |
El último punto conecta con el presupuesto de contexto: si un subagente vuelca 50.000 tokens de hallazgos en la ventana del coordinador, la topología hub-and-spoke deja de escalar. La alternativa es persistir el resultado fuera y devolver un identificador más un resumen de 1.000-2.000 tokens.
Resumen de principios del examen
| Principio | Aplicación en el sistema |
|---|---|
| Tools acotadas por agente | Cada agente tiene solo las tools de su dominio |
| Contexto explícito en subagentes | buildSubagentPrompt inyecta solo lo necesario |
| Provenance preservado | Finding tipado sobrevive todo el pipeline |
| Conflictos anotados, no resueltos automáticamente | resolution: "UNRESOLVED" escala al usuario |
| Deadlock detection antes de ejecutar | detectCircularDependency valida el DAG de tasks |
| Timeout con partial results | executeWithTimeout permite continuar con datos parciales |
| Explorar antes de actuar | Dev agent hace interview + grep antes de cambios |
| Iterative refinement con criterio de parada | isComplete() previene over-investigation |
| Multi-agente solo si el valor paga el sobrecoste | ~15x tokens vs. chat; subir de modelo rinde más que duplicar tokens |
| Paralelismo en dos niveles | 3-5 subagentes en paralelo, cada uno con 3+ herramientas en paralelo |
| Delegación explícita | Objetivo + formato + guía de herramientas + límites en cada subagente |
| Salida garantizada por esquema | output_config.format con json_schema, no «devuelve SOLO JSON» |
| Lectura robusta de la respuesta | content.find(b => b.type === 'text'), nunca content[0] |
| Presupuesto visible para el modelo | output_config.task_budget sobre todo el bucle, no solo max_tokens |
| Los subagentes no son frontera de seguridad | Enforcement con permissions.deny y sandbox OS-level |
| Evaluación desde el día uno | ~20 queries + LLM-as-judge sobre el estado final |
11. Conflict resolution policy — quién decide
El examen pregunta sobre el proceso de decisión cuando hay conflictos entre fuentes.
FACTUAL — fechas, números, hechos verificables
- Buscar fuente primaria: documentos oficiales > artículos > blogs
- Si no hay fuente primaria clara → reportar ambas versiones con fuentes
- NUNCA elegir basado en “más reciente” sin verificar (puede ser un error actualizado)
type SourceTier = 'official' | 'article' | 'blog' | 'unknown';
interface FactualConflict {
claim_a: string; source_a: string; tier_a: SourceTier;
claim_b: string; source_b: string; tier_b: SourceTier;
}
const TIER_PRIORITY: Record<SourceTier, number> = {
official: 3, article: 2, blog: 1, unknown: 0
};
function resolveFact(conflict: FactualConflict): { winner: string; confidence: 'high' | 'low' } {
const diff = TIER_PRIORITY[conflict.tier_a] - TIER_PRIORITY[conflict.tier_b];
if (diff > 0) return { winner: conflict.claim_a, confidence: 'high' };
if (diff < 0) return { winner: conflict.claim_b, confidence: 'high' };
// Mismo tier → no se puede resolver automáticamente
return { winner: `Conflicto sin resolver: "${conflict.claim_a}" vs "${conflict.claim_b}"`, confidence: 'low' };
}
INTERPRETIVE — opiniones, análisis, conclusiones
No hay “correcto”. El usuario decide cuál es más relevante para su contexto.
interface InterpretiveConflict {
topic: string;
perspective_a: { claim: string; source: string };
perspective_b: { claim: string; source: string };
}
function renderInterpretiveConflict(conflict: InterpretiveConflict): string {
return `**${conflict.topic}** — perspectivas en conflicto:
- Según [${conflict.perspective_a.source}]: ${conflict.perspective_a.claim}
- Según [${conflict.perspective_b.source}]: ${conflict.perspective_b.claim}
*Ambas perspectivas son válidas. El contexto de aplicación determina cuál es más relevante.*`;
}
TEMPORAL — dato que cambió con el tiempo
interface TemporalConflict {
claim: string;
versions: Array<{ value: string; date: string; source: string }>;
}
function renderTemporalConflict(conflict: TemporalConflict): string {
const sorted = [...conflict.versions].sort(
(a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()
);
const current = sorted[sorted.length - 1];
const history = sorted.slice(0, -1);
const historyLines = history
.map(v => ` - ${v.date}: ${v.value} (${v.source})`)
.join('\n');
return `**${conflict.claim}**
- **Actual** (${current.date}): ${current.value} — ${current.source}
- Histórico:\n${historyLines}`;
}
Provenance overflow — demasiadas fuentes
Si hay más de 5 fuentes para una afirmación → consolidar en grupos:
- Mostrar las 3 más authoritativas + “y N más”
- Para reportes: inline citation solo para claims controversiales, bibliography para el resto
flowchart TD
Conflict[Conflicto detectado] --> Type{Tipo de conflicto}
Type -->|Factual| F1{¿Hay fuente primaria?}
F1 -->|Sí| F2[Usar fuente de mayor tier]
F1 -->|No| F3[Reportar ambas versiones con atribución]
Type -->|Interpretive| I1[Presentar ambas perspectivas]
I1 --> I2[El usuario decide]
Type -->|Temporal| T1[Ordenar cronológicamente]
T1 --> T2[Marcar más reciente como actual]
T2 --> T3[Mostrar evolución histórica]
F2 --> Report[Incluir en reporte]
F3 --> Report
I2 --> Report
T3 --> Report
12. Escalation de subagente a coordinador
Cuándo un subagente debe escalar vs intentar resolver solo:
| Situación | Acción |
|---|---|
| Acceso denegado a recurso | Escalar — el coordinador puede tener credenciales o redirigir |
| Información ambigua que afecta el scope completo | Escalar — el coordinador conoce el objetivo global |
| Timeout que impide completar la tarea | Escalar con partial results |
| Timeout parcial (parte del trabajo completada) | NO escalar — usar partial results y continuar |
| Formato inesperado en la respuesta | NO escalar — intentar parsear o normalizar primero |
type FailureType = 'access_denied' | 'ambiguous_scope' | 'timeout_complete' | 'partial_timeout' | 'parse_error';
interface EscalationMessage {
agentId: string;
failureType: FailureType;
attemptedQuery: string;
partialResults: Finding[];
suggestedAction: 'retry_with_other_agent' | 'skip' | 'report_gap';
}
async function escalateToCoordinator(
context: EscalationMessage
): Promise<void> {
// El subagente devuelve un tool_result especial con la escalation
// El coordinador lo recibe como parte del pipeline normal
throw new EscalationError(context);
}
class EscalationError extends Error {
constructor(public readonly escalation: EscalationMessage) {
super(`ESCALATION: ${escalation.failureType} in ${escalation.agentId}`);
}
}
// En el coordinador: manejar escalaciones
async function handleSubagentResult(
agentId: string,
resultPromise: Promise<Finding[]>
): Promise<{ findings: Finding[]; gap?: string }> {
try {
const findings = await resultPromise;
return { findings };
} catch (err) {
if (err instanceof EscalationError) {
const { escalation } = err;
switch (escalation.suggestedAction) {
case 'retry_with_other_agent':
// Coordinador reintenta con agente alternativo
return { findings: escalation.partialResults };
case 'skip':
// Continuar con partial results
return { findings: escalation.partialResults };
case 'report_gap':
// Documentar el gap para el reporte final
return {
findings: escalation.partialResults,
gap: `No se pudo completar: ${escalation.attemptedQuery} (${escalation.failureType})`
};
}
}
throw err;
}
}
Siguiente: API Fundamentals para Arquitectos