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<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étricaValor
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 tokens80%
Varianza sumando llamadas a herramientas y elección de modelo95%

De ahí se derivan dos reglas operativas:

  1. 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.
  2. 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_format y la cabecera structured-outputs-2025-11-13 solo funcionan durante un periodo de transición; lo vigente es output_config.format.
  • Para definiciones de herramientas, el campo equivalente es strict: true a nivel top-level de la tool (no dentro de input_schema), y exige additionalProperties: false más required.
  • 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
  1. Nivel líder: lanzar 3-5 subagentes en paralelo, nunca en serie.
  2. 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 queryAsignación
Hecho simple y verificable1 agente, 3-10 llamadas a herramientas
Comparativa acotada2-4 subagentes
Investigación compleja multifuente10+ 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 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.
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:

PiezaContenido
ObjetivoQué pregunta concreta responde este subagente, no el tema general
Formato de salidaEl esquema exacto que debe devolver (WebFinding[], DocFinding[]…)
Guía sobre herramientas y fuentesQué herramientas usar y qué tipo de fuentes priorizar
LímitesQué 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-agent necesita URLs encontradas por web-search-agent
  • web-search-agent necesita keywords extraídas por document-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

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

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:

MecanismoAlcance¿El modelo lo conoce?
max_tokensUna respuestaNo — es un corte duro e invisible
output_config.task_budgetTodo el bucle agénticoSí — el modelo administra su gasto
Timeout / AbortControllerUna llamada a herramientaNo

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:

MecanismoCabecera betaConfiguraciónQué hace
Compactioncompact-2026-01-12context_management: { edits: [{ type: 'compact_20260112' }] }Resume el historial en servidor
Context editingcontext-management-2025-06-27context_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.

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<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:

  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.

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:

  1. Aislamiento de contexto: cada uno trabaja en una ventana limpia y devuelve solo el resumen.
  2. Restricción de herramientas: reduce la ambigüedad de elección y por tanto los errores.

El enforcement duro se hace en otra capa:

ObjetivoMecanismo correcto
Impedir de verdad el uso de una herramientapermissions.deny
Deshabilitar un subagente completopermissions.deny: ["Agent(NombreAgente)"]
Contener escrituras y redSandboxing OS-level (/sandbox)
Reducir superficie de elección del modeloCampo 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

ScopeDónde viveAlcance
local (default)~/.claude.json, bajo la ruta del proyectoSolo tú, solo este proyecto
project.mcp.json en la raíz del repoCompartido con el equipo
user~/.claude.jsonTodos 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

TransporteEstado
stdioDefault si el entry no declara type
httpStreamable HTTP — recomendado para servidores remotos
sseDeprecado
wsWebSocket; 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:

AjusteEfecto
ENABLE_TOOL_SEARCH sin definirTodo diferido
ENABLE_TOOL_SEARCH=autoCarga upfront si las definiciones caben en el 10% de la ventana; difiere el resto
ENABLE_TOOL_SEARCH=auto:NUmbral porcentual custom
ENABLE_TOOL_SEARCH=true / falseTodo diferido / todo upfront
permissions.deny: ["ToolSearch"]Desactiva el mecanismo
"alwaysLoad": true en el entry del servidorExime 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ñ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.

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ácticaMotivo
Reanudar desde el punto de fallo, no reiniciarReiniciar tira todo el trabajo ya pagado en tokens
Tracing completo del pipelineSin traza no se puede depurar un sistema no determinista
Avisar al agente cuando una herramienta fallaEl 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 externosPasan 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

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
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 niveles3-5 subagentes en paralelo, cada uno con 3+ herramientas en paralelo
Delegación explícitaObjetivo + formato + guía de herramientas + límites en cada subagente
Salida garantizada por esquemaoutput_config.format con json_schema, no «devuelve SOLO JSON»
Lectura robusta de la respuestacontent.find(b => b.type === 'text'), nunca content[0]
Presupuesto visible para el modelooutput_config.task_budget sobre todo el bucle, no solo max_tokens
Los subagentes no son frontera de seguridadEnforcement 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ó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: API Fundamentals para Arquitectos