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\nWebFetch"/]
DA --- T2[/"Read\nGrep"/]
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.
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-5',
max_tokens: 256,
system: 'Responde SOLO con JSON válido. Sin explicaciones.',
messages: [{
role: 'user',
content: `Analiza esta query y devuelve JSON con:
{"useWebSearch": bool, "useDocAnalysis": bool}
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).`
}]
});
const text = response.content[0].type === 'text' ? response.content[0].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);
}
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.
Devuelve SOLO JSON con el esquema solicitado.`;
}
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',
input_schema: {
type: 'object' as const,
properties: {
query: { type: 'string', description: 'Términos de búsqueda' }
},
required: ['query']
}
},
{
name: 'web_fetch',
description: 'Obtiene el contenido de una URL',
input_schema: {
type: 'object' as const,
properties: {
url: { type: 'string', description: 'URL a obtener' }
},
required: ['url']
}
}
];
export async function runWebSearchAgent(query: string): Promise<WebFinding[]> {
const prompt = buildSubagentPrompt({
query,
priorFindings: [],
constraints: { format: 'JSON array de WebFinding', date_accessed: new Date().toISOString() }
});
const response = await client.messages.create({
model: 'claude-haiku-4-5',
max_tokens: 2048,
tools: WEB_TOOLS,
system: 'Eres un agente de búsqueda web. Devuelve SOLO JSON: array de {claim, source_url, date_accessed, confidence, excerpt}.',
messages: [{ role: 'user', content: prompt }]
});
const text = response.content.find(b => b.type === 'text')?.text ?? '[]';
return JSON.parse(text) 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\n{claim, source_url, date}"]
DA["document-analysis-agent\n{claim, doc_id, page, excerpt}"]
SY["synthesis-agent\nPreserva claim-source mappings"]
RP["report-agent\nCitas 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).
Devuelve SOLO JSON con esquema: {summary, findings, conflicts, gaps}`;
const response = await client.messages.create({
model: 'claude-sonnet-4-5',
max_tokens: 4096,
// Sin tools: synthesis-agent no accede a web ni archivos
system: 'Eres un agente de síntesis. Solo recibes datos de otros agentes. Devuelve SOLO JSON.',
messages: [{ role: 'user', content: prompt }]
});
const text = response.content[0].type === 'text' ? response.content[0].text : '{}';
return JSON.parse(text) as SynthesisResult;
}
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"\nFuente: web] --> Compare{¿Contradicción?}
F2[Finding B: "X es falso"\nFuente: doc] --> Compare
Compare -->|No| Merge[Merge normal en synthesis]
Compare -->|Sí| Conflict["Conflict{\n claim_a: 'X es verdad',\n claim_b: 'X es falso',\n sources: [...],\n resolution: 'UNRESOLVED'\n}"]
Conflict --> SynthAgent[synthesis-agent lo recibe explícito]
SynthAgent --> ReportConflict["Reportar al usuario:\n'Fuente A dice X, fuente B dice lo contrario.\nSe 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\n⏳ espera URLs de WS"]
WS["web-search-agent\n⏳ 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\n(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;
}
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\nWebFetch"]
DA["document-analysis-agent"] --- T2["Read\nGrep"]
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.
// 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.
Devuelve SOLO JSON: {title, body, citations}`;
const response = await client.messages.create({
model: 'claude-sonnet-4-5',
max_tokens: 4096,
tools: REPORT_TOOLS,
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:\n¿Qué quieres hacer?\n¿Conoces el codebase?"]
Interview --> Explore[Exploración incremental]
Explore --> Grep["Grep: buscar\npatrones relevantes"]
Grep --> Read["Read: leer archivos\nclave encontrados"]
Read --> Trace["Trazar flujos:\nGlob → función → callers"]
Trace --> Understand{¿Suficiente contexto?}
Understand -->|No| Grep
Understand -->|Sí| Plan[Plan de acción]
Plan --> Boilerplate["Autocompletar boilerplate\ncon patrones del proyecto"]
Plan --> MCP["MCP servers:\nsistemas 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-5',
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.
// mcp-integration.ts
// Configuración en claude_desktop_config.json o equivalente:
//
// {
// "mcpServers": {
// "internal-jira": {
// "command": "node",
// "args": ["./mcp-servers/jira-server.js"],
// "env": { "JIRA_TOKEN": "..." }
// },
// "internal-db": {
// "command": "node",
// "args": ["./mcp-servers/db-server.js"]
// }
// }
// }
//
// El agente obtiene las tools del MCP server automáticamente.
// No necesita hardcodear el schema de cada herramienta interna.
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.
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 |
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: Índice