Artículo

Anatomía de un agente de código: lo que aprendí construyendo uno desde cero

A
Anónimo
11 de septiembre de 20266 min de lectura
Anatomía de un agente de código: lo que aprendí construyendo uno desde cero

Uso agentes de código todos los días. Claude Code y Cursor escriben buena parte del código que commiteo. Y durante meses, una pregunta me quitó el sueño: ¿qué está pasando exactamente ahí adentro?

Así que construí uno. Se llama Killami Code, corre en tu terminal, nació en el Caribe colombiano, y cabe en unas 1,400 líneas de TypeScript con exactamente una dependencia: el SDK de Anthropic. Sin framework de orquestación, sin librería de agentes. Ese era el punto. No entiendes una máquina hasta que armas una pequeña con tus propias manos.

Este artículo recorre cada parte con el código real (los snippets están recortados para legibilidad; todo está en el repo). Es el mismo recorrido que hacemos en el módulo de harness de Tech Centre, donde nuestros estudiantes no aprenden a usar agentes: aprenden a construirlos. El repo es material del curso.

El núcleo: un loop, no un chat

Un chatbot responde. Un agente itera.

El corazón de Killami está en src/agent/loop.ts, y la idea completa cabe en un solo loop: mandas la conversación al modelo; si responde texto plano, el turno terminó; si pide herramientas, las ejecutas, agregas los resultados, y vuelves a llamar al modelo.

TypeScript
for (let step = 0; step < MAX_ITERATIONS; step += 1) {
  await compactIfNeeded(client, model, history);
  const response = await streamAssistant(client, model, history);
  history.push({ role: "assistant", content: response.content });

  if (response.stop_reason !== "tool_use") return; // texto plano: turno terminado

  const results = [];
  for (const block of response.content) {
    if (block.type !== "tool_use") continue;

    const allowed = await gate.authorize(block.name, block.input);
    const output = allowed
      ? await executeTool(block.name, block.input)
      : "The user denied this action. Do not retry it unless they explicitly ask.";

    results.push({ type: "tool_result", tool_use_id: block.id, content: output });
  }
  history.push({ role: "user", content: results });
}

Fíjate en el techo duro de MAX_ITERATIONS = 20: un agente sin condición de parada es un loop infinito con factura de API. Y fíjate en que hasta la negación se vuelve información: el modelo recibe "el usuario negó esta acción" como resultado de herramienta, y puede cambiar de estrategia en vez de reintentar a ciegas.

Todo lo demás del código existe para mantener ese loop honesto, seguro y barato. El modelo pone la inteligencia. El harness pone el criterio.

Tools: las manos del agente

Killami le da al modelo siete herramientas, definidas en src/tools.ts: read, write, edit, bash, grep, glob y ls. Esta es edit, la más opinada:

TypeScript
{
  name: "edit",
  description:
    "Replace exactly one occurrence of old_string with new_string in a file. " +
    "The old string must match exactly once.",
  input_schema: {
    type: "object",
    properties: {
      path:       { type: "string", description: "File path relative to the workspace" },
      old_string: { type: "string", description: "Exact text to find" },
      new_string: { type: "string", description: "Replacement text" },
    },
    required: ["path", "old_string", "new_string"],
  },
}

Dos decisiones de diseño importan más que la lista. Primera: edit exige que el texto viejo aparezca exactamente una vez; la ambigüedad se le devuelve al modelo como error para que sea más específico. Fallar con ruido le gana a adivinar en silencio. Segunda: vuelve a mirar ese campo description. Las descripciones de las tools son prompts que el modelo lee en cada llamada. Escribirlas resultó ser una de las superficies de prompt engineering con más palanca de todo el sistema.

Permisos: consentimiento antes del daño

Leer es gratis. Mutar no. Antes de cada write, edit o bash, Killami se detiene y pregunta (src/permissions.ts):

TypeScript
const RISKY_TOOLS = new Set(["write", "edit", "bash"]);

async authorize(name, input) {
  if (!RISKY_TOOLS.has(name) || allowSession) return true;

  // s: sí, solo esta vez · n: no, no lo toques · a: sí, y no preguntes más
  const decision = await askDecision(ask);
  if (decision === "deny") return false;
  if (decision === "allow_session") allowSession = true;
  return true;
}

Esa es la diferencia entre un agente que pruebas una vez y un agente en el que confías dentro de un repo que te importa. Y también, con honestidad, es la capa de seguridad más débil posible: el consentimiento no es aislamiento. Un bash aprobado puede hacer todo lo que tu shell puede. El aislamiento real es sandboxing de ejecución, y por eso las microVMs de Firecracker son el siguiente hito del roadmap: quiero que el agente corra comandos dentro de una VM desechable, no dentro del kernel de mi laptop.

Checkpoints: un botón de deshacer para un agente

Los agentes se equivocan con confianza. Todo el sistema de checkpoints es un contrato pequeño (src/checkpoint.ts):

TypeScript
const MAX_CHECKPOINTS = 50;

export type CheckpointStore = {
  beginTurn(): void;
  finishTurn(): void;
  snapshot(relativePath: string): Promise<void>; // se llama antes de write/edit
  undo(): Promise<UndoResult | null>;            // /undo restaura el último turno
};

Dentro del loop, justo antes de que un write o edit se ejecute, el archivo objetivo queda fotografiado: await checkpoints.snapshot(target). Escribes /undo y los cambios de archivos del último turno se restauran, hasta 50 turnos atrás. El chat se queda; solo el disco retrocede.

Una limitación honesta, escrita en el README porque el usuario merece saberla: bash no entra en el checkpoint. Si el modelo corre un rm, ninguna foto te salva. El undo cubre lo que el harness puede ver, y fingir otra cosa sería peor que la limitación misma.

Memoria: el archivo que el agente lee antes de pensar

Si el repo contiene un KILLAMI.md o un AGENTS.md, su contenido se inyecta en cada request (src/memory.ts):

TypeScript
export const MEMORY_FILES = ["KILLAMI.md", "AGENTS.md"] as const;

export function loadProjectMemory(root = process.cwd()): string {
  const chunks: string[] = [];
  for (const name of listMemoryFiles(root)) {
    let text = readFileSync(path.join(root, name), "utf8").trim();
    if (text.length > MAX_CHARS) text = `${text.slice(0, MAX_CHARS)}\n…(truncated)`;
    chunks.push(`### ${name}\n${text}`);
  }
  return chunks.join("\n\n");
}

Es la arquitectura de memoria más simple posible, un archivo markdown, y es sorprendentemente efectiva. Fíjate en el tope de truncado: hasta la memoria tiene presupuesto de tokens. Uno de los cuatro evals del repo prueba específicamente que el agente obedezca un KILLAMI.md que prohíbe tocar un archivo. Memoria que no puedes verificar es decoración.

Compactación: sobrevivir a tu propia ventana de contexto

Las sesiones largas acumulan historial, y el historial cuesta tokens. Cuando la conversación pasa un umbral, Killami resume la parte vieja usando el propio modelo y conserva intacta una cola reciente (src/agent/compact.ts):

TypeScript
const COMPACT_AFTER_CHARS = 80_000;
const KEEP_TAIL_CHARS = 28_000;

if (totalChars(history) < COMPACT_AFTER_CHARS) return;

const prefix = history.slice(0, cut);
const tail = history.slice(cut);
const summary = await summarizePrefix(client, model, prefix);

history.splice(0, history.length,
  {
    role: "user",
    content: `Resumen de la conversación anterior. Úsalo como contexto y no lo contradigas:\n\n${summary}`,
  },
  { role: "assistant", content: "Entendido. Sigo desde ese resumen." },
  ...tail,
);

El resumen se inyecta como un mensaje que el modelo no debe contradecir, y la cola reciente sobrevive literal para que el trabajo inmediato no pierda detalle.

Funciona, y me enseñó el trade-off más profundo de todo el código: compactar reescribe el prefijo de la conversación, y reescribir el prefijo es exactamente lo que rompe el prompt caching, donde ahorras costo reusando un prefijo idéntico entre llamadas. El presupuesto de tokens, el orden de los mensajes y la estabilidad del caché se jalan entre sí. Esa tensión no la sientes usando un agente. La sientes la noche que construyes uno. En el módulo de harness la convertimos en ejercicio: los estudiantes miden el costo de una sesión con y sin compactación, y descubren el trade-off con su propia factura de API.

Evals: la confianza es un número, no una sensación

"Parece que funciona" no es ingeniería. Killami trae una suite pequeña de evaluaciones (evals/): cada caso levanta un repo temporal, le da una tarea al agente, y verifica el resultado con código. Este es el más simple:

TypeScript
{
  name: "rewrite-greeting",
  files: { "hello.ts": 'export const greeting = "hello";\n' },
  prompt: 'En hello.ts, cambia el valor de greeting a "hola". No toques nada más.',
  check: async (root) => {
    const text = await read(root, "hello.ts");
    if (!text.includes('"hola"')) return `greeting no quedó en "hola":\n${text}`;
    if (text.includes('"hello"')) return `sigue teniendo "hello":\n${text}`;
    return null; // null significa que pasó
  },
}

La función check es toda la filosofía: un repo real en disco, una corrida real del agente, y un veredicto calculado por código, no por sensaciones.

Cuatro casos son una semilla, no un benchmark, y los agentes son estocásticos, así que el roadmap es correr cada caso N veces y reportar tasas de éxito por modelo. Pero incluso cuatro verificaciones programáticas cambiaron cómo desarrollo: cada refactor del loop tiene ahora una red de regresión. Los tests unitarios verifican el harness sin gastar un token; los evals verifican el comportamiento gastando algunos. Necesitas ambos, y responden preguntas distintas.

Lo que falta a propósito

Killami no tiene subagentes, ni soporte de MCP como cliente, ni persistencia de sesión, ni prompt caching, ni ejecución en sandbox todavía. Algunas de esas cosas vienen (Firecracker y MCP están en el roadmap); otras faltan para que el código siga siendo legible, porque este repo tiene un segundo trabajo: ser un lugar donde puedas aprender cómo funciona un agente de código leyendo 1,400 líneas, no 140,000.

Si necesitas un harness de producción, usa el Claude Agent SDK: es esta misma anatomía, productizada, con las partes difíciles resueltas. Killami es lo que construí para entender por qué el SDK toma las decisiones que toma. Y es lo que enseñamos a construir en Tech Centre, porque estamos convencidos de una cosa: la próxima generación de developers del Caribe no puede ser solo usuaria de estas herramientas. Tiene que saber armarlas.

Constrúyete uno. Es la masterclass de agentes más barata que vas a tomar.

Bash
npm install -g killami-code
cd tu-proyecto
killami

Te recibe con sol, palma y mar. Y después se pone a trabajar.

Comentarios (0)

Inicia sesión para dejar un comentario.

    Aún no hay comentarios. ¡Sé el primero en comentar!