Cap 25: Sistema Multi-Agente de Investigación

Por: Artiko
claude-codemulti-agentresearchorchestrationsdkcertificationsdomain1domain2

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 usuarioHistorial completo del coordinador
Findings previos de otros agentesRaw tool calls intermedios
Constraints (idioma, fecha límite, etc.)Conversación interna de coordinación
Metadata de documentos/fuentesTokens 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:

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

Tipopartialerror¿Reintentar?
Timeouttrue"timeout"Sí, con backoff
Error HTTP 4xxtrue"http_4xx"No
Error de parsingtrue"parse_error"No
ÉxitofalseN/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.

AgenteTools permitidasJustificación
web-search-agentWebSearch, WebFetchSolo acceso a internet
document-analysis-agentRead, GrepSolo acceso a archivos locales
synthesis-agent(ninguna)Solo procesa datos recibidos
report-agentWriteSolo genera el artefacto final
coordinatorNinguna externaLlama 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:

  1. Hacer búsquedas adicionales fuera del scope de la query original
  2. Traer información sin pasar por provenance tracking
  3. 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ñalAcción
iterations >= MAX_ITERATIONSDetener, reportar con gaps abiertos
Nuevos findings duplican claims ya conocidosDetener (rendimiento decreciente)
Todos los gaps respondidosDetener (completitud alcanzada)
Confianza promedio >= high en todos los claimsDetener

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

PrincipioAplicación en el sistema
Tools acotadas por agenteCada agente tiene solo las tools de su dominio
Contexto explícito en subagentesbuildSubagentPrompt inyecta solo lo necesario
Provenance preservadoFinding tipado sobrevive todo el pipeline
Conflictos anotados, no resueltos automáticamenteresolution: "UNRESOLVED" escala al usuario
Deadlock detection antes de ejecutardetectCircularDependency valida el DAG de tasks
Timeout con partial resultsexecuteWithTimeout permite continuar con datos parciales
Explorar antes de actuarDev agent hace interview + grep antes de cambios
Iterative refinement con criterio de paradaisComplete() 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

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:

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ónAcción
Acceso denegado a recursoEscalar — el coordinador puede tener credenciales o redirigir
Información ambigua que afecta el scope completoEscalar — el coordinador conoce el objetivo global
Timeout que impide completar la tareaEscalar con partial results
Timeout parcial (parte del trabajo completada)NO escalar — usar partial results y continuar
Formato inesperado en la respuestaNO 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