Niveau 10
Des tours tout neufs
- user
- assistant
- tool_result
EventBus
Le problème
Certains travaux ne tiennent pas en une exécution : migrer 300 fichiers, traduire tout un catalogue, réparer chaque test en échec d'un dépôt. Chaque étape ajoute un appel d'outil et un résultat à l'historique, et la boucle renvoie tout cela à chaque tour.
C'est Muddle, la session sans fin. L'après-midi, il traîne chaque étape depuis le matin : les requêtes sont énormes, les vieux résultats enterrent les nouveaux, et le modèle se met à refaire un travail déjà fait ou à sauter un travail qu'il avait seulement prévu. Rien ne plante. La qualité s'écoule, tout simplement.
La compaction (niveau 7) ralentit Muddle. Elle ne l'arrête pas : un travail assez long finit par résumer ses propres résumés.
La solution
Ne gardez pas un seul agent en vie pendant tout le travail. Exécutez-le en tours. À chaque tour, votre code lance un nouvel agent avec un historique vide et le même objectif. Il fait une tranche du travail, note où en sont les choses, et se termine. Ensuite, votre code vérifie le travail lui-même et, s'il n'est pas fini, lance le tour suivant.
-
Une seule longue session
Garder le même agent et le même historique pendant tout le travail.
Chaque tour renvoie tout depuis le début. Les requêtes s'alourdissent, le modèle a de plus en plus de mal à y trouver ce qui compte, et au-delà de la fenêtre, ça casse.
-
Compacter en route
La même session, mais en réduisant les vieux messages quand l'historique approche de la limite (niveau 7).
Ça fait gagner du temps, pas une solution. Chaque compaction perd des détails, et un travail assez long finit par compacter ses propres résumés.
-
Des tours tout neufs
Découper le travail en tours. Chaque tour est un nouvel agent avec un historique vide. Ce qu'il doit savoir, il le lit dans des fichiers.
Chaque tour démarre petit et propre. Le prix : chaque tour passe un ou deux échanges à se repérer, et les fichiers doivent dire tout ce qui compte.
L'astuce, c'est que rien d'important ne vit dans l'historique. Le travail est sur le disque (le pont), et une
courte note qui dit jusqu'où on est allé aussi (PROGRESS.md). Un nouvel agent n'a pas besoin de se
souvenir du tour précédent. Il lui suffit de lire.
On appelle souvent ce pattern la Ralph loop, d'après un one-liner shell qui donnait sans cesse le même prompt à un agent de code. Les agents de code s'en servent pour les longs refactorings, avec l'arbre git et un fichier TODO comme état.
Les personnages
Les mêmes personnages que d'habitude, cette fois dans un canyon.
- La trappe votre code
-
Elle lance un nouvel agent à chaque tour (
createIterationAgent) et récupère sa réponse. C'est la boucle autour de la boucle. - L'Astor d'un tour une exécution d'agent
- La boucle de l'agent du niveau 2, avec son propre bandonéon. Il commence vide et s'envole quand le tour se termine.
- Le pont le travail
- Ce que les outils ont modifié sur le disque. Aucun tour ne le jette.
- Le panneau PROGRESS.md
- Une courte note de chaque tour au suivant : ce qui est fait, ce qui vient ensuite.
- DONE? isDone
- Votre vérification, entre les tours. Elle mesure le pont, pas ce que le modèle en dit.
- LAP 3/5 maxIterations
- Le fusible. Si le travail ne passe jamais la vérification, la boucle s'arrête quand même.
Regardez les deux barres du haut. Ce tour, c'est ce que pèse vraiment chaque requête, et elle repart de zéro à chaque tour. 1 session, c'est ce que pèseraient les mêmes requêtes si un seul agent avait fait les trois tours : elle ne redescend jamais.
Le code
Avec astorlm : runGoalLoop prend une fabrique qui renvoie un nouvel agent, votre
vérification isDone et un fusible maxIterations. Les outils écrivent dans des fichiers,
donc chaque tour retrouve le travail là où le précédent l'a laissé.
À partir de zéro : La boucle du niveau 2, appelée dans un for. L'historique est une
variable locale de chaque appel, donc chaque tour démarre vide sans effort.
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}")
Points de vigilance
-
Vérifiez le travail, pas la réponse. Un « Fini ! » du modèle ne prouve rien.
isDonedoit regarder le résultat lui-même : lancer les tests, compter les lignes, mesurer le pont. Gardez-le peu coûteux et déterministe, parce qu'il s'exécute après chaque tour. -
Posez toujours le fusible. Une vérification qui ne peut jamais passer, ou un agent qui défait
sans cesse son propre travail, tourne en boucle jusqu'à ce que votre facture l'arrête.
maxIterations, et un œil sur la raison pour laquelle il s'est épuisé. - Le fichier de progression est le seul passage de relais. Ce qu'il omet, le tour suivant ne le sait pas. Dites à l'agent exactement quoi y écrire : ce qui est fait, ce qui suit, ce qu'il a essayé sans succès.
- Faites en sorte que chaque étape puisse être rejouée sans risque. Un tour peut mourir à mi-chemin, après le travail mais avant la note. Le tour suivant refera cette tranche, donc la faire deux fois ne doit rien casser.
- Gardez des tranches petites. Un tour devrait tenir dans une courte exécution. Si une seule tranche a déjà besoin de compaction, les tranches sont trop grosses.