Resumen
- Un router útil con esfuerzo Low necesita cuatro decisiones separadas: clase de tarea, modelo, límite de ejecución y criterio de aceptación.
- No le pidas a un modelo que elija el modelo para cada solicitud. Empieza con metadatos de tarea explícitos como
routine,normaloambiguous; haz que la política sea determinista y verificable. - Una política inicial práctica es Sonnet 5 Low para trabajo acotado, Opus 4.8 Low para trabajo normal solo después de que supere la evaluación de tu repositorio y Fable 5 Low para casos ambiguos o con un alto costo de reinicio.
- El Claude Agent SDK permite configurar
model,effort,maxTurns,maxBudgetUsd,cwd, las herramientas y el modo de permisos para cada ejecución. - Mantén las ejecuciones de planificación en modo de solo lectura. Ejecuta los agentes de código en worktrees o sandboxes desechables, elimina
Bashde las herramientas del modelo y ejecuta las pruebas permitidas desde el controlador, no desde la salida del modelo. - Compara el router con líneas base de modelos fijos usando las mismas tareas y commits. Mide la tasa de tareas aceptadas, el costo por resultado aceptado, la tasa de escalamiento, los resultados de la rúbrica de planificación y las infracciones de la política.
- Lee
total_cost_usd,num_turnsymodelUsagedel resultado del SDK.modelUsageofrece la vista correcta de todo el árbol cuando intervienen subagentes. - Empieza con registros JSONL locales. Agrega OpenTelemetry cuando la política pase a un entorno compartido o de producción.
Qué aprenderás aquí
- Cómo definir una política de enrutamiento pequeña y determinista
- Cómo ejecutar cada ruta mediante el Claude Agent SDK con esfuerzo Low
- Cómo separar las herramientas de planificación de las herramientas de código
- Cómo aislar las ejecuciones con escritura y mantener las pruebas fuera del control del modelo
- Cómo construir una matriz de evaluación representativa
- Cómo evaluar de forma distinta las tareas de código y las de planificación
- Cómo calcular el costo por resultado aceptado
- Cómo decidir si el router es mejor que un único modelo predeterminado aprobado
Qué es este router y qué no es
Esta guía implementa una capa de enrutamiento para equipos que ya decidieron probar el esfuerzo Low.
No es:
- una afirmación de que Low deba reemplazar los valores predeterminados de Anthropic
- un clasificador semántico entrenado por otro modelo
- un sistema autónomo de permisos
- un planificador de producción
- un sustituto de las pruebas del repositorio y la revisión humana
Anthropic recomienda actualmente High como valor predeterminado para Fable 5, Xhigh como punto de partida para trabajo de código y con agentes en Opus 4.8, y Low principalmente para trabajo sencillo o sensible al costo. Por lo tanto, un router Low es una optimización que se adopta después de evaluarla.
La arquitectura propuesta es deliberadamente pequeña:
Task request + explicit metadata
|
v
[Deterministic router]
| | |
| | +--> ambiguous -> Fable 5 Low
| +----------> normal ----> Opus 4.8 Low
+------------------> routine ---> Sonnet 5 Low
|
v
[Tool, turn, and cost policy]
|
v
[Read-only plan or isolated writer]
|
v
[Controller-owned acceptance checks]
|
+------+------+
| |
accept escalate once
|
v
human review / stop
El router de modelos elige una configuración inicial. El sistema de aceptación decide si el trabajo es utilizable.
En qué se diferencia de las otras guías de enrutamiento
La comparación de High y Ultracode aborda la selección de modelos en los niveles de esfuerzo predeterminados y superiores. Este artículo parte de que un equipo ya decidió experimentar con Low y plantea una pregunta más específica: ¿puede una política Low explícita superar a un único modelo predeterminado aprobado cuando se incluyen las ejecuciones fallidas, la revisión y el escalamiento?
La estrategia de modelos para Spec Kit enruta las fases del flujo de trabajo. Esta política enruta según el riesgo de las tareas dentro de las ejecuciones de implementación y de agentes. El enrutamiento nativo de Cursor para ciclos rápidos es otra dimensión; reutiliza el contrato de evaluación, no la asignación de modelos.
Por qué empezar con metadatos explícitos es mejor que usar un clasificador basado en modelos
Un clasificador basado en modelos agrega:
- otra solicitud de pago
- otra fuente de no determinismo
- un nuevo prompt que necesita evaluación
- la posibilidad de que un clasificador débil envíe trabajo difícil a una ruta menos capaz
Para una política pequeña, deja que quien hace la llamada proporcione los metadatos de la tarea:
// src/schema.ts
import { z } from "zod";
export const PlanningRubricSchema = z.object({
requiredRequirements: z.array(z.string()).min(1),
requiredDecisions: z.array(z.string()).min(1),
requiredDependencies: z.array(z.string()),
requiredScopeBoundaries: z.array(z.string()),
mustIncludeRollback: z.boolean(),
mustIncludeVerification: z.boolean(),
requiresActionableSteps: z.boolean(),
forbiddenAssumptions: z.array(z.string()),
});
export const TaskSchema = z
.object({
id: z.string().regex(/^[a-z0-9-]+$/),
kind: z.enum(["plan", "code"]),
risk: z.enum(["routine", "normal", "ambiguous"]),
prompt: z.string().min(1),
baseSha: z.string().regex(/^[0-9a-f]{7,40}$/),
allowedPaths: z.array(z.string()),
// Additional task-specific restrictions. Mandatory oracle paths live
// in the controller-owned CHECKS registry and cannot be removed here.
forbiddenPaths: z.array(z.string()).default([]),
checkId: z.enum(["docs-links", "auth-tests"]).optional(),
planningRubric: PlanningRubricSchema.optional(),
})
.superRefine((task, ctx) => {
if (task.kind === "code" && !task.checkId) {
ctx.addIssue({ code: "custom", message: "code tasks require checkId" });
}
if (task.kind === "plan" && !task.planningRubric) {
ctx.addIssue({
code: "custom",
message: "plan tasks require planningRubric",
});
}
});
export type RoutedTask = z.infer<typeof TaskSchema>;
export type PlanningRubric = z.infer<typeof PlanningRubricSchema>;
Esto hace que el enrutamiento sea visible en la revisión de código y fácil de ajustar:
{
"id": "auth-cookie-fix",
"kind": "code",
"risk": "normal",
"prompt": "Fix SameSite handling for the session cookie.",
"baseSha": "8de1c9a",
"allowedPaths": ["src/auth/**"],
"forbiddenPaths": ["tests/**", "fixtures/**"],
"checkId": "auth-tests"
}
Los metadatos pueden provenir de:
- una plantilla de tickets
- un flag de línea de comandos
- reglas de responsabilidad del repositorio
- un manifiesto de tareas revisado
- un sistema de planificación previo
Automatiza la clasificación más adelante, cuando la política explícita tenga suficientes datos de ejecuciones aceptadas para entrenar o evaluar el clasificador.
Paso 1: Define la política
Fija la versión del SDK usado en el experimento y haz commit del archivo de bloqueo. La versión 0.3.211 incluía Claude Code 2.1.211 cuando se validó este artículo:
npm install @anthropic-ai/claude-agent-sdk@0.3.211 zod minimatch
npm install --save-dev tsx typescript @types/node
Define la salida de la política por separado de los metadatos de la tarea:
// src/policy.ts
export const POLICY_VERSION = "low-router-2026-07-16.1";
export type RouteDecision = {
model: "claude-sonnet-5" | "claude-opus-4-8" | "claude-fable-5";
effort: "low" | "high" | "xhigh";
maxTurns: number;
maxBudgetUsd: number;
reason: string;
};
const LOW_POLICY: Record<string, RouteDecision> = {
routine: {
model: "claude-sonnet-5",
effort: "low",
maxTurns: 10,
maxBudgetUsd: 1.5,
reason: "Narrow work with deterministic acceptance checks",
},
normal: {
model: "claude-opus-4-8",
effort: "low",
maxTurns: 16,
maxBudgetUsd: 3,
reason: "Measured middle route after repository evaluation",
},
ambiguous: {
model: "claude-fable-5",
effort: "low",
maxTurns: 24,
maxBudgetUsd: 6,
reason: "Scoping or ambiguity is part of the task",
},
};
export function routeTask(risk: string): RouteDecision {
const decision = LOW_POLICY[risk];
if (!decision) {
throw new Error(`No approved Low-effort route for risk=${risk}`);
}
return decision;
}
Los límites de turnos y dólares son valores iniciales de ejemplo, no valores predeterminados derivados de benchmarks. Ajústalos según la distribución de tus propias ejecuciones. Su propósito es hacer explícita la política de presupuesto antes de que arranque un agente.
La asignación de modelos también es una hipótesis experimental. Anthropic recomienda High para la mayoría del trabajo con Fable y Xhigh para código con Opus. Trata Fable Low y Opus Low como rutas candidatas que deben superar esas líneas base recomendadas por el proveedor para las clases de tareas que se les asignen.
No recurras silenciosamente a la ruta más barata. Los metadatos desconocidos deben provocar un rechazo seguro.
Paso 2: Establece límites distintos para planes y código
La planificación debe ser de solo lectura. La programación necesita escritura, pero no acceso irrestricto a la shell.
// src/boundary.ts
import type { Options } from "@anthropic-ai/claude-agent-sdk";
type TaskKind = "plan" | "code";
export function boundaryFor(kind: TaskKind): Pick<
Options,
"tools" | "allowedTools" | "permissionMode" | "disallowedTools"
> {
if (kind === "plan") {
return {
tools: ["Read", "Glob", "Grep", "ExitPlanMode"],
allowedTools: ["ExitPlanMode"],
permissionMode: "plan",
disallowedTools: ["Edit", "Write", "Bash"],
};
}
return {
tools: ["Read", "Edit", "Write", "Glob", "Grep"],
allowedTools: [],
permissionMode: "acceptEdits",
disallowedTools: ["Bash"],
};
}
La ruta de código elimina Bash intencionalmente. El controlador ejecutará un comando de prueba fijo después de que el modelo se detenga.
Esta separación evita que el modelo:
- invente un comando de prueba menos exigente
- cambie las pruebas y luego se evalúe a sí mismo contra los criterios modificados
- lea secretos mediante comandos arbitrarios de shell
- instale paquetes o acceda a la red
- oculte un comando fallido detrás de un resumen en prosa
Las herramientas del sistema de archivos siguen siendo potentes. Ejecuta las tareas con escritura en un worktree, contenedor o VM aislado y verifica después las rutas modificadas.
Paso 3: Ejecuta una tarea enrutada
El Agent SDK emite un mensaje de resultado final con:
subtypetotal_cost_usdnum_turnsduration_msusageacumuladomodelUsagepor modelo
Usa modelUsage para contabilizar todo el árbol cuando un modelo invoque subagentes; el campo usage de nivel superior puede contabilizar de menos el trabajo anidado.
// src/run-task.ts
import {
query,
type SDKResultMessage,
} from "@anthropic-ai/claude-agent-sdk";
import { boundaryFor } from "./boundary.js";
import { routeTask, type RouteDecision } from "./policy.js";
const REVIEWED_SYSTEM_PROMPT = `
Work only on the requested task and inside the supplied workspace.
Do not weaken tests, fixtures, acceptance scripts, or success criteria.
Do not access the network or files outside the workspace.
Report incomplete work and failed assumptions explicitly.
`;
export type RunTaskInput = {
id: string;
kind: "plan" | "code";
risk: "routine" | "normal" | "ambiguous";
prompt: string;
cwd: string;
configDir: string;
route?: RouteDecision;
};
export async function runTask(input: RunTaskInput) {
const route = input.route ?? routeTask(input.risk);
const boundary = boundaryFor(input.kind);
let result: SDKResultMessage | undefined;
let cliVersion: string | undefined;
let thrown: unknown;
try {
for await (const message of query({
prompt: input.prompt,
options: {
cwd: input.cwd,
model: route.model,
effort: route.effort,
maxTurns: route.maxTurns,
maxBudgetUsd: route.maxBudgetUsd,
tools: boundary.tools,
allowedTools: boundary.allowedTools,
permissionMode: boundary.permissionMode,
disallowedTools: boundary.disallowedTools,
settingSources: [],
strictMcpConfig: true,
skills: [],
systemPrompt: {
type: "preset",
preset: "claude_code",
append: REVIEWED_SYSTEM_PROMPT,
},
env: {
...process.env,
CLAUDE_CONFIG_DIR: input.configDir,
CLAUDE_CODE_DISABLE_AUTO_MEMORY: "1",
ENABLE_CLAUDEAI_MCP_SERVERS: "false",
},
persistSession: false,
},
})) {
if (message.type === "system" && message.subtype === "init") {
cliVersion = message.claude_code_version;
}
if (message.type === "result") {
result = message;
}
}
} catch (error) {
// A single-shot query throws after yielding an error result.
thrown = error;
}
if (!result) {
throw thrown ?? new Error(`Agent ended without a result: ${input.id}`);
}
const refused = result.stop_reason === "refusal";
return {
taskId: input.id,
route,
sdkVersion: "0.3.211",
cliVersion,
subtype: result.subtype,
loopCompleted: result.subtype === "success" && !refused,
stopReason: result.stop_reason,
resultText: result.subtype === "success" ? result.result : undefined,
errors: result.subtype === "success" ? [] : result.errors,
totalCostUsd: result.total_cost_usd,
numTurns: result.num_turns,
durationMs: result.duration_ms,
modelUsage: result.modelUsage,
permissionDenials: result.permission_denials,
};
}
Hay tres detalles de implementación importantes:
maxBudgetUsdusa una estimación del lado del cliente. Concilia el gasto de producción con la facturación del proveedor.- Las evaluaciones controladas usan un
CLAUDE_CONFIG_DIRvacío para cada ejecución y no cargan configuraciones del sistema de archivos, skills, servidores MCP ni memoria automática. La política administrada de la organización sigue aplicándose; usa un host controlado y mantén esa política constante entre condiciones. - Un subtipo
successdel SDK significa que el ciclo terminó. Una negativa sigue rechazándose, y ni la finalización ni la ausencia de una negativa significan que el código o el plan haya superado la aceptación.
Paso 4: Crea un espacio de trabajo aislado
Cada ejecución repetida de código necesita un checkout vinculado y limpio en el mismo commit base. Los worktrees de Git siguen compartiendo el historial del repositorio y los metadatos administrativos; aíslan los archivos del checkout, no todo el repositorio.
Una alternativa local sencilla es un worktree de Git en estado detached:
// src/workspace.ts
import { rmSync } from "node:fs";
import { mkdtemp } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { spawnSync } from "node:child_process";
function git(args: string[], cwd: string) {
const result = spawnSync("git", args, {
cwd,
encoding: "utf8",
shell: false,
});
if (result.error || result.status !== 0) {
throw result.error ?? new Error(result.stderr);
}
return result.stdout.trim();
}
export async function createWorkspace(repo: string, requestedSha: string) {
const baseSha = git(["rev-parse", requestedSha], repo);
const cwd = await mkdtemp(join(tmpdir(), "low-router-"));
const configDir = await mkdtemp(join(tmpdir(), "low-router-config-"));
git(["worktree", "add", "--detach", cwd, baseSha], repo);
const actualSha = git(["rev-parse", "HEAD"], cwd);
if (actualSha !== baseSha) {
throw new Error(`Workspace SHA mismatch: ${actualSha} != ${baseSha}`);
}
return {
cwd,
configDir,
baseSha,
cleanup() {
git(["worktree", "remove", "--force", cwd], repo);
rmSync(configDir, { recursive: true, force: true });
},
};
}
El manifiesto de tareas es la única fuente de verdad para baseSha; el controlador lo resuelve a un SHA completo y verifica el worktree antes de invocar el modelo.
Para repositorios no confiables o uso multitenant, un worktree no es un sandbox de seguridad. Usa un contenedor o VM efímero con:
- sin red por defecto
- credenciales de corta duración
- límites de CPU, memoria, disco, tiempo, turnos y tokens
- una imagen base de solo lectura
- un espacio de trabajo con escritura permitido explícitamente
- limpieza garantizada
Paso 5: Mantén la aceptación fuera del modelo
El manifiesto de tareas selecciona un ID de verificación controlado por el controlador, no un comando ejecutable.
// src/checks.ts
export const CHECKS = {
"docs-links": {
executable: "npm",
args: ["run", "check:links"],
timeoutMs: 120_000,
immutablePaths: ["tests/**", "fixtures/**", "scripts/check-links.*"],
env: { CI: "1" },
cwdPolicy: "workspace-root",
},
"auth-tests": {
executable: "npm",
args: ["test", "--", "tests/auth"],
timeoutMs: 180_000,
immutablePaths: ["tests/**", "test/**", "fixtures/**", "snapshots/**"],
env: { CI: "1" },
cwdPolicy: "workspace-root",
},
} as const;
export type CheckId = keyof typeof CHECKS;
El registro inmutable se revisa junto con el código del router. Define el ejecutable, los argumentos, el tiempo límite, el entorno, la política del directorio de trabajo y las rutas inmutables obligatorias. Un manifiesto puede agregar restricciones, pero no eliminarlas.
Verifica las rutas modificadas antes de ejecutar la comprobación:
// src/accept-code.ts
import { minimatch } from "minimatch";
import { CHECKS } from "./checks.js";
import type { SandboxRunner } from "./sandbox.js";
import type { RoutedTask } from "./schema.js";
import { spawnSync } from "node:child_process";
function gitPaths(cwd: string, args: string[]) {
const result = spawnSync("git", args, {
cwd,
encoding: "utf8",
shell: false,
});
if (result.error || result.status !== 0) {
throw result.error ?? new Error(result.stderr);
}
return result.stdout
.split("\0")
.filter(Boolean);
}
export function changedPaths(cwd: string) {
return [
...new Set([
...gitPaths(cwd, ["diff", "--name-only", "-z", "HEAD", "--"]),
...gitPaths(cwd, ["ls-files", "-z", "--others", "--"]),
]),
].sort();
}
export function untrackedPaths(cwd: string) {
return gitPaths(cwd, ["ls-files", "-z", "--others", "--"]).sort();
}
export async function acceptCodeTask(
task: RoutedTask,
cwd: string,
loopCompleted: boolean,
stopReason: string | null,
runInSandbox: SandboxRunner,
) {
const reasons: string[] = [];
const check =
task.kind === "code" && task.checkId ? CHECKS[task.checkId] : undefined;
const immutablePaths = [
...(check?.immutablePaths ?? []),
...task.forbiddenPaths,
];
const beforeCheck = changedPaths(cwd);
if (!loopCompleted) reasons.push(`agent stopped: ${stopReason ?? "error"}`);
const outsideScope = beforeCheck.filter(
(path) => !task.allowedPaths.some((glob) => minimatch(path, glob)),
);
const forbidden = beforeCheck.filter((path) =>
immutablePaths.some((glob) => minimatch(path, glob)),
);
if (outsideScope.length) {
reasons.push(`outside allowed paths: ${outsideScope.join(", ")}`);
}
if (forbidden.length) {
reasons.push(`modified immutable oracle files: ${forbidden.join(", ")}`);
}
if (reasons.length || !check) {
return {
accepted: false,
reasons,
paths: beforeCheck,
testExitCode: undefined,
testStdout: undefined,
testStderr: undefined,
sandboxAuditViolations: [],
};
}
const test = await runInSandbox({
workspace: cwd,
executable: check.executable,
args: [...check.args],
timeoutMs: check.timeoutMs,
env: { ...check.env },
cwdPolicy: check.cwdPolicy,
network: "none",
credentials: "none",
});
if (test.exitCode !== 0) {
reasons.push(test.error ?? `check exited ${test.exitCode}`);
}
reasons.push(...test.auditViolations);
// A compliant sandbox receives a disposable copy or a read-only mount.
// Scan again anyway; a post-check change is always a policy violation.
const afterCheck = changedPaths(cwd);
if (JSON.stringify(afterCheck) !== JSON.stringify(beforeCheck)) {
reasons.push("controller check changed the agent workspace");
}
return {
accepted: reasons.length === 0,
reasons,
paths: afterCheck,
testExitCode: test.exitCode,
testStdout: test.stdout,
testStderr: test.stderr,
sandboxAuditViolations: test.auditViolations,
};
}
El adaptador del sandbox es un límite de infraestructura intencional:
// src/sandbox.ts
export type SandboxRunner = (request: {
workspace: string;
executable: string;
args: string[];
timeoutMs: number;
env: Record<string, string>;
cwdPolicy: "workspace-root";
network: "none";
credentials: "none";
}) => Promise<{
exitCode: number;
stdout: string;
stderr: string;
error?: string;
auditViolations: string[];
}>;
No proporciones una alternativa insegura basada en procesos del host. Conecta esta interfaz a tu contenedor, VM o servicio de sandbox existente. Debe copiar o montar en solo lectura la salida del agente, deshabilitar la red, no exponer credenciales del controlador, imponer límites de recursos y tiempo, y descartar el entorno de verificación al terminar.
Mantén el estado sin configurar en modo de rechazo seguro:
// src/configured-sandbox.ts (Docker reference adapter)
import { randomUUID } from "node:crypto";
import { spawnSync } from "node:child_process";
import type { SandboxRunner } from "./sandbox.js";
const docker = (args: string[], timeout?: number) =>
spawnSync("docker", args, {
encoding: "utf8",
shell: false,
timeout,
});
export const runCheckInSandbox: SandboxRunner = async (request) => {
if (request.cwdPolicy !== "workspace-root") {
throw new Error(`Unsupported cwd policy: ${request.cwdPolicy}`);
}
const image = process.env.LOW_ROUTER_SANDBOX_IMAGE;
if (!image?.includes("@sha256:")) {
throw new Error("LOW_ROUTER_SANDBOX_IMAGE must be pinned by digest");
}
const imageCheck = docker(["image", "inspect", image]);
if (imageCheck.error || imageCheck.status !== 0) {
throw new Error("Pinned sandbox image must already exist locally");
}
const name = `low-router-${randomUUID()}`;
const envArgs = Object.entries(request.env).flatMap(([key, value]) => [
"--env",
`${key}=${value}`,
]);
const create = docker([
"create",
"--name",
name,
"--network",
"none",
"--cap-drop",
"ALL",
"--security-opt",
"no-new-privileges",
"--memory",
"2g",
"--cpus",
"2",
"--pids-limit",
"256",
"--workdir",
"/workspace",
...envArgs,
image,
request.executable,
...request.args,
]);
if (create.error || create.status !== 0) {
throw create.error ?? new Error(create.stderr);
}
const containerId = create.stdout.trim();
try {
const networkMode = docker([
"inspect",
"--format",
"{{.HostConfig.NetworkMode}}",
containerId,
]);
const auditViolations =
networkMode.stdout.trim() === "none" ? [] : ["sandbox network enabled"];
const copy = docker(["cp", `${request.workspace}/.`, `${containerId}:/workspace`]);
if (copy.error || copy.status !== 0) {
throw copy.error ?? new Error(copy.stderr);
}
const result = docker(["start", "--attach", containerId], request.timeoutMs);
return {
exitCode: result.status ?? 124,
stdout: result.stdout,
stderr: result.stderr,
error: result.error?.message,
auditViolations,
};
} finally {
docker(["rm", "--force", containerId]);
}
};
La imagen con versión fijada debe contener un /workspace vacío, dependencias para el commit base y ninguna credencial. El controlador copia la salida del agente al contenedor detenido, ejecuta el programa fijo sin una shell, no proporciona al contenedor montajes del host ni variables de entorno heredadas, deshabilita la red, limita los recursos y lo destruye al terminar.
Usa este adaptador solo cuando el daemon de Docker sea un límite de aislamiento aprobado. Reemplázalo por la VM o el servicio de sandbox de tu organización cuando se requiera un aislamiento más estricto.
Para las tareas de código, la aceptación determinista ahora requiere:
agent loop completed without refusal
+ changed files stay inside allowed paths
+ tests, fixtures, and acceptance scripts are unchanged
+ controller-owned check exits 0
= deterministically accepted code run
El modelo no puede debilitar las pruebas visibles sin fallar la verificación de rutas. Una suite de aceptación oculta e inmutable, fuera del worktree con escritura, ofrece una garantía más sólida cuando la propia tarea requiere agregar o cambiar pruebas.
Conserva la salida sin procesar, el código de salida, las rutas modificadas y el diff final como artefactos. La revisión humana o de políticas todavía puede rechazar un patch aceptado de forma determinista.
Preserva el resultado antes de eliminar el worktree:
// src/artifacts.ts
import { createHash } from "node:crypto";
import {
copyFile,
lstat,
mkdir,
readFile,
writeFile,
} from "node:fs/promises";
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
import { spawnSync } from "node:child_process";
import { changedPaths, untrackedPaths } from "./accept-code.js";
const sha256 = (data: Buffer | string) =>
createHash("sha256").update(data).digest("hex");
export async function preserveArtifacts(
cwd: string,
outputDir: string,
runId: string,
) {
const artifactDir = resolve(outputDir, "artifacts", runId);
await mkdir(artifactDir, { recursive: true });
const diff = spawnSync("git", ["diff", "--binary", "HEAD", "--"], {
cwd,
encoding: "utf8",
shell: false,
});
if (diff.error || diff.status !== 0) {
throw diff.error ?? new Error(diff.stderr);
}
const patchPath = join(artifactDir, "tracked.patch");
await writeFile(patchPath, diff.stdout);
const untracked: Array<{ path: string; sha256: string; mode: number }> = [];
for (const path of untrackedPaths(cwd)) {
const source = resolve(cwd, path);
const destination = resolve(artifactDir, "untracked", path);
const relativeSource = relative(resolve(cwd), source);
if (relativeSource.startsWith("..") || isAbsolute(relativeSource)) {
throw new Error(`Unsafe untracked path: ${path}`);
}
const stat = await lstat(source);
if (!stat.isFile()) throw new Error(`Unsupported untracked type: ${path}`);
await mkdir(dirname(destination), { recursive: true });
await copyFile(source, destination);
untracked.push({
path,
sha256: sha256(await readFile(source)),
mode: stat.mode,
});
}
const manifest = {
changedPaths: changedPaths(cwd),
patchPath,
patchSha256: sha256(diff.stdout),
untracked,
};
const manifestPath = join(artifactDir, "manifest.json");
const manifestText = JSON.stringify(manifest, null, 2);
await writeFile(manifestPath, manifestText);
return {
manifestPath,
manifestSha256: sha256(manifestText),
};
}
El patch binario preserva las ediciones de archivos rastreados, las eliminaciones, los cambios de nombre y los cambios de modo. Los archivos regulares no rastreados se copian por separado con sus modos y hashes SHA-256; los tipos no compatibles hacen fallar la ejecución en lugar de descartarse silenciosamente.
Aceptación de planes
El manifiesto validado contiene:
type PlanningRubric = {
requiredRequirements: string[];
requiredDecisions: string[];
requiredDependencies: string[];
requiredScopeBoundaries: string[];
mustIncludeRollback: boolean;
mustIncludeVerification: boolean;
requiresActionableSteps: boolean;
forbiddenAssumptions: string[];
};
Los planes necesitan un criterio de evaluación diferente. Usa una rúbrica fija:
| Criterio | Pregunta |
|---|---|
| Cobertura de requisitos | ¿Cada requisito proporcionado corresponde a un paso del plan? |
| Dependencias | ¿El orden y las dependencias externas son explícitos? |
| Alcance | ¿Se identifican los archivos y servicios bajo su responsabilidad, así como lo que queda fuera del alcance? |
| Verificación | ¿Cada fase de implementación tiene una verificación? |
| Rollback | ¿Se define un rollback o una interrupción segura donde sea necesario? |
| Supuestos | ¿Se identifican las incógnitas en lugar de inventar respuestas? |
| Viabilidad de ejecución | ¿Puede otro ingeniero ejecutar el plan sin tener que investigar todo de nuevo? |
Prefiere una revisión humana a ciegas para la primera evaluación. Si más adelante agregas un evaluador automático, evalúalo contra etiquetas humanas antes de confiar en él.
Haz que la revisión sea verificable automáticamente sin pedirle al revisor un veredicto de formato libre:
// src/accept-plan.ts
import { z } from "zod";
import type { RoutedTask } from "./schema.js";
export const PlanReviewSchema = z.object({
runId: z.string().uuid(),
coveredRequirements: z.array(z.string()),
coveredDecisions: z.array(z.string()),
coveredDependencies: z.array(z.string()),
coveredScopeBoundaries: z.array(z.string()),
includesRollback: z.boolean(),
includesVerification: z.boolean(),
actionableSteps: z.boolean(),
assessedForbiddenAssumptions: z.array(z.string()),
invalidAssumptions: z.array(z.string()),
humanReviewMinutes: z.number().nonnegative(),
});
export type PlanReview = z.infer<typeof PlanReviewSchema>;
export function acceptPlan(task: RoutedTask, review: PlanReview) {
if (task.kind !== "plan" || !task.planningRubric) {
throw new Error("planning rubric required");
}
const rubric = task.planningRubric;
const missingRequirements = rubric.requiredRequirements.filter(
(item) => !review.coveredRequirements.includes(item),
);
const missingDecisions = rubric.requiredDecisions.filter(
(item) => !review.coveredDecisions.includes(item),
);
const missingDependencies = rubric.requiredDependencies.filter(
(item) => !review.coveredDependencies.includes(item),
);
const missingScope = rubric.requiredScopeBoundaries.filter(
(item) => !review.coveredScopeBoundaries.includes(item),
);
const unassessedAssumptions = rubric.forbiddenAssumptions.filter(
(item) => !review.assessedForbiddenAssumptions.includes(item),
);
const reasons = [
...missingRequirements.map((item) => `missing requirement: ${item}`),
...missingDecisions.map((item) => `missing decision: ${item}`),
...missingDependencies.map((item) => `missing dependency: ${item}`),
...missingScope.map((item) => `missing scope boundary: ${item}`),
...unassessedAssumptions.map(
(item) => `forbidden assumption not assessed: ${item}`,
),
...review.invalidAssumptions.map((item) => `invalid assumption: ${item}`),
];
if (rubric.mustIncludeRollback && !review.includesRollback) {
reasons.push("rollback missing");
}
if (rubric.mustIncludeVerification && !review.includesVerification) {
reasons.push("verification missing");
}
if (rubric.requiresActionableSteps && !review.actionableSteps) {
reasons.push("actionable steps missing");
}
return { accepted: reasons.length === 0, reasons };
}
Guarda la revisión a ciegas como un registro separado de solo anexado, vinculado al ID de la ejecución:
// src/review-plan.ts
import { createHash } from "node:crypto";
import { appendFile, readFile } from "node:fs/promises";
import { join, resolve } from "node:path";
import {
acceptPlan,
PlanReviewSchema,
} from "./accept-plan.js";
import type { EvalRecord } from "./record.js";
import { TaskSchema } from "./schema.js";
const [runsArg, taskFileArg, reviewFileArg, outputDirArg] =
process.argv.slice(2);
if (!runsArg || !taskFileArg || !reviewFileArg || !outputDirArg) {
throw new Error(
"usage: review-plan <runs.jsonl> <task.json> <review.json> <output-dir>",
);
}
const sha256 = (value: string) =>
createHash("sha256").update(value).digest("hex");
const taskText = await readFile(resolve(taskFileArg), "utf8");
const task = TaskSchema.parse(JSON.parse(taskText));
const review = PlanReviewSchema.parse(
JSON.parse(await readFile(resolve(reviewFileArg), "utf8")),
);
const runs = (await readFile(resolve(runsArg), "utf8"))
.split("\n")
.filter(Boolean)
.map((line) => JSON.parse(line) as EvalRecord);
const run = runs.find((candidate) => candidate.runId === review.runId);
if (
!run ||
task.kind !== "plan" ||
run.taskId !== task.id ||
run.accepted !== null
) {
throw new Error("Review does not target one pending plan run");
}
if (
run.taskManifestSha256 !== sha256(taskText) ||
run.planningRubricSha256 !== sha256(JSON.stringify(task.planningRubric))
) {
throw new Error("Task manifest or planning rubric changed after the run");
}
if (!run.planArtifactPath || !run.planArtifactSha256) {
throw new Error("Pending run has no bound plan artifact");
}
const planText = await readFile(run.planArtifactPath, "utf8");
if (sha256(planText) !== run.planArtifactSha256) {
throw new Error("Plan artifact hash mismatch");
}
const acceptance = acceptPlan(task, review);
const reviewPath = join(resolve(outputDirArg), "plan-reviews.jsonl");
let existingReviews: Array<{ runId: string }> = [];
try {
existingReviews = (await readFile(reviewPath, "utf8"))
.split("\n")
.filter(Boolean)
.map((line) => JSON.parse(line) as { runId: string });
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
}
if (existingReviews.some((candidate) => candidate.runId === review.runId)) {
throw new Error("Plan run already has a terminal review");
}
await appendFile(
reviewPath,
`${JSON.stringify({
runId: review.runId,
taskId: run.taskId,
experimentId: run.experimentId,
condition: run.condition,
reviewVersion: "plan-review-2026-07-16.1",
accepted: acceptance.accepted,
acceptanceReasons: acceptance.reasons,
humanReviewMinutes: review.humanReviewMinutes,
})}\n`,
);
Relaciona plan-reviews.jsonl con runs.jsonl mediante runId. Excluye las filas de planes pendientes de los denominadores de la tasa de aceptación y del costo por resultado aceptado hasta que exista una revisión final. No sobrescribas el registro original de la ejecución del agente.
Paso 6: Construye la matriz de evaluación
El router debe superar una línea base más sencilla, no solo producir resultados plausibles.
Esta es una evaluación de escenarios offline: las tareas fijadas y los criterios de aceptación explícitos determinan si se puede promover la política. Después del despliegue, agrega evaluación de trazas de producción para detectar el uso indebido de herramientas, la desviación de comportamiento y nuevos tipos de tareas. Consulta Evaluación de trazas vs. pruebas de escenarios para conocer ese ciclo de evaluación más amplio.
Compara al menos:
- Sonnet 5 Low para todas las tareas
- Opus 4.8 Low para todas las tareas
- Fable 5 Low para todas las tareas
- La política de enrutamiento
- Tu opción predeterminada aprobada de High o Xhigh
Representa las líneas base fijas con la misma estructura de configuración de ejecución:
// src/conditions.ts
import { routeTask, type RouteDecision } from "./policy.js";
import type { RoutedTask } from "./schema.js";
type EvalCondition = {
name: string;
decide(task: RoutedTask): RouteDecision;
};
const conditions: EvalCondition[] = [
{ name: "routed-low", decide: (task) => routeTask(task.risk) },
{
name: "sonnet-low",
decide: () => ({
model: "claude-sonnet-5",
effort: "low",
maxTurns: 16,
maxBudgetUsd: 3,
reason: "Fixed Low baseline",
}),
},
{
name: "opus-low",
decide: () => ({
model: "claude-opus-4-8",
effort: "low",
maxTurns: 16,
maxBudgetUsd: 3,
reason: "Fixed Low baseline",
}),
},
{
name: "fable-low",
decide: () => ({
model: "claude-fable-5",
effort: "low",
maxTurns: 24,
maxBudgetUsd: 6,
reason: "Fixed Low baseline",
}),
},
{
name: "approved-opus-high",
decide: () => ({
model: "claude-opus-4-8",
effort: "high",
maxTurns: 24,
maxBudgetUsd: 6,
reason: "Existing approved baseline",
}),
},
{
name: "approved-opus-xhigh",
decide: () => ({
model: "claude-opus-4-8",
effort: "xhigh",
maxTurns: 30,
maxBudgetUsd: 10,
reason: "Vendor-guided coding baseline; keep only if team-approved",
}),
},
{
name: "routed-escalation-routine",
decide: () => ({
model: "claude-opus-4-8",
effort: "low",
maxTurns: 16,
maxBudgetUsd: 3,
reason: "Linked escalation from routed routine work",
}),
},
{
name: "routed-escalation-normal",
decide: () => ({
model: "claude-opus-4-8",
effort: "high",
maxTurns: 24,
maxBudgetUsd: 6,
reason: "Linked escalation from routed normal work",
}),
},
];
export function decisionFor(name: string, task: RoutedTask) {
const condition = conditions.find((candidate) => candidate.name === name);
if (!condition) throw new Error(`Unknown eval condition: ${name}`);
return condition.decide(task);
}
Pasa la condición seleccionada a runTask. La línea base High debe ser la configuración que tu equipo ya aprueba, no una nueva recomendación de este artículo.
Una misma etiqueta de esfuerzo se calibra por modelo y no implica un presupuesto idéntico de razonamiento interno. Compara los resultados aceptados y la economía total, no solo los nombres de esfuerzo o los precios de los tokens.
Usa un manifiesto con trabajo representativo:
[
{
"id": "docs-link-update",
"kind": "code",
"risk": "routine",
"baseSha": "8de1c9a",
"prompt": "Update the deprecated documentation links.",
"allowedPaths": ["docs/**"],
"forbiddenPaths": ["tests/**", "fixtures/**"],
"checkId": "docs-links"
},
{
"id": "auth-refactor-plan",
"kind": "plan",
"risk": "normal",
"baseSha": "8de1c9a",
"prompt": "Plan the session-store migration without editing files.",
"allowedPaths": [],
"planningRubric": {
"requiredRequirements": ["no forced logout", "preserve session TTL"],
"requiredDecisions": ["dual-write", "cutover", "rollback"],
"requiredDependencies": ["database migration", "session TTL"],
"requiredScopeBoundaries": ["auth service", "session store"],
"mustIncludeRollback": true,
"mustIncludeVerification": true,
"requiresActionableSteps": true,
"forbiddenAssumptions": ["zero active sessions"]
}
}
]
Un primer conjunto de datos razonable contiene entre 20–50 tareas representativas seleccionadas de fallos reales y trabajo aceptado. Incluye tareas de código rutinarias y normales, planes con dependencias conocidas y tareas ambiguas de definición de alcance o migración.
Ejecuta al menos cinco repeticiones por condición como piloto. Después, usa la variabilidad observada y la confianza que requiere la decisión para elegir el tamaño final de la muestra.
Aleatoriza el orden de ejecución para que la carga del proveedor, el estado de la caché o los efectos de la hora del día no favorezcan sistemáticamente a una condición.
Paso 7: Registra cada intento
Escribe un registro JSONL de solo anexado por ejecución:
// src/record.ts
export type EvalRecord = {
runId: string;
rootRunId: string;
parentRunId?: string;
condition: string;
attemptKind: "initial" | "escalation";
experimentId: string;
taskId: string;
repeat: number;
baseSha: string;
policyVersion: string;
requestedRisk: "routine" | "normal" | "ambiguous";
selectedModel: string;
selectedEffort: "low" | "high" | "xhigh";
selectedMaxTurns: number;
selectedMaxBudgetUsd: number;
promptVersion: string;
checkRegistryVersion: string;
taskManifestSha256: string;
planningRubricSha256?: string;
resultSubtype: string;
stopReason: string | null;
accepted: boolean | null;
acceptanceReasons: string[];
totalCostUsd: number | null;
numTurns: number | null;
durationMs: number | null;
controllerDurationMs: number;
modelUsage: unknown;
permissionDenials: unknown[];
sandboxAuditViolations: string[];
sdkVersion: string;
cliVersion?: string;
changedPaths: string[];
testExitCode?: number;
artifactManifestPath?: string;
artifactManifestSha256?: string;
planArtifactPath?: string;
planArtifactSha256?: string;
followUpKind: "model_escalation" | "human_review" | null;
policyViolations: string[];
};
Define una política de escalamiento con límites:
// src/escalation.ts
import type { RoutedTask } from "./schema.js";
import type { RouteDecision } from "./policy.js";
export type FollowUp =
| { kind: "model_escalation"; route: RouteDecision }
| { kind: "human_review" }
| null;
export function followUpFor(
condition: string,
task: RoutedTask,
accepted: boolean | null,
): FollowUp {
if (accepted === null) return { kind: "human_review" };
if (accepted || condition !== "routed-low") return null;
if (task.risk === "routine") {
return {
kind: "model_escalation",
route: {
model: "claude-opus-4-8",
effort: "low",
maxTurns: 16,
maxBudgetUsd: 3,
reason: "One measured escalation from routine to normal",
},
};
}
if (task.risk === "normal") {
return {
kind: "model_escalation",
route: {
model: "claude-opus-4-8",
effort: "high",
maxTurns: 24,
maxBudgetUsd: 6,
reason: "One effort escalation after Opus Low rejection",
},
};
}
return { kind: "human_review" };
}
Ahora proporciona el punto de entrada que falta para el controlador:
// src/run-one.ts
import { createHash, randomUUID } from "node:crypto";
import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises";
import { join, resolve } from "node:path";
import {
acceptCodeTask,
changedPaths as inspectChangedPaths,
} from "./accept-code.js";
import { preserveArtifacts } from "./artifacts.js";
import { decisionFor } from "./conditions.js";
import { runCheckInSandbox } from "./configured-sandbox.js";
import { followUpFor } from "./escalation.js";
import { POLICY_VERSION } from "./policy.js";
import type { EvalRecord } from "./record.js";
import { runTask } from "./run-task.js";
import { TaskSchema } from "./schema.js";
import { createWorkspace } from "./workspace.js";
const [repoArg, taskFileArg, outputDirArg] = process.argv.slice(2);
if (!repoArg || !taskFileArg || !outputDirArg) {
throw new Error("usage: run-one <repo> <task.json> <output-dir>");
}
const repo = resolve(repoArg);
const outputDir = resolve(outputDirArg);
const taskText = await readFile(resolve(taskFileArg), "utf8");
const task = TaskSchema.parse(JSON.parse(taskText));
const sha256 = (value: string) =>
createHash("sha256").update(value).digest("hex");
const taskManifestSha256 = sha256(taskText);
const planningRubricSha256 = task.planningRubric
? sha256(JSON.stringify(task.planningRubric))
: undefined;
const condition = process.env.CONDITION ?? "routed-low";
const route = decisionFor(condition, task);
const experimentId = process.env.EXPERIMENT_ID ?? "local-pilot";
const repeat = Number(process.env.REPEAT ?? "0");
if (!Number.isInteger(repeat) || repeat < 0) {
throw new Error("REPEAT must be a non-negative integer");
}
const parentRunId = process.env.PARENT_RUN_ID || undefined;
const uuidPattern =
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
await mkdir(outputDir, { recursive: true });
let parent: EvalRecord | undefined;
if (parentRunId) {
if (!uuidPattern.test(parentRunId)) throw new Error("Invalid parent UUID");
const existing = (await readFile(join(outputDir, "runs.jsonl"), "utf8"))
.split("\n")
.filter(Boolean)
.map((line) => JSON.parse(line) as EvalRecord);
parent = existing.find((row) => row.runId === parentRunId);
if (!parent || parent.followUpKind !== "model_escalation") {
throw new Error("Parent is not eligible for model escalation");
}
if (!uuidPattern.test(parent.rootRunId)) {
throw new Error("Parent has invalid root UUID");
}
if (existing.some((row) => row.parentRunId === parentRunId)) {
throw new Error("Parent already has an escalation attempt");
}
const expectedCondition =
parent.requestedRisk === "routine"
? "routed-escalation-routine"
: "routed-escalation-normal";
if (
condition !== expectedCondition ||
parent.experimentId !== experimentId ||
parent.taskId !== task.id ||
parent.repeat !== repeat ||
parent.requestedRisk !== task.risk ||
parent.taskManifestSha256 !== taskManifestSha256 ||
parent.policyVersion !== POLICY_VERSION
) {
throw new Error("Escalation metadata does not match parent");
}
}
const workspace = await createWorkspace(repo, task.baseSha);
if (parent && parent.baseSha !== workspace.baseSha) {
throw new Error("Escalation base SHA does not match parent");
}
const runId = randomUUID();
const rootRunId = parent?.rootRunId ?? runId;
const attemptKind = parentRunId ? "escalation" : "initial";
const controllerStartedAt = Date.now();
const PROMPT_VERSION = "low-router-prompt-2026-07-16.1";
const CHECK_REGISTRY_VERSION = "low-router-checks-2026-07-16.1";
let recordWritten = false;
let agent: Awaited<ReturnType<typeof runTask>> | undefined;
let changedPaths: string[] = [];
let artifactManifestPath: string | undefined;
let artifactManifestSha256: string | undefined;
let planArtifactPath: string | undefined;
let planArtifactSha256: string | undefined;
let sandboxAuditViolations: string[] = [];
try {
try {
agent = await runTask({
id: task.id,
kind: task.kind,
risk: task.risk,
prompt: task.prompt,
cwd: workspace.cwd,
configDir: workspace.configDir,
route,
});
} catch (error) {
if (task.kind === "code") {
try {
changedPaths = inspectChangedPaths(workspace.cwd);
const artifacts = await preserveArtifacts(
workspace.cwd,
outputDir,
runId,
);
artifactManifestPath = artifacts.manifestPath;
artifactManifestSha256 = artifacts.manifestSha256;
} catch {
// Preserve the original SDK/process error in the attempt record.
}
}
const record: EvalRecord = {
runId,
rootRunId,
parentRunId,
condition,
attemptKind,
experimentId,
taskId: task.id,
repeat,
baseSha: workspace.baseSha,
policyVersion: POLICY_VERSION,
requestedRisk: task.risk,
selectedModel: route.model,
selectedEffort: route.effort,
selectedMaxTurns: route.maxTurns,
selectedMaxBudgetUsd: route.maxBudgetUsd,
promptVersion: PROMPT_VERSION,
checkRegistryVersion: CHECK_REGISTRY_VERSION,
taskManifestSha256,
planningRubricSha256,
resultSubtype: "controller_error",
stopReason: null,
accepted: false,
acceptanceReasons: [
error instanceof Error ? error.message : String(error),
],
totalCostUsd: null,
numTurns: null,
durationMs: null,
controllerDurationMs: Date.now() - controllerStartedAt,
modelUsage: {},
permissionDenials: [],
sandboxAuditViolations: [],
sdkVersion: "0.3.211",
changedPaths,
artifactManifestPath,
artifactManifestSha256,
followUpKind: "human_review",
policyViolations: ["controller_error"],
};
await appendFile(
join(outputDir, "runs.jsonl"),
`${JSON.stringify(record)}\n`,
);
recordWritten = true;
throw error;
}
let accepted: boolean | null;
let reasons: string[];
let testExitCode: number | undefined;
if (task.kind === "code") {
changedPaths = inspectChangedPaths(workspace.cwd);
const artifacts = await preserveArtifacts(
workspace.cwd,
outputDir,
runId,
);
artifactManifestPath = artifacts.manifestPath;
artifactManifestSha256 = artifacts.manifestSha256;
const check = await acceptCodeTask(
task,
workspace.cwd,
agent.loopCompleted,
agent.stopReason,
runCheckInSandbox,
);
accepted = check.accepted;
reasons = check.reasons;
changedPaths = check.paths;
testExitCode = check.testExitCode;
sandboxAuditViolations = check.sandboxAuditViolations;
await writeFile(
join(outputDir, `${runId}.test.log`),
`${check.testStdout ?? ""}\n${check.testStderr ?? ""}`,
);
} else if (!agent.loopCompleted) {
accepted = false;
reasons = [`agent stopped: ${agent.stopReason ?? agent.subtype}`];
} else {
accepted = null;
reasons = ["pending blinded planning-rubric review"];
await writeFile(
(planArtifactPath = join(outputDir, `${runId}.plan.md`)),
agent.resultText ?? "",
);
planArtifactSha256 = sha256(agent.resultText ?? "");
}
const followUp = followUpFor(condition, task, accepted);
const record: EvalRecord = {
runId,
rootRunId,
parentRunId,
condition,
attemptKind,
experimentId,
taskId: task.id,
repeat,
baseSha: workspace.baseSha,
policyVersion: POLICY_VERSION,
requestedRisk: task.risk,
selectedModel: route.model,
selectedEffort: route.effort,
selectedMaxTurns: route.maxTurns,
selectedMaxBudgetUsd: route.maxBudgetUsd,
promptVersion: PROMPT_VERSION,
checkRegistryVersion: CHECK_REGISTRY_VERSION,
taskManifestSha256,
planningRubricSha256,
resultSubtype: agent.subtype,
stopReason: agent.stopReason,
accepted,
acceptanceReasons: reasons,
totalCostUsd: agent.totalCostUsd,
numTurns: agent.numTurns,
durationMs: agent.durationMs,
controllerDurationMs: Date.now() - controllerStartedAt,
modelUsage: agent.modelUsage,
permissionDenials: agent.permissionDenials,
sandboxAuditViolations,
sdkVersion: agent.sdkVersion,
cliVersion: agent.cliVersion,
changedPaths,
testExitCode,
artifactManifestPath,
artifactManifestSha256,
planArtifactPath,
planArtifactSha256,
followUpKind: followUp?.kind ?? null,
policyViolations: [
...reasons.filter(
(reason) =>
reason.startsWith("outside allowed paths") ||
reason.startsWith("modified immutable oracle files") ||
reason === "controller check changed the agent workspace",
),
...sandboxAuditViolations,
...agent.permissionDenials.map(
(denial) => `permission denied: ${JSON.stringify(denial)}`,
),
],
};
await appendFile(
join(outputDir, "runs.jsonl"),
`${JSON.stringify(record)}\n`,
);
recordWritten = true;
console.log(JSON.stringify({ condition, record, followUp }, null, 2));
} catch (error) {
if (!recordWritten) {
const record: EvalRecord = {
runId,
rootRunId,
parentRunId,
condition,
attemptKind,
experimentId,
taskId: task.id,
repeat,
baseSha: workspace.baseSha,
policyVersion: POLICY_VERSION,
requestedRisk: task.risk,
selectedModel: route.model,
selectedEffort: route.effort,
selectedMaxTurns: route.maxTurns,
selectedMaxBudgetUsd: route.maxBudgetUsd,
promptVersion: PROMPT_VERSION,
checkRegistryVersion: CHECK_REGISTRY_VERSION,
taskManifestSha256,
planningRubricSha256,
resultSubtype: "controller_error",
stopReason: agent?.stopReason ?? null,
accepted: false,
acceptanceReasons: [
error instanceof Error ? error.message : String(error),
],
totalCostUsd: agent?.totalCostUsd ?? null,
numTurns: agent?.numTurns ?? null,
durationMs: agent?.durationMs ?? null,
controllerDurationMs: Date.now() - controllerStartedAt,
modelUsage: agent?.modelUsage ?? {},
permissionDenials: agent?.permissionDenials ?? [],
sandboxAuditViolations,
sdkVersion: agent?.sdkVersion ?? "0.3.211",
cliVersion: agent?.cliVersion,
changedPaths,
artifactManifestPath,
artifactManifestSha256,
planArtifactPath,
planArtifactSha256,
followUpKind: "human_review",
policyViolations: ["controller_error"],
};
await appendFile(
join(outputDir, "runs.jsonl"),
`${JSON.stringify(record)}\n`,
);
}
throw error;
} finally {
workspace.cleanup();
}
Invoca el ejecutable local con versión fijada; no uses npx en la evaluación, porque podría descargar o seleccionar un paquete diferente:
./node_modules/.bin/tsx src/run-one.ts \
/path/to/repository \
evals/auth-cookie-fix.json \
results/low-router-pilot
Selecciona las líneas base con CONDITION y etiqueta las repeticiones de forma explícita:
EXPERIMENT_ID=low-router-01 \
CONDITION=approved-opus-high \
REPEAT=0 \
./node_modules/.bin/tsx src/run-one.ts \
/path/to/repository \
evals/auth-cookie-fix.json \
results/low-router-pilot
El controlador registra un escalamiento propuesto, pero no lo ejecuta en el espacio de trabajo con cambios. Inícialo en un worktree nuevo con el mismo SHA base y vincula la cadena de intentos:
EXPERIMENT_ID=low-router-01 \
CONDITION=routed-escalation-normal \
PARENT_RUN_ID="<initial-run-id>" \
REPEAT=0 \
./node_modules/.bin/tsx src/run-one.ts \
/path/to/repository \
evals/auth-cookie-fix.json \
results/low-router-pilot
Agrupa por rootRunId al calcular el costo total. Las líneas base fijas nunca reciben el escalamiento de la política de enrutamiento porque followUpFor() solo propone un escalamiento de modelo para la condición routed-low. Los costos de ejecuciones sin resultado pueden permanecer como null y deben conciliarse con el uso registrado por el proveedor en lugar de tratarse como cero.
Conserva estos artefactos por separado:
- manifiesto de tareas
- versión del prompt y de la política
- commit base
- diff o plan final
- logs de pruebas
- puntuaciones de la rúbrica
- metadatos del resultado del SDK
No registres por defecto el contenido del repositorio ni los prompts en un backend de observabilidad. La exportación de OpenTelemetry de Claude Code es estructural, a menos que se habilite explícitamente el registro de contenido; mantén desactivados los flags de contenido salvo que se haya aprobado la ubicación de almacenamiento.
Paso 8: Calcula las métricas importantes
Tasa de tareas aceptadas
accepted-task rate =
accepted runs / total runs
Repórtala por clase de tarea, no solo como un porcentaje global. Una política puede parecer exitosa mientras falla en todas las tareas ambiguas si las tareas rutinarias dominan el conjunto de datos.
Primero relaciona las revisiones finales de planes. Excluye las filas con accepted: null tanto del numerador como del denominador hasta que llegue su registro de revisión vinculado.
Costo por resultado aceptado
cost per accepted result =
total cost of all attempts
/ accepted runs
Incluye los intentos fallidos y las ejecuciones de escalamiento en el numerador.
Agrupa todos los registros que compartan un rootRunId antes de calcular esta métrica. Mantén los costos desconocidos como datos faltantes y concílialos con el uso registrado por el proveedor; nunca los conviertas a cero.
Esto evita el error clásico de contabilización del esfuerzo Low:
cheap Low attempt fails
+ second Low retry fails
+ High rescue succeeds
= all three attempts belong to the accepted result's cost
Tasa de escalamiento
escalation rate =
terminal eligible initial chains with model escalation
/ terminal eligible initial chains
Haz seguimiento de human_review por separado para que las revisiones de planificación pendientes y la intervención humana no se mezclen con el escalamiento de modelos. Si muchas ejecuciones de Opus Low se escalan, considéralo una señal de alerta y calcula el costo combinado de los intentos, el escalamiento y la revisión antes de conservar la ruta.
Calidad de la planificación
Haz seguimiento de:
- decisiones requeridas cubiertas
- dependencias faltantes
- supuestos inválidos
- integridad del rollback
- integridad de la verificación
- retrabajo de implementación atribuible al plan
La última métrica requiere seguir el plan hasta su implementación. Toma más tiempo recopilarla, pero es más útil que evaluar únicamente la redacción del plan.
Carga de revisión
La agregación de referencia registra los minutos de revisión de planificación a ciegas. Si la carga de revisión de código forma parte de tu decisión de promoción, agrega un registro de revisión de código vinculado a los artefactos de forma similar antes de usar el tiempo de revisión como criterio automatizado. Un modelo más barato que duplica el trabajo de revisión no es más barato en términos operativos, pero no afirmes que existe ese ahorro hasta medirlo.
Agregación mínima
El siguiente controlador relaciona las revisiones de planes y las cadenas de intentos:
// src/rollup.ts
import { readFile } from "node:fs/promises";
import type { EvalRecord } from "./record.js";
const readJsonl = async <T>(path: string) =>
(await readFile(path, "utf8"))
.split("\n")
.filter(Boolean)
.map((line) => JSON.parse(line) as T);
type PlanReviewRecord = {
runId: string;
accepted: boolean;
humanReviewMinutes: number;
};
const runs = await readJsonl<EvalRecord>(process.argv[2]);
if (new Set(runs.map((row) => row.runId)).size !== runs.length) {
throw new Error("Duplicate run IDs");
}
let reviewRows: PlanReviewRecord[] = [];
try {
reviewRows = await readJsonl<PlanReviewRecord>(process.argv[3]);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
}
if (new Set(reviewRows.map((row) => row.runId)).size !== reviewRows.length) {
throw new Error("Duplicate plan review IDs");
}
const reviews = new Map(reviewRows.map((row) => [row.runId, row]));
const chains = new Map<string, EvalRecord[]>();
for (const run of runs) {
chains.set(run.rootRunId, [...(chains.get(run.rootRunId) ?? []), run]);
}
const rolled = [...chains.values()].map((attempts) => {
const initials = attempts.filter((row) => row.attemptKind === "initial");
if (initials.length !== 1 || attempts.length > 2) {
throw new Error("Invalid attempt chain shape");
}
const initial = initials[0];
if (attempts.length > 1 && initial.condition !== "routed-low") {
throw new Error("Fixed baseline cannot contain escalation");
}
if (attempts.length > 1 && initial.requestedRisk === "ambiguous") {
throw new Error("Ambiguous route requires human review, not escalation");
}
for (const row of attempts) {
if (
row.experimentId !== initial.experimentId ||
row.taskId !== initial.taskId ||
row.repeat !== initial.repeat ||
row.baseSha !== initial.baseSha ||
row.requestedRisk !== initial.requestedRisk
) {
throw new Error(`Mixed metadata in chain ${initial.rootRunId}`);
}
if (
row.attemptKind === "escalation" &&
row.parentRunId !== initial.runId
) {
throw new Error(`Broken parent link in chain ${initial.rootRunId}`);
}
if (row.attemptKind === "escalation") {
if (initial.followUpKind !== "model_escalation") {
throw new Error(`Initial run did not request escalation`);
}
const expected =
initial.requestedRisk === "routine"
? "routed-escalation-routine"
: "routed-escalation-normal";
if (row.condition !== expected) {
throw new Error(`Wrong escalation condition in ${initial.rootRunId}`);
}
}
}
const terminal = attempts.map(
(row) => row.accepted ?? reviews.get(row.runId)?.accepted ?? null,
);
const actualEscalation = attempts.some(
(row) => row.attemptKind === "escalation",
);
const pending =
(initial.followUpKind === "model_escalation" && !actualEscalation) ||
terminal.every((value) => value === null);
const accepted = pending ? null : terminal.some((value) => value === true);
const costKnown = attempts.every((row) => row.totalCostUsd !== null);
const totalCostUsd = costKnown
? attempts.reduce((sum, row) => sum + row.totalCostUsd!, 0)
: null;
const planningReviewMinutes = attempts.reduce(
(sum, row) => sum + (reviews.get(row.runId)?.humanReviewMinutes ?? 0),
0,
);
return {
condition: initial.condition,
accepted,
totalCostUsd,
planningReviewMinutes,
policyViolations: attempts.flatMap((row) => row.policyViolations).length,
modelEscalated: actualEscalation,
};
});
const summaries = [];
for (const condition of new Set(rolled.map((row) => row.condition))) {
const eligible = rolled.filter(
(row) => row.condition === condition && row.accepted !== null,
);
const acceptedCount = eligible.filter((row) => row.accepted).length;
const allCostsKnown = eligible.every((row) => row.totalCostUsd !== null);
const pendingCount = rolled.filter(
(row) => row.condition === condition && row.accepted === null,
).length;
summaries.push({
condition,
sampleCount: eligible.length,
pendingCount,
acceptedRate: eligible.length ? acceptedCount / eligible.length : null,
costPerAcceptedResult:
acceptedCount > 0 && allCostsKnown
? eligible.reduce((sum, row) => sum + row.totalCostUsd!, 0) /
acceptedCount
: null,
modelEscalationRate: eligible.length
? eligible.filter((row) => row.modelEscalated).length / eligible.length
: null,
planningReviewMinutes: eligible.reduce(
(sum, row) => sum + row.planningReviewMinutes,
0,
),
policyViolations: eligible.reduce(
(sum, row) => sum + row.policyViolations,
0,
),
});
}
console.log(JSON.stringify(summaries, null, 2));
Las cadenas finalizadas y rechazadas permanecen en el numerador del costo porque consumieron presupuesto del experimento. Reporta por separado el costo de las cadenas exitosas únicamente si eso responde a una pregunta operativa diferente.
Para un análisis real, agrega intervalos pareados por tarea y reporta la incertidumbre. Esta agregación existe para hacer ejecutable el contrato de contabilización, no para reemplazar el análisis estadístico.
Paso 9: Define los criterios de promoción antes de ejecutar
No examines los resultados para después inventar un umbral que favorezca la política preferida.
Ejemplos de categorías de criterios:
zero out-of-scope writes
zero unapproved commands
accepted-rate difference clears a predefined paired non-inferiority margin
cost per accepted result below the approved baseline
planning requirement coverage does not regress
ambiguous-task escalation remains within team capacity
Estas son categorías de ejemplo, no umbrales numéricos universales. Antes de ejecutar la evaluación, define el margen de no inferioridad, el nivel de confianza, el análisis pareado y los valores concretos a partir del riesgo y la economía de tu carga de trabajo.
Un criterio descriptivo para el piloto puede implementarse de forma ejecutable:
// src/gates.ts
export type Summary = {
sampleCount: number;
pendingCount: number;
acceptedRate: number | null;
costPerAcceptedResult: number | null;
policyViolations: number;
planningReviewMinutes: number;
};
export function passesPilot(
routed: Summary,
baseline: Summary,
acceptedRateMargin: number,
minimumSample: number,
) {
if (
!Number.isFinite(acceptedRateMargin) ||
acceptedRateMargin < 0 ||
!Number.isInteger(minimumSample) ||
minimumSample < 1
) {
throw new Error("Invalid pilot gate parameters");
}
if (
routed.sampleCount < minimumSample ||
baseline.sampleCount < minimumSample ||
routed.pendingCount > 0 ||
baseline.pendingCount > 0 ||
routed.acceptedRate === null ||
baseline.acceptedRate === null ||
routed.costPerAcceptedResult === null ||
baseline.costPerAcceptedResult === null
) {
throw new Error("Pilot is incomplete");
}
return (
routed.policyViolations === 0 &&
routed.acceptedRate >= baseline.acceptedRate - acceptedRateMargin &&
routed.costPerAcceptedResult < baseline.costPerAcceptedResult
);
}
Pasa el JSON emitido por rollup.ts a una pequeña CLI de evaluación de criterios:
// src/check-pilot.ts
import { readFile } from "node:fs/promises";
import { passesPilot, type Summary } from "./gates.js";
const [summaryFile, routedName, baselineName, marginArg, sampleArg] =
process.argv.slice(2);
const summaries = JSON.parse(
await readFile(summaryFile, "utf8"),
) as Array<Summary & { condition: string }>;
const routed = summaries.find((row) => row.condition === routedName);
const baseline = summaries.find((row) => row.condition === baselineName);
if (!routed || !baseline) throw new Error("Missing routed or baseline summary");
const passed = passesPilot(
routed,
baseline,
Number(marginArg),
Number(sampleArg),
);
console.log(JSON.stringify({ routedName, baselineName, passed }));
process.exitCode = passed ? 0 : 1;
./node_modules/.bin/tsx src/rollup.ts \
results/low-router-pilot/runs.jsonl \
results/low-router-pilot/plan-reviews.jsonl \
> results/low-router-pilot/summary.json
./node_modules/.bin/tsx src/check-pilot.ts \
results/low-router-pilot/summary.json \
routed-low \
approved-opus-high \
0.03 \
20
Esto no es un intervalo de confianza. Usa los resultados pareados por tarea y el plan estadístico definido de antemano antes de una promoción a producción; no promociones la política basándote únicamente en un piloto descriptivo.
La política de enrutamiento debe rechazarse si:
- ahorra dinero solo porque acepta trabajo de menor calidad
- los metadatos de las tareas son incorrectos con frecuencia
- la cola de escalamiento se convierte en el verdadero cuello de botella
- las salvaguardas de Fable hacen que una ruta sea poco confiable para el dominio del repositorio
- los ahorros desaparecen cuando cambia el precio promocional de Sonnet
- la complejidad operativa supera el beneficio medido
Paso 10: Despliega de forma conservadora
Usa tres etapas:
En sombra
Ejecuta el router sin cambiar la selección en producción. Registra lo que elegiría y compáralo con los resultados aceptados de la opción predeterminada actual.
Consultiva
Muestra la ruta recomendada a los ingenieros, pero exige confirmación. Registra las decisiones de cambiar la ruta y sus motivos.
Automática
Enruta automáticamente solo las clases de tareas que superaron los criterios predefinidos. Mantén el trabajo ambiguo o de alto impacto en modo consultivo hasta que exista suficiente evidencia.
Asigna una versión a cada política:
export const POLICY_VERSION = "low-router-2026-07-16.1";
Guarda esa versión en cada registro para que una actualización del benchmark o un cambio de precios no mezcle silenciosamente dos políticas.
Asigna responsables de forma explícita. Un responsable de plataforma o de productividad de desarrollo debe versionar la política y el registro de verificaciones; los responsables de los repositorios deben revisar los manifiestos de tareas y las verificaciones de aceptación; y un revisor designado debe evaluar los cambios de ruta en modo consultivo y los fallos de escalamiento.
Agrega OpenTelemetry cuando JSONL deje de escalar
El Agent SDK expone el costo y el uso directamente en los mensajes de resultado, lo cual es suficiente para una evaluación local.
En entornos compartidos, Claude Code puede exportar:
claude_code.cost.usageclaude_code.token.usage- conteos de sesiones
- tiempo activo
- decisiones de permisos para editar código
- trazas opcionales de solicitudes al modelo y herramientas
Habilita la telemetría y envíala a un colector OTLP:
export CLAUDE_CODE_ENABLE_TELEMETRY=1
export OTEL_METRICS_EXPORTER=otlp
export OTEL_LOGS_EXPORTER=otlp
export OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
export OTEL_EXPORTER_OTLP_ENDPOINT=http://collector.example.com:4318
Las señales de tokens y costos incluyen atributos de modelo y esfuerzo, lo que permite a un equipo comparar las rutas en producción.
Este ejemplo habilita únicamente métricas y eventos de log. Las trazas requieren además OTEL_TRACES_EXPORTER=otlp y CLAUDE_CODE_ENHANCED_TELEMETRY_BETA=1. Los procesos de corta duración del SDK pueden terminar antes de que se envíe la telemetría acumulada, así que acorta los intervalos de exportación o utiliza el mensaje de resultado como fuente definitiva para los registros de evaluación local.
No habilites a la ligera el registro de prompts o del contenido de las herramientas. La telemetría estructural es suficiente para la mayoría de los dashboards de costos y confiabilidad.
Fallos comunes
| Fallo | Por qué ocurre | Corrección |
|---|---|---|
| Enrutar todo a Sonnet Low | Optimiza el precio por llamada, no la aceptación | Aplica criterios según la estructura determinista de la tarea |
| Considerar Opus Low como predeterminado sin una evaluación | Contradice las recomendaciones del proveedor para código | Exige datos de aceptación por clase |
| Dejar que el modelo clasifique su propio riesgo | Agrega costo y no determinismo al enrutamiento | Empieza con metadatos explícitos |
| Dejar que el modelo elija las pruebas | El agente puede debilitar su propio criterio de evaluación | El controlador administra los comandos permitidos |
Tratar success del SDK como aceptación de la tarea | La finalización del ciclo no implica que el resultado sea correcto | Aplica criterios de rutas, pruebas, rúbrica y negativas |
| Reutilizar un checkout entre repeticiones | Los resultados contaminan ejecuciones posteriores | Usa un worktree o contenedor nuevo por intento |
| Ignorar el costo de los intentos fallidos | Hace que Low parezca artificialmente barato | Mide el costo por resultado aceptado |
| Comparar commits diferentes | Cambia la dificultad de la tarea | Fija un solo SHA base |
| Cargar configuraciones personales en algunas ejecuciones | Los prompts y las herramientas difieren | Fija settingSources y la versión de la política |
| Promover basándose solo en la puntuación agregada | Las tareas rutinarias ocultan los fallos difíciles | Reporta cada clase de tarea por separado |
| Activar la telemetría con contenido detallado | Filtra código o prompts | Conserva los valores predeterminados de telemetría estructural |
Recomendación final
Empieza con la política más pequeña que pueda refutarse:
explicit task class
+ one Low model per class
+ estimated budget stop and hard turn limit
+ read-only plans
+ isolated writes
+ controller-owned tests
+ append-only run records
+ predefined promotion gates
= an auditable Low-effort router
No empieces optimizando el clasificador, agregando cinco niveles de esfuerzo u orquestando muchos subagentes.
Primero responde:
- ¿Low mantiene la calidad para cada clase de tarea?
- ¿El enrutamiento mejora el costo por resultado aceptado frente a un único modelo predeterminado aprobado?
- ¿Puede el equipo gestionar la carga de escalamiento y revisión?
Si la respuesta es sí, amplía la política una ruta a la vez.
Si la respuesta es no, el resultado correcto no es un router más complicado. Es una configuración predeterminada más sencilla de modelo y esfuerzo.
Lecturas relacionadas
- Fable 5 High vs. Opus 4.8 High vs. Sonnet 5 High
- Una estrategia de modelos fase por fase para GitHub Spec Kit en 2026
- Claude Sonnet 4.6 1M vs. Composer 2.5 Fast
- Evaluación de trazas vs. pruebas de escenarios para agentes en producción
- Evaluación de agentes de IA con LangWatch
- Cuándo usar subagentes en lugar del hilo principal de chat
- Plano de arquitectura de Claude Code Ultracode
Fuentes
- Claude Platform Docs, “Esfuerzo”, consultado el July 16, 2026.
- Claude Platform Docs, “Descripción general de modelos”, consultado el July 16, 2026.
- Claude Platform Docs, “Precios”, consultado el July 16, 2026.
- Claude Code Docs, “Referencia del Agent SDK—TypeScript”, consultado el July 16, 2026.
- Claude Code Docs, “Cómo funciona el ciclo del agente”, consultado el July 16, 2026.
- Claude Code Docs, “Permisos del Agent SDK”, consultado el July 16, 2026.
- Claude Code Docs, “Usa las funciones de Claude Code en el Agent SDK”, consultado el July 16, 2026.
- Claude Code Docs, “Despliegue seguro de agentes”, consultado el July 16, 2026.
- Claude Code Docs, “Seguimiento de costos y uso”, consultado el July 16, 2026.
- Claude Code Docs, “Referencia de la CLI”, consultado el July 16, 2026.
- Claude Code Docs, “Monitoreo”, consultado el July 16, 2026.
- Claude Code Docs, “Observabilidad con OpenTelemetry”, consultado el July 16, 2026.
- Claude Platform Docs, “Cómo escribir prompts para Claude Fable 5”, consultado el July 16, 2026.
- Claude Platform Docs, “Cómo escribir prompts para Claude Opus 4.8”, consultado el July 16, 2026.
- Claude Platform Docs, “Cómo escribir prompts para Claude Sonnet 5”, consultado el July 16, 2026.
- Claude Platform Docs, “Negativas y alternativas”, consultado el July 16, 2026.
- Anthropic, “Cómo desmitificar las evaluaciones de agentes de IA”, consultado el July 16, 2026.
- Anthropic, “Un enfoque estadístico para las evaluaciones de modelos”, consultado el July 16, 2026.
- Git, “git-worktree”, consultado el July 16, 2026.
- Node.js, “Proceso hijo”, consultado el July 16, 2026.