El bucle del agente
Antes de empezar
Dos cosas con las que tropieza casi todo el mundo la primera vez:
-
Los snippets de los capítulos 01–08 no se corresponden línea a línea con
main.go. Muestran cómo se veía el harness en ese momento de la construcción. El repo en HEAD ya tiene la misma lógica repartida en paquetesinternal/, con una interfazTool, una TUI hecha con Bubble Tea y algunas capas más — la refactorización llega en el capítulo 10; los capítulos 03–09 van metiendo las piezas. Si abresmain.goesperando que cuadre exactamente con lo que estás leyendo, vas a acabar mareado. Quédate con la forma; al final de cada capítulo hay un callout "En el repo actual" que te indica dónde vive ese código hoy. -
Si prefieres verlo correr antes de leer la prosa, hazlo ahora:
examples/minimal/main.gotiene el agente entero en unas 130 líneas — sin abstracciones, sin TUI, solo el bucle y tres herramientas.go run ./examples/minimaly luego vuelves aquí para el porqué.
Aquí está el bucle general de cualquier aplicación que use agentes de IA a modo de arnés:
[tu entrada]
│
▼
[agregar a messages]
│
▼
[llamar al modelo] ─────┐
│ │
▼ │
[¿hay tool_use?] ──no───┴──▶ [imprimir texto, volver al REPL]
│
sí
│
▼
[ejecutar cada herramienta]
│
▼
[agregar tool_results]
│
▼
(volver a "llamar al modelo")
Eso es todo. El modelo decide qué hacer; el arnés ejecuta; el bucle continúa hasta que el modelo deja de pedir herramientas. Todo lo demás en este repositorio — proveedores, compactación, subagentes, la TUI — es una capa encima de este bucle.
Un paréntesis: ¿qué es un REPL?
El diagrama de arriba es el bucle interno, un turno completo del agente. También hay un bucle externo que envuelve todo esto, llamado REPL: Read–Eval–Print–Loop.
Si has escrito o jugado videojuegos, ya viste esta forma. Un game loop corre a 60 cuadros por segundo y hace las mismas cuatro cosas cada cuadro:
loop forever:
leer inputs (teclas, mouse, control)
actualizar estado (física, IA, lo que cambió desde el último cuadro)
renderizar (dibujar el nuevo cuadro en pantalla)
dormir hasta el siguiente cuadro
Un REPL es el mismo esqueleto, solo que ralentizado y dirigido por eventos. En lugar de correr 60 veces por segundo guiado por un temporizador, corre una vez por cada entrada tuya — disparado por lo que escribes, no por el reloj:
loop forever:
leer entrada (tu línea)
eval (hacer algo con ella)
print (mostrar el resultado)
esperar la siguiente línea
Ya has usado REPLs con esta forma, probablemente sin ponerles nombre: el prompt python3 de Python, la consola de JavaScript de un navegador, el propio bash. Mismo bucle, mismo propósito, distinto paso de "eval".
El bucle externo de nuestro harness es un REPL. El giro: "eval" significa "correr el bucle del agente sobre tu mensaje", no "ejecutar un trozo de código". Así que el harness tiene dos bucles anidados:
| Bucle | Lo dispara | Una iteración es |
|---|---|---|
| Externo (REPL) | Tus pulsaciones | Leer una línea → correr el agente sobre ella → imprimir → esperar la siguiente línea |
| Interno (bucle del agente) | Las elecciones del modelo | Mandar al modelo → si hay tool_use, ejecutar y agregar → repetir hasta terminar |
Mismo esqueleto que un game loop. El bucle externo es un tick de juego sobre tu entrada; el interno es el paso de actualización (con el modelo+herramientas haciendo las veces de física+IA). Cuando leas "el bucle" en capítulos posteriores, el contexto te dirá cuál — en su mayoría es el bucle del agente, porque ahí vive el estado interesante.
El vocabulario, en un ejemplo
El resto del capítulo — y los doce siguientes — se apoya en un puñado de términos de la API de Anthropic. Si nunca los has visto, aquí los tienes todos en una sola ida y vuelta.
Mandamos:
{
"model": "claude-opus-4-7",
"max_tokens": 8192,
"system": "Eres un asistente de programación.",
"tools": [
{"name": "read_file",
"description": "Lee un archivo en el path dado.",
"input_schema": {
"type": "object",
"properties": {"path": {"type": "string"}},
"required": ["path"]
}}
],
"messages": [
{"role": "user", "content": "¿qué hay en main.go?"}
]
}
Recibimos:
{
"content": [
{"type": "tool_use",
"id": "toolu_abc",
"name": "read_file",
"input": {"path": "main.go"}}
],
"stop_reason": "tool_use"
}
Ese es todo el vocabulario:
messageses la conversación hasta ese momento. Vamos añadiéndole turnos; la API no guarda estado, así que el cliente arrastra el historial entero (capítulo 06).toolses la lista de cosas que el modelo puede llamar. Cada herramienta lleva un JSON Schema describiendo sus entradas — JSON Schema es el formato estándar para tipar las entradas de las herramientas de un LLM.- bloques de
contentes lo que devuelve el modelo:textcuando tiene algo que decir,tool_usecuando quiere que el harness ejecute algo. stop_reasonle indica al bucle qué hacer a continuación:tool_usesignifica "ejecuta esas herramientas y vuélveme a preguntar";end_turnsignifica "imprime y devuelve el control al usuario".max_tokenspone un techo al tamaño de la salida, contado en tokens (~4 caracteres de texto en inglés por token).
Si has usado la API de OpenAI, la forma es prácticamente la misma — distintos nombres para la misma idea (tool_calls en lugar de tool_use, finish_reason en lugar de stop_reason). La interfaz de provider del capítulo 03 es donde tapamos esa diferencia.
Qué pasa en un turno
El diagrama del principio del capítulo muestra la imagen completa, pero es más fácil de digerir en dos pasadas — primero sin herramientas, después con ellas.
Pasada 1: sin herramientas, es solo un cliente de chat
Imagina que el modelo no tiene herramientas. El bucle interno colapsa a:
- Escribes un mensaje.
- Lo agregamos a una lista corriente de mensajes.
- Mandamos la lista completa al modelo.
- El modelo devuelve una respuesta de texto.
- La imprimimos.
- Esperamos tu siguiente entrada.
Eso es un programa que funciona. Conversa. Podrías preguntar "¿cuál es la capital de Francia?" y obtener "París." Pero no puede hacer nada — no tiene manos. Desde tu perspectiva, se siente como un wrapper alrededor de la API.
Pasada 2: las herramientas convierten el chat en un agente
Para darle manos al modelo, agregamos herramientas — operaciones con nombre que el harness sabe cómo ejecutar, como bash, read_file, write_file. Cada herramienta tiene un schema JSON que describe sus entradas. Los schemas se mandan junto con cada petición al modelo para que sepa qué está disponible.
Ahora el modelo tiene dos tipos de respuesta que puede devolver:
| Forma de la respuesta | Qué significa | Qué hacemos |
|---|---|---|
| Texto plano | "Aquí está mi respuesta." | Imprimirlo, esperar tu siguiente mensaje |
| Una petición de tool call | "Antes de responder, por favor corre read_file con path: main.go y dime qué contiene." |
Ejecutar la herramienta, mandar el resultado de vuelta, llamar al modelo otra vez |
La segunda rama es donde entra el bucle. Cuando el modelo pide una herramienta, el harness:
- Ejecuta la herramienta localmente (p.ej. corre
read_file, captura la salida). - Agrega un tool result al slice de mensajes.
- Manda la conversación ya más larga de vuelta al modelo.
- El modelo ve el resultado y decide qué hacer a continuación — responder o llamar a otra herramienta.
Cómo se ve realmente una herramienta en el repo. Aquí está la herramienta
read_fileviva —internal/tool/readfile.goen este codebase:type ReadFileTool struct{} func (ReadFileTool) Definition() api.ToolDef { return api.ToolDef{ Name: "read_file", Description: "Read the contents of a file at the given path.", InputSchema: map[string]any{ "path": map[string]any{ "type": "string", "description": "Path to the file to read.", }, }, Required: []string{"path"}, } } func (ReadFileTool) Execute(_ context.Context, rawInput string) (string, bool) { var in struct{ Path string `json:"path"` } json.Unmarshal([]byte(rawInput), &in) data, err := os.ReadFile(in.Path) if err != nil { return err.Error(), true } return string(data), false }Un struct que implementa dos métodos.
Definitiondevuelve el schema que ve el modelo.Executehace el trabajo y devuelve(result string, isError bool). El capítulo 09 cubre por qué las herramientas terminan con esta forma; el resto de este capítulo muestra un precursor más simple donde las tres herramientas se despachan con un switch.
Ese último paso es recursivo de espíritu pero iterativo en el código. Un solo mensaje tuyo puede disparar una llamada al modelo ("París.") o veinte (leer tres archivos, correr dos comandos bash, y finalmente sintetizar una respuesta). El modelo elige; el harness obedece. Seguimos iterando mientras el modelo siga pidiendo herramientas, y paramos en el momento en que devuelve texto plano.
Juntando las piezas
Así que el bucle interno tiene exactamente dos salidas:
- El modelo devuelve texto → imprimirlo, volver al REPL, esperar tu siguiente mensaje.
- El modelo devuelve una tool call → ejecutar la herramienta, agregar el resultado, preguntar otra vez.
Esa es la imagen conceptual completa. Todo lo que sigue en este capítulo es detalle a nivel de cables: cómo se ven en realidad la petición y la respuesta, qué herramientas exponemos y cómo estructurar el código Go.
Un turno, paso a paso
Escribes list the files here. Esta es la secuencia exacta que corre — once pasos para una entrada del usuario, porque el modelo decide que necesita una herramienta primero:
1. El REPL lee tu línea.
2. El REPL agrega a messages:
[{role: user, content: "list the files here"}]
3. El bucle del agente hace POST a api.anthropic.com/v1/messages con
{system, tools, messages}
4. Claude responde:
content: [{type: tool_use, id: "toolu_01",
name: "bash", input: {"command": "ls"}}]
stop_reason: "tool_use"
5. El bucle agrega el turno del asistente a messages y recorre su content:
- Ve un bloque tool_use.
- Imprime [tool] bash {"command":"ls"}
- Pregunta: approve? [y/n]
6. Escribes y.
7. El harness corre sh -c "ls" , captura stdout:
"main.go\nREADME.md\n..."
8. El bucle agrega un tool_result a messages:
{role: user, content: [{type: tool_result,
tool_use_id: "toolu_01",
content: "main.go\nREADME.md\n...",
is_error: false}]}
9. stop_reason era tool_use → el bucle itera. POST a Claude otra vez.
10. Claude responde:
content: [{type: text, text: "Aquí están los archivos: ..."}]
stop_reason: "end_turn"
11. El bucle recorre content → imprime el texto. stop_reason ≠ tool_use →
vuelve al REPL, espera tu siguiente línea.
Cada capítulo posterior es una capa más sobre este trazado. La compactación (capítulo 07) recorta messages entre los pasos 2 y 3. Las políticas de permisos (capítulo 02) deciden qué pasa en el paso 6. Los subagentes (capítulo 11) cambian el paso 7 por un bucle de agente recursivo, y las herramientas MCP (capítulo 14) lo cambian por una llamada JSON-RPC a otro proceso. La forma del trazado no cambia — lo que cambia es lo que hace cada paso.
El contrato con el modelo
Una sola llamada a la API Messages de Anthropic tiene esta forma:
- Entrada: un prompt
system, un array demessagesy un array opcional detools(cada uno con un schema JSON para su entrada). - Salida: una respuesta con bloques de
content(texto y/otool_use) y unstop_reason.
El stop_reason es lo que dirige el bucle:
| Stop reason | Qué significa | Qué hacemos |
|---|---|---|
end_turn |
El modelo terminó | Imprimir texto, volver al REPL |
tool_use |
El modelo quiere llamar herramientas | Ejecutarlas, agregar resultados, llamar otra vez |
Hay otros stop reasons (max_tokens, refusal, etc.) — los manejamos tratando cualquier cosa que no sea tool_use como "terminamos con este turno".
Eligiendo la superficie de herramientas
Le podríamos haber dado al modelo una sola herramienta bash y darlo por hecho — bash puede leer archivos, escribir archivos, hacer de todo. O le podríamos haber dado decenas de herramientas especializadas.
Elegimos tres:
bash— para todo lo que no tenemos una herramienta dedicadaread_file— explícita, le da al harness un gancho para hacer chequeos de staleness después si queremoswrite_file— igual, además es fácil de exponer en la UI como "el modelo está escribiendo este archivo"
La razón por la que ascendimos las operaciones de archivos a herramientas dedicadas no es que sean necesarias — es que son controlables. Una herramienta read_file le da al harness una costura específica por acción para registrar, auditar o restringir. Bash nos da solo un string opaco de comando. La aprobación (próximo capítulo) tiene sentido por herramienta; no lo tiene si solo tienes bash.
Esta es la primera vez que importa la división harness/modelo: al modelo le da igual que le des una herramienta o tres. La forma de tu superficie de herramientas es una decisión del harness.
El bucle básico en Go
El esqueleto, más o menos:
func main() {
client := anthropic.NewClient()
var messages []anthropic.MessageParam
scanner := bufio.NewScanner(os.Stdin)
for {
fmt.Print("> ")
if !scanner.Scan() { return }
userInput := scanner.Text()
if userInput == "" { continue }
messages = append(messages, anthropic.NewUserMessage(
anthropic.NewTextBlock(userInput),
))
messages = agentLoop(messages)
}
}
func agentLoop(messages []anthropic.MessageParam) []anthropic.MessageParam {
for {
resp, _ := client.Messages.New(ctx, anthropic.MessageNewParams{
Model: anthropic.ModelClaudeOpus4_7,
MaxTokens: 8192,
System: []anthropic.TextBlockParam{{Text: systemPrompt}},
Messages: messages,
Tools: tools,
})
messages = append(messages, resp.ToParam()) // assistant turn
var toolResults []anthropic.ContentBlockParamUnion
for _, block := range resp.Content {
switch v := block.AsAny().(type) {
case anthropic.TextBlock:
fmt.Println(v.Text)
case anthropic.ToolUseBlock:
result, isErr := executeTool(v.Name, v.JSON.Input.Raw())
toolResults = append(toolResults,
anthropic.NewToolResultBlock(v.ID, result, isErr))
}
}
if resp.StopReason != anthropic.StopReasonToolUse {
return messages
}
messages = append(messages, anthropic.NewUserMessage(toolResults...))
}
}
El REPL completo es el bucle externo; el bucle del agente es el interno. Están anidados a propósito: el REPL es una conversación, y cada turno de la conversación es potencialmente múltiples viajes de ida y vuelta entre modelo y herramientas.
El switch de executeTool
El dispatcher de herramientas es un switch sobre el nombre de la herramienta. Cada caso decodifica el JSON de entrada, hace el trabajo y devuelve un string + una flag de error:
func executeTool(name, rawInput string) (string, bool) {
fmt.Printf("[tool] %s %s\n", name, rawInput)
switch name {
case "bash":
var in struct{ Command string `json:"command"` }
json.Unmarshal([]byte(rawInput), &in)
out, err := exec.Command("sh", "-c", in.Command).CombinedOutput()
if err != nil {
return fmt.Sprintf("%s\n[exit error: %v]", out, err), true
}
return string(out), false
case "read_file":
// similar
case "write_file":
// similar
default:
return fmt.Sprintf("unknown tool: %s", name), true
}
}
Tres cosas que vale la pena señalar:
-
La función nunca devuelve un
errorde Go. Los fallos se convierten en strings que el modelo lee. Siread_filefalla porque el path no existe, el tool result es"no such file or directory"conis_error: true. El modelo lo ve, se disculpa o prueba un path distinto, y continúa. Si devolviéramos un error de Go y crasheáramos el bucle, el modelo no tendría manera de recuperarse. -
Hay un print arriba.
fmt.Printf("[tool] %s %s\n", name, rawInput)— pura observabilidad. Te deja ver las acciones del agente conforme ocurren. No es load-bearing. -
El caso default es defensivo. Los modelos a veces alucinan nombres de herramientas. Devolver un resultado de error (en lugar de hacer panic) le permite al modelo autocorregirse.
Tropiezos que tuvimos
Olvidar resp.ToParam(). La respuesta del modelo tiene que ser agregada de vuelta a messages antes de la siguiente iteración del bucle — si no, el modelo no tiene idea de qué dijo en el turno anterior. El .ToParam() del SDK convierte la respuesta a la forma correcta. Es fácil saltárselo la primera vez que escribes esto.
IDs de tool result. Cada bloque tool_use tiene un id; cada tool_result que mandas de vuelta tiene que referenciar ese id vía tool_use_id. Si no coinciden, la API devuelve un 400 hablando de un tool result huérfano. El NewToolResultBlock(id, content, isErr) del SDK te arma el bloque.
Terminación del bucle. Si chequeas el campo equivocado (p.ej., stop_reason == "end_turn" en lugar de != "tool_use"), o vas a iterar para siempre o no vas a iterar nunca. El chequeo confiable es "¿la respuesta contiene algún bloque tool_use?" — equivalente a stop_reason == "tool_use".
En el repo actual. El bucle del agente vive en
internal/agent/agent.gocomo el método(*Agent).loop(el capítulo 11 cubre por qué terminó siendo un método sobre un struct). El wrapperexecuteToolen la capa del harness está enmain.go. El dispatch de switch único de arriba evolucionó a untool.Registry— el capítulo 09 cubre ese refactor.
Ahora prueba
- Instrumenta el bucle. Abre
examples/minimal/main.goy añade unlog.Printfantes de cada paso del trazado de arriba: justo antes declient.Messages.New(paso 3), después de recibir la respuesta (paso 4) imprimiendostop_reasony los tipos de bloque, después de cadaexecuteTool(paso 7), y justo antes de retornar al REPL (paso 11). Corrego run ./examples/minimal, pídelelist the files here, y compara los logs con los 11 pasos. Bonus: imprimelen(messages)en cada paso — verás exactamente cómo crece. - Corre el agente y pídele
list the files here. Mira pasar volando los prints[tool] bash .... - Pídele
write a hello.txt with a haiku in it. Dos tool calls en un turno — observa el bucle. - Pídele
read the file /does/not/exist. El modelo recibe un string de error y o te lo reporta o prueba un path distinto. Este es el contrato de "errores como tool results" en acción.
Siguiente: 02 · El control de permisos.