Nivel 2
El bucle del agente
- user
- assistant
- tool_result
EventBus
El problema
Una mamá pájaro le pregunta a un modelo "¿puedes despejar el fuerte de los cerdos?". El modelo no puede lanzar
nada. Lo mejor que puede hacer es responder con un pedido de herramienta:
{ name: "launch_red", input: { angle: 40 } }.
Si tu código hace una sola llamada al modelo, la conversación termina ahí. Te quedas con un pedido que nadie ejecutó y sin respuesta. Si ejecutas la herramienta a mano, chocas con el mismo problema en la siguiente ronda, porque el modelo puede necesitar otra herramienta, y después otra más.
La solución
Un bucle con una única regla de salida:
- Envía al modelo todo el historial más la lista de herramientas disponibles.
- Si la respuesta termina con
stopReason: "end_turn", devuélvela. Esa es la única salida normal. -
Si termina con
"tool_use", ejecuta cada herramienta pedida, agrega los resultados al historial como bloquestool_resulty vuelve al paso 1.
El modelo decide qué hacer; el bucle es el que lo hace. Esa división es la base de todos los demás patrones: todo lo demás (steering, compactación de contexto, subagentes) se engancha en algún punto de este bucle.
El elenco
El bucle contado como una pequeña aventura. Una vez que conoces al elenco, ya no queda nada por descifrar.
- Astor el bucle
- Un pequeño tanguero, y el único que se mueve. Le lleva la pregunta al Oráculo, corre a la resortera en cada tiro y le trae la respuesta a la mamá pájaro.
- El Oráculo Provider
- El modelo. Nunca toca la resortera: solo escucha el bandoneón y devuelve una nota. Naranja si necesita una herramienta, dorada si terminó.
- El bandoneón messages[]
- El historial, un pliegue de color por mensaje. El fuelle crece en cada vuelta, y el Oráculo escucha cada pliegue cada vez. Eso son las notas que suben flotando hacia el Oráculo.
- El banco ToolRegistry
-
Un pájaro por herramienta:
launch_red,launch_bomby un tercero que hoy nadie necesita. Astor lanza el que nombra la nota y muestra el resultado: verde si funcionó. - La mamá pájaro agent.run()
- Tu código. Hace la pregunta y espera.
- Estelas, puntaje y pájaros historial, tokens, maxTurns
- Cada tiro deja su estela en el cielo, igual que el historial guarda cada resultado. El puntaje son los tokens, y salta más en cada vuelta porque se vuelve a enviar todo el historial. Cada vuelta cuesta un pájaro de la fila de la barra superior, el presupuesto de turnos. Los números son ilustrativos.
El panel EventBus muestra los eventos que emite el bucle real en cada paso de la animación.
El código
Con astorlm: El mismo bucle vive en src/agent/loop.ts, con streaming, reintentos,
hooks, ejecución de herramientas en paralelo y cancelación. Desde afuera se ve así.
Desde cero: Unas 40 líneas contra cualquier endpoint compatible con OpenAI, sin SDK:
fetch pelado en TypeScript, la biblioteca estándar en Python. Los tres pasos de arriba están
marcados en los comentarios. Completa el bloque LLM del principio con tu propio endpoint, modelo y
clave.
import { OpenAIProvider, createLocalAgent, tool } from 'astorlm'
import { z } from 'zod'
// Your game's functions, wrapped as tools: one per bird.
const angle = z.number().min(10).max(80).describe('Launch angle in degrees')
const launchRed = tool({
name: 'launch_red',
description: 'Fling the red bird. Good against wood. Returns what fell and how many pigs are left.',
schema: z.object({ angle }),
execute: async ({ angle }) => level.fling('red', angle), // your code
})
const launchBomb = tool({
name: 'launch_bomb',
description: 'Fling the bomb bird. It explodes on impact: the one to use against stone.',
schema: z.object({ angle }),
execute: async ({ angle }) => level.fling('bomb', angle), // your code
})
const agent = await createLocalAgent({
// Any OpenAI-compatible endpoint: OpenAI, Ollama, LM Studio, vLLM, a proxy…
provider: new OpenAIProvider({
baseURL: 'http://localhost:11434/v1', // e.g. Ollama's default address
model: 'your-model', // e.g. 'llama3.1', 'gpt-4o-mini'
apiKey: 'YOUR_API_KEY', // local servers usually ignore it
}),
tools: [launchRed, launchBomb],
maxTurns: 10, // the birds in line: a cap on the laps
})
agent.on('tool-start', (tool) => console.log('→', tool.name, tool.input))
const answer = await agent.run('The pigs took our eggs! Can you clear their fort?')
// Agent loop from scratch. Plain fetch, no SDK.
// Any OpenAI-compatible endpoint: OpenAI, Ollama, LM Studio, vLLM, a proxy…
const LLM = {
baseURL: 'http://localhost:11434/v1', // e.g. Ollama's default address
model: 'your-model', // e.g. 'llama3.1', 'gpt-4o-mini'
apiKey: 'YOUR_API_KEY', // local servers usually ignore it
}
type ToolCall = { id: string; function: { name: string; arguments: string } }
type Message =
| { role: 'user'; content: string }
| { role: 'assistant'; content: string | null; tool_calls?: ToolCall[] }
| { role: 'tool'; tool_call_id: string; content: string }
type ToolFn = (args: Record<string, string>) => Promise<string>
const tools: Record<string, ToolFn> = { launch_red: launchRed, launch_bomb: launchBomb }
const toolSchemas = [/* one JSON Schema per tool */]
export async function runAgent(prompt: string, maxTurns = 10): Promise<string> {
const messages: Message[] = [{ role: 'user', content: prompt }]
for (let turn = 1; turn <= maxTurns; turn++) {
// 1. Send the whole history plus the tool list.
const res = await fetch(`${LLM.baseURL}/chat/completions`, {
method: 'POST',
headers: { 'content-type': 'application/json', authorization: `Bearer ${LLM.apiKey}` },
body: JSON.stringify({ model: LLM.model, messages, tools: toolSchemas }),
})
const [choice] = (await res.json()).choices
const reply: Message = choice.message
messages.push(reply)
// 2. No tool calls: the model is done. The only normal exit.
if (choice.finish_reason !== 'tool_calls') return reply.content ?? ''
// 3. Run each requested tool and feed the result back as a message.
for (const call of reply.tool_calls ?? []) {
const run = tools[call.function.name]
let output = `Unknown tool: ${call.function.name}`
if (run) {
try {
output = await run(JSON.parse(call.function.arguments))
} catch (err) {
output = `Error: ${err instanceof Error ? err.message : err}`
}
}
messages.push({ role: 'tool', tool_call_id: call.id, content: output })
}
}
throw new Error(`No answer after ${maxTurns} turns`)
}
# Agent loop from scratch. Standard library only, no SDK.
import json
import urllib.request
# Any OpenAI-compatible endpoint: OpenAI, Ollama, LM Studio, vLLM, a proxy...
LLM = {
"base_url": "http://localhost:11434/v1", # e.g. Ollama's default address
"model": "your-model", # e.g. "llama3.1", "gpt-4o-mini"
"api_key": "YOUR_API_KEY", # local servers usually ignore it
}
TOOLS = {"launch_red": launch_red, "launch_bomb": launch_bomb}
TOOL_SCHEMAS = [...] # one JSON Schema per tool
def chat(messages):
request = urllib.request.Request(
f"{LLM['base_url']}/chat/completions",
data=json.dumps({"model": LLM["model"], "messages": messages, "tools": TOOL_SCHEMAS}).encode(),
headers={"Content-Type": "application/json", "Authorization": f"Bearer {LLM['api_key']}"},
)
with urllib.request.urlopen(request) as response:
return json.load(response)["choices"][0]
def run_agent(prompt, max_turns=10):
messages = [{"role": "user", "content": prompt}]
for _ in range(max_turns):
# 1. Send the whole history plus the tool list.
choice = chat(messages)
reply = choice["message"]
messages.append(reply)
# 2. No tool calls: the model is done. The only normal exit.
if choice["finish_reason"] != "tool_calls":
return reply.get("content") or ""
# 3. Run each requested tool and feed the result back as a message.
for call in reply.get("tool_calls", []):
name = call["function"]["name"]
run = TOOLS.get(name)
try:
output = run(**json.loads(call["function"]["arguments"])) if run else f"Unknown tool: {name}"
except Exception as err:
output = f"Error: {err}"
messages.append({"role": "tool", "tool_call_id": call["id"], "content": output})
raise RuntimeError(f"No answer after {max_turns} turns")
Fíjate que un error de herramienta no detiene el bucle: vuelve al modelo como texto, para que pueda corregirse en la siguiente ronda.
Cuándo usarlo y qué vigilar
Siempre que el modelo necesite actuar (leer, buscar, ejecutar algo) antes de poder responder. Si solo necesitas transformar texto, alcanza con una sola llamada, y es más barato.
- El costo crece en cada vuelta. Mira el bandoneón y el contador de tokens: el Oráculo escucha cada pliegue en cada turno, así que cada vuelta reenvía todo lo anterior. Con suficientes turnos, el contexto se llena.
-
Define siempre
maxTurns. Un modelo confundido puede seguir pidiendo herramientas para siempre. Sin un límite, el bucle también corre para siempre.