De generar archivos a usar herramientas: el loop ReAct de un agente de código
El paso de generar la función entera a un agente que lee, busca, edita y corre con herramientas: el loop ReAct y por qué el agente construye su propio contexto en vez de recibirlo en el prompt.
Hasta ahora, en esta serie, el agente generaba la solución completa en una sola llamada: le dabas la spec y los tests, y el modelo devolvía la función entera. Eso funciona cuando toda la tarea cabe en un prompt. Pero en un repositorio real no sabes de antemano qué archivos hacen falta, ni puedes meter miles de ellos en la ventana de contexto. El salto de este post es darle al agente herramientas para que consiga ese contexto por sí mismo: leer, buscar, editar y ejecutar comandos. Ese patrón —razonar, actuar con una herramienta, observar el resultado y repetir— se llama ReAct, es el componente “acción” que el primer post prometió abrir, y es donde un loop de juguete empieza a parecerse a Claude Code.
Resumen para perezosos
- El cambio es de "genera la función entera" a "lee, busca, edita, corre". El modelo deja de emitir la solución y empieza a emitir llamadas a herramientas; el programa las ejecuta y le devuelve el resultado.
- El agente construye su propio contexto. En vez de que tú metas el repo en el prompt, lee y busca solo lo que necesita, cuando lo necesita. El contexto es el resultado de sus observaciones, no algo que precargas.
- Es el loop ReAct: razonar, actuar, observar. El mismo loop, evaluador y sandbox de los posts anteriores, pero la acción ya no es única —es elegir entre varias herramientas—, y esa elección es lo que lo hace sentir como un agente de código real.
En este artículo:
- Fundamentos — Por qué deja de servir generar el archivo entero · Qué es una herramienta para un LLM
- Implementación — Las cuatro herramientas · El loop ReAct · Una tarea paso a paso
- Operación — Dónde se complica · El agente construye su contexto
Por qué generar el archivo entero deja de servir
En el loop write-test-fix la acción del agente era una sola: “escribe la función”. El modelo recibía todo lo que necesitaba en el prompt —la spec y los tests— y devolvía la solución completa en un bloque. Funcionó porque el ejemplo cabía entero en el prompt: una función pequeña, unos pocos tests, nada más.
Esa condición se rompe en cuanto sales del caso de juguete. Una tarea real —“arregla el 500 que devuelve el endpoint de usuarios”, “agrega un campo a este modelo”— vive en un repositorio de miles de archivos. No puedes meter el repo entero en el prompt: no cabe en la ventana de contexto, y aunque cupiera, pagarías una fortuna por mandar miles de archivos irrelevantes en cada llamada. Y hay un problema anterior a ese: no sabes de antemano qué archivos hacen falta. Esa es justo la parte que querías que el agente resolviera.
La salida no es darle más contexto en el prompt, sino darle la capacidad de conseguirlo él mismo. En vez de una acción única que produce la solución, le das un conjunto de acciones —leer un archivo, buscar un patrón, escribir, correr un comando— y dejas que el modelo elija cuál usar en cada vuelta. El cambio es exactamente este:
WRITE-TEST-FIX (post 2) ReAct con herramientas (este post)
acción única: muchas acciones; el modelo elige:
"escribe la función entera" leer · buscar · escribir · correr
el contexto entra en el prompt el agente arma su contexto:
(spec + tests, todo de una vez) lee y busca solo lo que necesita
El loop por debajo es el mismo del primer post: estado, acción, observación, condición de parada. Lo único que cambia es qué es la “acción”. Antes era escribir código; ahora es elegir y llamar una herramienta. Pero ese cambio aparentemente pequeño es el que separa un generador de funciones de un agente que opera sobre un repo.
Qué es una herramienta para un LLM
Una herramienta, en el sentido de un LLM, es una función que el modelo puede pedir que se ejecute. No la ejecuta él —esto es lo mismo que repetí en toda la serie: el modelo elige, el programa ejecuta—, sino que emite una llamada a herramienta: el nombre de la función y los argumentos, en formato estructurado. El programa lee esa llamada, corre la función real y le devuelve el resultado.
Para que el modelo sepa qué herramientas existen, le pasas en cada petición una lista de definiciones: el nombre, una descripción de para qué sirve, y el esquema de sus parámetros. Esto es tool calling, y casi todos los modelos actuales lo soportan de forma nativa. Conviene fijar el límite desde ya: tool calling es solo el protocolo de comunicación. No convierte al modelo en un agente; únicamente le permite pedir acciones externas. El agente aparece cuando ese pedido entra en un loop, que es lo que arma el resto del post. La definición de una herramienta se ve así:
{
"type": "function",
"function": {
"name": "read_file",
"description": "Lee un archivo del repo y devuelve su contenido. Úsalo antes de editar.",
"parameters": {
"type": "object",
"properties": {
"path": { "type": "string", "description": "Ruta relativa del archivo" }
},
"required": ["path"]
}
}
}
Cuando el modelo decide usarla, no responde con texto: responde con una llamada estructurada que el SDK te entrega aparte de la respuesta normal.
{
"tool_calls": [
{
"id": "call_01",
"function": { "name": "read_file", "arguments": "{\"path\": \"src/users.ts\"}" }
}
]
}
Ese objeto es la “acción” del loop, ahora en forma de dato: el modelo dice qué quiere hacer y con qué argumentos, y deja que tu código lo haga. La descripción de la herramienta importa más de lo que parece: es lo que el modelo lee para decidir cuál usar. Una descripción vaga lleva a que elija mal. En la práctica, escribir buenas descripciones de herramientas es una parte central de afinar un agente, casi tanto como el prompt.
Las cuatro herramientas: leer, buscar, escribir, correr
Para parecerte a un agente de código no necesitas decenas de herramientas. Con cuatro llegas sorprendentemente lejos, porque cubren las cuatro cosas que un humano hace en un repo: leer un archivo, buscar dónde está algo, escribir un cambio y correr algo para comprobarlo. Las cuatro se definen con el mismo formato que read_file de arriba —cambian el nombre, la descripción y los parámetros—, así que no repito el JSON; lo interesante está del otro lado de la definición.
Del otro lado hay una función normal de tu código. Lo único que comparten todas es que devuelven texto: la observación que volverá al modelo. Muestro dos —leer y correr—; grep y write_file siguen exactamente el mismo patrón.
// tools.ts — la implementación real de cada herramienta. Cada una devuelve texto:
// la observación que volverá al modelo. (grep y write_file: mismo patrón.)
import { readFileSync } from "node:fs";
import { execSync } from "node:child_process";
export const TOOLS: Record<string, (args: any) => string> = {
read_file: ({ path }) => readFileSync(path, "utf8"),
// run_command debería ir dentro del sandbox del post 3 (timeout, proceso aislado).
run_command: ({ cmd }) => {
try {
return execSync(cmd, { encoding: "utf8", timeout: 10000 });
} catch (err: any) {
return (err.stdout ?? "") + (err.stderr ?? "");
}
},
// grep({ pattern }) y write_file({ path, content }): igual de cortas, devuelven texto.
};# tools.py — la implementación real de cada herramienta. Cada una devuelve texto:
# la observación que volverá al modelo. (grep y write_file: mismo patrón.)
import subprocess
def read_file(path: str) -> str:
with open(path, encoding="utf-8") as f:
return f.read()
# run_command debería ir dentro del sandbox del post 3 (timeout, proceso aislado).
def run_command(cmd: str) -> str:
proc = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=10)
return proc.stdout + proc.stderr
# grep(pattern) y write_file(path, content): igual de cortas, devuelven texto.
TOOLS = {"read_file": read_file, "run_command": run_command}<?php
// tools.php — la implementación real de cada herramienta. Cada una devuelve texto:
// la observación que volverá al modelo. (grep y write_file: mismo patrón.)
function read_file_tool(array $a): string {
return file_get_contents($a["path"]);
}
// run_command debería ir dentro del sandbox del post 3 (timeout, proceso aislado).
function run_command_tool(array $a): string {
$out = [];
exec($a["cmd"] . " 2>&1", $out);
return implode("\n", $out);
}
// grep_tool($a) y write_file_tool($a): igual de cortas, devuelven texto.
const TOOLS = [
"read_file" => "read_file_tool",
"run_command" => "run_command_tool",
];Fíjate en run_command: cuando el comando son los tests, esa herramienta es el evaluador del post anterior. Y como ejecuta lo que el modelo pidió, debería ir dentro del sandbox del post 3 —timeout, proceso aislado, entorno limpio—; aquí lo dejo directo solo por brevedad. Las piezas de los posts anteriores no desaparecen: se vuelven herramientas dentro de este loop.
El loop ReAct: razonar, actuar, observar
Con las herramientas definidas e implementadas, el loop es el mismo while de siempre, pero cada vuelta tiene tres momentos en lugar de uno. El modelo razona (decide qué hacer), actúa (emite una llamada a herramienta), y el programa le devuelve la observación (el resultado). Eso es ReAct: reason + act, en bucle. Desenrollado, una corrida se ve así:
prompt (objetivo)
│
▼
modelo ──► read_file ──────► observación ← razonar · actuar · observar
│
▼
modelo ──► grep ───────────► observación
│
▼
modelo ──► run_command ────► observación
│
▼
modelo ──► "LISTO" (sin herramienta: el loop para)
Cada fila es una vuelta del loop, y cada modelo ve todas las observaciones de las filas de arriba: el contexto crece hacia abajo, vuelta a vuelta. La última fila es la condición de parada —el modelo responde sin pedir herramienta—. En código, el driver hace una cosa nueva respecto al post 2: en vez de mandar siempre la salida a un runner de tests, mira si el modelo pidió una herramienta. Si la pidió, la ejecuta y devuelve el resultado; si no, el agente terminó.
// agent.ts — el loop ReAct. El modelo elige una herramienta, el programa la ejecuta,
// el resultado vuelve como observación. Igual que el write-test-fix, pero la acción
// ahora es "¿qué herramienta llamo?", no "escribe la función entera".
import { client } from "./openrouter"; // el mismo cliente del post de routing
import { TOOLS } from "./tools";
import { toolSchema } from "./tool-schema"; // el array JSON de definiciones
const MAX_ITER = 25;
export async function runAgent(goal: string): Promise<string> {
const messages: any[] = [
{ role: "system", content: "Eres un agente de código. Usa las herramientas para leer, buscar, editar y correr. Cuando los tests pasen, responde 'LISTO'." },
{ role: "user", content: goal },
];
for (let i = 0; i < MAX_ITER; i++) { // ← tope rígido (post 1)
const res = await client.chat.completions.create({
model: "anthropic/claude-opus-4.8",
messages,
tools: toolSchema, // las herramientas disponibles
});
const msg = res.choices[0].message;
messages.push(msg); // el "razonar" + la decisión
if (!msg.tool_calls) return msg.content ?? ""; // ← sin tool call: el agente terminó
for (const call of msg.tool_calls) { // ← "actuar": ejecutar cada herramienta
const args = JSON.parse(call.function.arguments);
const observation = TOOLS[call.function.name](args); // el programa EJECUTA
messages.push({ // ← "observar": el resultado vuelve
role: "tool",
tool_call_id: call.id,
content: observation.slice(0, 4000), // recorta: las observaciones llenan el contexto
});
}
}
return "Se alcanzó el tope de iteraciones.";
}# agent.py — el loop ReAct. El modelo elige una herramienta, el programa la ejecuta,
# el resultado vuelve como observación. Igual que el write-test-fix, pero la acción
# ahora es "¿qué herramienta llamo?", no "escribe la función entera".
import json
from openrouter import client # el mismo cliente del post de routing
from tools import TOOLS
from tool_schema import TOOL_SCHEMA # el array JSON de definiciones
MAX_ITER = 25
def run_agent(goal: str) -> str:
messages = [
{"role": "system", "content": "Eres un agente de código. Usa las herramientas para leer, buscar, editar y correr. Cuando los tests pasen, responde 'LISTO'."},
{"role": "user", "content": goal},
]
for _ in range(MAX_ITER): # ← tope rígido (post 1)
res = client.chat.completions.create(
model="anthropic/claude-opus-4.8",
messages=messages,
tools=TOOL_SCHEMA, # las herramientas disponibles
)
msg = res.choices[0].message
messages.append(msg) # el "razonar" + la decisión
if not msg.tool_calls: # ← sin tool call: el agente terminó
return msg.content or ""
for call in msg.tool_calls: # ← "actuar": ejecutar cada herramienta
args = json.loads(call.function.arguments)
observation = TOOLS[call.function.name](**args) # el programa EJECUTA
messages.append({ # ← "observar": el resultado vuelve
"role": "tool",
"tool_call_id": call.id,
"content": observation[:4000], # recorta: las observaciones llenan el contexto
})
return "Se alcanzó el tope de iteraciones."<?php
// agent.php — el loop ReAct. El modelo elige una herramienta, el programa la ejecuta,
// el resultado vuelve como observación. Igual que el write-test-fix, pero la acción
// ahora es "¿qué herramienta llamo?", no "escribe la función entera".
require "openrouter.php"; // el mismo cliente del post de routing ($client)
require "tools.php";
require "tool_schema.php"; // $TOOL_SCHEMA: el array de definiciones
const MAX_ITER = 25;
function run_agent(string $goal): string {
global $client, $TOOL_SCHEMA;
$messages = [
["role" => "system", "content" => "Eres un agente de código. Usa las herramientas para leer, buscar, editar y correr. Cuando los tests pasen, responde 'LISTO'."],
["role" => "user", "content" => $goal],
];
for ($i = 0; $i < MAX_ITER; $i++) { // ← tope rígido (post 1)
$res = $client->chat()->create([
"model" => "anthropic/claude-opus-4.8",
"messages" => $messages,
"tools" => $TOOL_SCHEMA, // las herramientas disponibles
]);
$msg = $res->choices[0]->message;
$messages[] = $msg->toArray(); // el "razonar" + la decisión
if (empty($msg->toolCalls)) { // ← sin tool call: el agente terminó
return $msg->content ?? "";
}
foreach ($msg->toolCalls as $call) { // ← "actuar": ejecutar cada herramienta
$args = json_decode($call->function->arguments, true);
$fn = TOOLS[$call->function->name];
$observation = $fn($args); // el programa EJECUTA
$messages[] = [ // ← "observar": el resultado vuelve
"role" => "tool",
"tool_call_id" => $call->id,
"content" => substr($observation, 0, 4000), // recorta: llenan el contexto
];
}
}
return "Se alcanzó el tope de iteraciones.";
}Compáralo con el driver del post 2 y verás que la estructura es idéntica: un bucle con tope, una llamada al modelo, una ejecución, y la observación de vuelta al estado. Lo nuevo son dos cosas. Una: pasas tools en la petición, así el modelo sabe qué puede pedir. Dos: en vez de correr siempre los tests, despachas la herramienta que el modelo eligió y devuelves su salida con role: "tool". La condición de parada también cambió de forma: el agente termina cuando deja de pedir herramientas y responde con texto. Sigue conviviendo con el tope rígido de iteraciones, exactamente por las razones del primer post.
Una tarea real, paso a paso
La teoría se entiende mejor con un trace. Supón este objetivo: “el endpoint /users devuelve 500 cuando filtras por rol”. El agente no recibió ningún archivo en el prompt, solo esa frase. Mira cómo consigue el resto por su cuenta:
Objetivo: "/users devuelve 500 al filtrar por rol"
1 grep "users" → muchas coincidencias, demasiado ruido
2 read_file users.service.ts → no es aquí: este archivo no filtra por rol
3 grep "rol" → users.controller.ts:42, roles.ts:10
4 read_file users.controller.ts → el handler usa ROLES de roles.ts
5 read_file roles.ts → ROLES no incluye "admin", el rol que falla
6 write_file roles.ts → agrega "admin" a la lista
7 run_command "npm test" → 1 test rojo: dejó una coma de más
8 read_file roles.ts → relee su propio cambio
9 write_file roles.ts → corrige la coma
10 run_command "npm test" → verde
11 responde "LISTO" → sin tool call: el loop para
Once vueltas, y en ninguna le pasaste un archivo. Fíjate en los pasos 1 y 2: el primer grep fue demasiado amplio y el primer read_file abrió el archivo equivocado. Eso no es un fallo del ejemplo, es la esencia de ReAct: el agente no sabe de antemano dónde está la respuesta, la descubre. Buscó de nuevo con un término mejor (grep "rol"), leyó lo justo para entender (dos read_file), hizo el cambio (write_file), lo comprobó contra el evaluador (run_command), y cuando un test se puso rojo, releyó su propio cambio y corrigió —el mismo ciclo de realimentación del error que vimos en el write-test-fix, solo que ahora dentro de una tarea de varios archivos.
Esto es lo que se siente como un agente de código de verdad. No porque el modelo sea más listo que en el post 2, sino porque puede moverse por el repo: encontrar, leer, cambiar y verificar sin que tú tengas que pasarle el contexto en el prompt.
Dónde se complica
El loop ReAct con cuatro herramientas es completo y funciona, pero como con cada pieza de la serie, conviene saber por dónde se rompe antes de confiarte:
- El contexto se llena. Cada observación —el contenido de un archivo, la salida de un
grep— se acumula en el estado. En tareas largas, eso desborda la ventana de contexto y encarece cada llamada. Elslice(0, 4000)del código es un parche tosco; los agentes reales resumen los pasos viejos, descartan lecturas que ya no importan o guardan parte del estado fuera del prompt. Es el tema del siguiente post de la serie. - Los errores de las herramientas también son observaciones. Un
read_filede una ruta que no existe, un comando que falla: cada uno tiene que volver al modelo como un mensaje claro y clasificado, no como una excepción que tumba el loop. Es la misma lección del sandbox, aplicada ahora a todas las herramientas, no solo a correr código. - Las descripciones son el prompt real. El modelo elige la herramienta leyendo su descripción. Descripciones ambiguas llevan a que use la equivocada o invente argumentos. Afinar un agente es, en gran parte, afinar esas descripciones.
- Demasiadas herramientas confunden. Cuantas más opciones le des, más fácil es que elija mal. Estas cuatro cubren casi todo; resiste la tentación de añadir veinte hasta que de verdad las necesites.
- Escribir y correr son peligrosos.
write_fileyrun_commandpueden romper cosas o ejecutar algo destructivo. Para acciones de riesgo conviene una aprobación humana antes de ejecutar, o el aislamiento del sistema operativo —las mismas barreras que mencioné en el primer post y en el sandbox. - El evaluador sigue decidiendo “terminado”. Las herramientas le dan al agente la capacidad de actuar, pero qué cuenta como correcto lo sigue diciendo el evaluador del post anterior: los tests que corre
run_command. Sin un buen evaluador, el agente actúa mucho y termina mal.
Ninguna invalida el patrón; lo acotan. Todas las herramientas de un agente de código real —que son más, y más afiladas— siguen viviendo dentro de este mismo loop de razonar, actuar y observar.
El agente construye su propio contexto
Por debajo de todos esos detalles hay una sola idea, y es la que conviene llevarse del post. En el write-test-fix, el contexto era algo que tú precargabas: metías la spec y los tests en el prompt y el modelo trabajaba con eso. Con herramientas, el contexto es algo que el agente construye: cada read_file y cada grep añade al estado justo lo que el modelo decidió que necesitaba, en el momento en que lo necesitaba.
Sin herramientas Con herramientas
tú decides qué entra al prompt el modelo decide qué leer
(y casi siempre te equivocas) (y lo pide cuando le hace falta)
contexto fijo, de una vez contexto que crece observación
a observación
El cambio es profundo. Deja de ser tu problema adivinar qué archivos meter —que era imposible de acertar para un repo grande— y pasa a ser una decisión del agente, vuelta a vuelta. Por eso un agente de código no te pide que pegues archivos: los lee. Y por eso este patrón escala donde el prompt-stuffing no: no importa que el repo tenga diez mil archivos, porque el agente solo trae a su contexto los pocos que toca.
La primera vez que miré el trace completo de un agente sobre un repo de verdad, lo que me sorprendió no fue el código que escribió, sino cuántas vueltas pasó leyendo y buscando antes de tocar una línea. La mayoría de sus acciones no eran “escribir”, eran “entender”. El prompt no le dio el contexto; se lo construyó él, una lectura a la vez.
Visto así, las herramientas no son un accesorio del agente: son el mecanismo por el que un modelo, que solo sabe transformar texto, llega a operar sobre un sistema que no cabe en su ventana de contexto. La inteligencia que parece tener un agente de código viene en buena parte de aquí, de que arma su propio contexto en lugar de depender del que le des.
Preguntas frecuentes
¿Esto es lo que hacen Claude Code y Cursor por dentro?
Es el núcleo, sí. Un agente de código real tiene más herramientas (editar por partes en vez de reescribir el archivo entero, listar directorios, aplicar parches, correr el linter), una gestión del contexto mucho más cuidada y un sistema de permisos para las acciones peligrosas. Pero el motor es este: un loop donde el modelo elige una herramienta, el programa la ejecuta, y el resultado vuelve como contexto, hasta que la tarea está hecha.
¿Cuál es la diferencia entre tool calling y un agente?
La misma que vimos en el primer post entre una inferencia y un loop. Una sola llamada a herramienta —el modelo pide una función, recibe el resultado y responde una vez— es tool calling, no un agente. Se vuelve agente cuando el resultado de la herramienta entra de nuevo al modelo y este decide la siguiente acción, repitiendo el ciclo. La frontera es la realimentación, no la presencia de herramientas.
¿Por qué no meter todo el repo en el prompt en lugar de dar herramientas?
Por tres razones. No cabe: un repo grande supera cualquier ventana de contexto. Es caro: pagarías por mandar miles de archivos irrelevantes en cada llamada. Y degrada la calidad: con demasiado texto irrelevante alrededor, al modelo le cuesta más encontrar lo que importa. Las herramientas resuelven las tres porque el agente trae solo lo que toca. Además, el repo cambia entre una tarea y otra; leerlo en vivo siempre está al día, un prompt precargado no.
¿Cómo sabe el modelo qué herramienta usar y con qué argumentos?
Por las definiciones que le pasas: el nombre, la descripción y el esquema de parámetros de cada herramienta. El modelo las lee y, según el objetivo y lo que ya observó, elige una y rellena los argumentos. Por eso las descripciones funcionan como parte del prompt: escríbelas como instrucciones claras, no como etiquetas, y di cuándo conviene usar cada herramienta.
¿Necesito un framework como LangGraph para esto?
No. Como en el resto de la serie, el núcleo es un while con una tabla que mapea nombre de herramienta a función. Lo puedes escribir a mano en cualquier lenguaje, como en el ejemplo. Los frameworks ayudan cuando el agente crece —gestión de memoria, trazas, reintentos—, pero ninguno hace falta para entender ni para arrancar. Empezar sin framework es la mejor forma de ver qué hace cada uno por dentro.
¿Qué pasa cuando tantas lecturas llenan el contexto?
Es el principal problema de escala de este patrón. Cada observación se queda en el estado, y en una tarea larga el contexto se llena de archivos y salidas viejas. Las soluciones son recortar (como el slice del ejemplo), resumir los pasos antiguos, o mover parte del estado fuera del prompt y volver a leerlo solo cuando haga falta. Es justo lo que abre el siguiente post de la serie: cómo se gestiona el estado cuando ya no cabe.
Conclusión
El paso de este post es el que convierte el loop mínimo en un agente de código: dejas de pedirle al modelo la solución entera y empiezas a darle herramientas —leer, buscar, escribir, correr— para que la consiga él. El loop por debajo no cambió; sigue siendo decidir, actuar, observar, con su evaluador y su tope de iteraciones. Lo que cambió es que la “acción” pasó de ser única a ser una elección entre varias herramientas, y que con cada observación el agente construye su propio contexto en vez de recibirlo en el prompt. Esa última idea es la que de verdad importa: es lo que le permite operar sobre un repo que no cabe en su ventana.
Si vas a construirlo, empieza por estas cuatro herramientas, haz que cada una devuelva una observación limpia y clasificada, escribe descripciones que digan cuándo usar cada una, deja el tope de iteraciones, y mete run_command dentro del sandbox antes de soltarlo sobre algo que importe. Lo que queda por resolver ya asomó en este post: cuando el agente lee y busca mucho, el contexto se llena. Cómo se gestiona el estado cuando deja de caber es el tema del siguiente post de la serie.