Nivel 10
Vueltas limpias
- user
- assistant
- tool_result
EventBus
El problema
Algunos trabajos no entran en una sola ejecución: migrar 300 archivos, traducir un catálogo entero, arreglar cada test que falla en un repo. Cada paso suma una llamada a herramienta y un resultado al historial, y el bucle reenvía todo eso en cada turno.
Ese es Muddle, la sesión interminable. Por la tarde ya carga cada paso desde la mañana: los pedidos son enormes, los resultados viejos entierran a los nuevos, y el modelo empieza a rehacer trabajo que ya hizo o a saltarse trabajo que solo había planeado. Nada se cae. La calidad simplemente se escurre.
La compactación (nivel 7) frena a Muddle. No lo detiene: un trabajo lo bastante largo termina resumiendo sus propios resúmenes.
La solución
No mantengas vivo un solo agente durante todo el trabajo. Córrelo en vueltas. En cada vuelta, tu código arranca un agente nuevo con el historial vacío y el mismo objetivo. Hace una porción del trabajo, anota cómo quedaron las cosas y termina. Después tu código revisa el trabajo en sí y, si no está terminado, arranca la siguiente vuelta.
-
Una sesión larga
Mantener el mismo agente y el mismo historial durante todo el trabajo.
Cada turno reenvía todo desde el principio. Los pedidos se vuelven más pesados, al modelo le cuesta más encontrar lo que importa en ellos, y pasada la ventana se rompe.
-
Compactar sobre la marcha
La misma sesión, pero achicando los mensajes viejos cuando el historial se acerca al límite (nivel 7).
Gana tiempo, no lo resuelve. Cada compactación pierde detalle, y un trabajo lo bastante largo termina compactando sus propios resúmenes.
-
Vueltas limpias
Dividir el trabajo en vueltas. Cada vuelta es un agente nuevo con el historial vacío. Lo que necesita saber, lo lee de archivos.
Cada vuelta empieza chica y limpia. El precio: cada vuelta gasta un turno o dos en ubicarse, y los archivos tienen que decir todo lo que importa.
El truco es que nada importante vive en el historial. El trabajo está en disco (el puente), y también una nota
corta que dice hasta dónde se llegó (PROGRESS.md). Un agente nuevo no necesita recordar la vuelta
anterior. Solo necesita leer.
Al patrón suelen llamarlo Ralph loop, por un one-liner de shell que le pasaba a un agente de código el mismo prompt una y otra vez. Los agentes de código lo usan para refactors largos, con el árbol de git y un archivo TODO como estado.
El elenco
El mismo elenco de siempre, esta vez en un cañón.
- La escotilla tu código
-
Arranca un agente nuevo en cada vuelta (
createIterationAgent) y recibe su respuesta. Es el bucle alrededor del bucle. - El Astor de una vuelta una ejecución del agente
- El bucle del agente del nivel 2, con su propio bandoneón. Empieza vacío y se va flotando cuando termina la vuelta.
- El puente el trabajo
- Lo que las herramientas cambiaron en disco. Ninguna vuelta lo tira.
- El cartel PROGRESS.md
- Una nota corta de cada vuelta para la siguiente: qué está hecho, qué viene después.
- DONE? isDone
- Tu verificación, entre vueltas. Mide el puente, no lo que el modelo dice de él.
- LAP 3/5 maxIterations
- El fusible. Si el trabajo nunca pasa la verificación, el bucle se detiene igual.
Mira las dos barras de arriba. Esta vuelta es lo que pesa de verdad cada pedido, y vuelve a empezar en cada vuelta. 1 sesión es lo que pesarían los mismos pedidos si un solo agente hubiera hecho las tres vueltas: nunca baja.
El código
Con astorlm: runGoalLoop recibe una fábrica que devuelve un agente nuevo, tu
verificación isDone y un fusible maxIterations. Las herramientas escriben en archivos,
así que cada vuelta encuentra el trabajo donde lo dejó la anterior.
Desde cero: El bucle del nivel 2, llamado dentro de un for. El historial es una
variable local de cada llamada, así que cada vuelta empieza vacía sin costo.
import { OpenAIProvider, createLocalAgent, runGoalLoop, tool } from 'astorlm'
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
import { z } from 'zod'
// Any OpenAI-compatible endpoint: OpenAI, Ollama, LM Studio, vLLM, a proxy…
const LLM = { baseURL: 'http://localhost:11434/v1', apiKey: 'YOUR_API_KEY' } // local servers usually ignore the key
// The state lives on disk, not in any history: the bridge, and a progress note.
const GAP = 36
const bridgeLength = (): number => (existsSync('bridge.json') ? JSON.parse(readFileSync('bridge.json', 'utf8')).length : 0)
const readProgress = tool({
name: 'read_progress',
description: 'Read PROGRESS.md: what earlier laps built, and where to start.',
schema: z.object({}),
execute: async () => (existsSync('PROGRESS.md') ? readFileSync('PROGRESS.md', 'utf8') : 'Nothing built yet.'),
})
const layBricks = tool({
name: 'lay_bricks',
description: 'Lay up to 12 bricks of the bridge, starting at brick number "from".',
schema: z.object({ from: z.number().int().min(1), count: z.number().int().min(1).max(12) }),
execute: async ({ from, count }) => {
const to = Math.min(from + count - 1, GAP)
writeFileSync('bridge.json', JSON.stringify({ length: Math.max(bridgeLength(), to) }))
return `Laid bricks ${from}-${to}. The bridge is ${bridgeLength()} bricks long.`
},
})
const writeProgress = tool({
name: 'write_progress',
description: 'Overwrite PROGRESS.md with where the bridge stands now, for whoever comes next.',
schema: z.object({ text: z.string() }),
execute: async ({ text }) => {
writeFileSync('PROGRESS.md', `# Progress\n${text}\n`)
return 'Saved PROGRESS.md.'
},
})
const result = await runGoalLoop({
goal: 'Build the bridge to the exit: 36 bricks. Read PROGRESS.md first, lay at most 12 bricks, then update PROGRESS.md.',
// A NEW agent every lap: empty history, fresh context window. Same tools, same folder.
createIterationAgent: () =>
createLocalAgent({
provider: new OpenAIProvider({ ...LLM, model: 'your-model' }), // e.g. 'llama3.1', 'gpt-4o-mini'
tools: [readProgress, layBricks, writeProgress],
maxTurns: 8,
}),
// Your code decides when the job is done, by checking the work itself. Not the model's word.
isDone: () => bridgeLength() >= GAP,
onIteration: ({ iteration, lastText }) => console.log(`lap ${iteration}: ${lastText}`),
maxIterations: 5, // the fuse: a goal that never checks out can't run forever
})
console.log(result) // { iterations: 3, done: true, stopReason: 'done', lastText: '…' }
// Fresh laps, from scratch. Plain fetch and node:fs, no SDK.
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
// 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
}
// 1. The state lives on disk: the bridge, and a progress note for the next lap.
const GAP = 36
const bridgeLength = (): number => (existsSync('bridge.json') ? JSON.parse(readFileSync('bridge.json', 'utf8')).length : 0)
type ToolFn = (args: Record<string, string | number>) => string
const tools: Record<string, ToolFn> = {
read_progress: () => (existsSync('PROGRESS.md') ? readFileSync('PROGRESS.md', 'utf8') : 'Nothing built yet.'),
lay_bricks: ({ from, count }) => {
const to = Math.min(Number(from) + Math.min(Number(count), 12) - 1, GAP)
writeFileSync('bridge.json', JSON.stringify({ length: Math.max(bridgeLength(), to) }))
return `Laid bricks ${from}-${to}. The bridge is ${bridgeLength()} bricks long.`
},
write_progress: ({ text }) => {
writeFileSync('PROGRESS.md', `# Progress\n${text}\n`)
return 'Saved PROGRESS.md.'
},
}
const toolSchemas = [/* one JSON Schema per tool: read_progress(), lay_bricks(from, count), write_progress(text) */]
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 }
// 2. The loop from level 2, unchanged. `messages` is born and dies inside each call.
async function runAgent(prompt: string, maxTurns = 8): Promise<string> {
const messages: Message[] = [{ role: 'user', content: prompt }]
for (let turn = 1; turn <= maxTurns; turn++) {
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)
if (choice.finish_reason !== 'tool_calls') return reply.content ?? ''
for (const call of reply.tool_calls ?? []) {
const run = tools[call.function.name]
let output = `Unknown tool: ${call.function.name}`
try {
if (run) output = 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`)
}
// 3. The goal loop: a fresh run per lap, then YOUR check of the work on disk.
const GOAL = 'Build the bridge to the exit: 36 bricks. Read PROGRESS.md first, lay at most 12 bricks, then update PROGRESS.md.'
const MAX_LAPS = 5 // the fuse
for (let lap = 1; lap <= MAX_LAPS; lap++) {
console.log(`lap ${lap}:`, await runAgent(GOAL))
if (bridgeLength() >= GAP) {
console.log(`Done after ${lap} laps.`)
break
}
if (lap === MAX_LAPS) throw new Error(`Bridge unfinished after ${MAX_LAPS} laps: ${bridgeLength()}/${GAP}`)
}
# Fresh laps, from scratch. Standard library only, no SDK.
import json
import urllib.request
from pathlib import Path
# 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
}
def post(path, payload):
request = urllib.request.Request(
f"{LLM['base_url']}{path}",
data=json.dumps(payload).encode(),
headers={"Content-Type": "application/json", "Authorization": f"Bearer {LLM['api_key']}"},
)
with urllib.request.urlopen(request) as response:
return json.load(response)
# 1. The state lives on disk: the bridge, and a progress note for the next lap.
GAP = 36
BRIDGE = Path("bridge.json")
PROGRESS = Path("PROGRESS.md")
def bridge_length():
return json.loads(BRIDGE.read_text())["length"] if BRIDGE.exists() else 0
def read_progress():
return PROGRESS.read_text() if PROGRESS.exists() else "Nothing built yet."
def lay_bricks(start, count):
end = min(start + min(count, 12) - 1, GAP)
BRIDGE.write_text(json.dumps({"length": max(bridge_length(), end)}))
return f"Laid bricks {start}-{end}. The bridge is {bridge_length()} bricks long."
def write_progress(text):
PROGRESS.write_text(f"# Progress\n{text}\n")
return "Saved PROGRESS.md."
TOOLS = {
"read_progress": read_progress,
"lay_bricks": lambda **args: lay_bricks(args["from"], args["count"]), # "from" is a Python keyword
"write_progress": write_progress,
}
TOOL_SCHEMAS = [...] # one JSON Schema per tool: read_progress(), lay_bricks(from, count), write_progress(text)
# 2. The loop from level 2, unchanged. `messages` is born and dies inside each call.
def run_agent(prompt, max_turns=8):
messages = [{"role": "user", "content": prompt}]
for _ in range(max_turns):
choice = post("/chat/completions", {"model": LLM["model"], "messages": messages, "tools": TOOL_SCHEMAS})["choices"][0]
reply = choice["message"]
messages.append(reply)
if choice["finish_reason"] != "tool_calls":
return reply.get("content") or ""
for call in reply.get("tool_calls", []):
try:
output = TOOLS[call["function"]["name"]](**json.loads(call["function"]["arguments"]))
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")
# 3. The goal loop: a fresh run per lap, then YOUR check of the work on disk.
GOAL = "Build the bridge to the exit: 36 bricks. Read PROGRESS.md first, lay at most 12 bricks, then update PROGRESS.md."
MAX_LAPS = 5 # the fuse
for lap in range(1, MAX_LAPS + 1):
print(f"lap {lap}:", run_agent(GOAL))
if bridge_length() >= GAP:
print(f"Done after {lap} laps.")
break
else:
raise RuntimeError(f"Bridge unfinished after {MAX_LAPS} laps: {bridge_length()}/{GAP}")
Qué vigilar
-
Verifica el trabajo, no la respuesta. Un “¡Listo!” del modelo no prueba nada.
isDonedebería mirar el resultado en sí: correr los tests, contar las filas, medir el puente. Mantenlo barato y determinista, porque corre después de cada vuelta. -
Pon siempre el fusible. Una verificación que nunca puede pasar, o un agente que sigue
deshaciendo su propio trabajo, da vueltas hasta que tu factura lo detiene.
maxIterations, y una mirada a por qué se agotó. - El archivo de progreso es el único traspaso. Lo que deje afuera, la siguiente vuelta no lo sabe. Dile al agente exactamente qué escribir ahí: qué está hecho, qué sigue, qué probó y falló.
- Haz que cada paso sea seguro de repetir. Una vuelta puede morir a la mitad, después del trabajo pero antes de la nota. La siguiente vuelta va a hacer esa porción otra vez, así que hacerla dos veces no debe romper nada.
- Mantén las porciones chicas. Una vuelta debería entrar en una ejecución corta. Si una sola porción ya necesita compactación, las porciones son demasiado grandes.