Niveau 2
La boucle de l'agent
- user
- assistant
- tool_result
EventBus
Le problème
Une maman oiseau demande à un modèle « pouvez-vous nettoyer le fort des cochons ? ». Le modèle ne peut rien
lancer. Le mieux qu'il puisse faire, c'est répondre par une demande d'outil :
{ name: "launch_red", input: { angle: 40 } }.
Si votre code ne fait qu'un seul appel au modèle, la conversation s'arrête là. Vous vous retrouvez avec une demande que personne n'a exécutée et sans réponse. Si vous exécutez l'outil à la main, vous retombez sur le même problème au tour suivant, parce que le modèle peut avoir besoin d'un autre outil, puis d'un autre encore.
La solution
Une boucle avec une seule règle de sortie :
- Envoyez au modèle tout l'historique plus la liste des outils disponibles.
- Si la réponse se termine par
stopReason: "end_turn", renvoyez-la. C'est la seule sortie normale. -
Si elle se termine par
"tool_use", exécutez chaque outil demandé, ajoutez les résultats à l'historique sous forme de blocstool_resultet revenez à l'étape 1.
Le modèle décide quoi faire ; c'est la boucle qui le fait. Cette séparation est la base de tous les autres patterns : tout le reste (steering, compaction du contexte, sous-agents) se branche à un endroit de cette boucle.
Les personnages
La boucle racontée comme une petite quête. Une fois que vous connaissez les personnages, il n'y a plus rien à décoder.
- Astor la boucle
- Un petit danseur de tango, et le seul qui bouge. Il porte la question à l'Oracle, court au lance-pierre à chaque tir et rapporte la réponse à maman oiseau.
- L'Oracle Provider
- Le modèle. Il ne touche jamais au lance-pierre : il écoute seulement le bandonéon et rend une note. Orange s'il a besoin d'un outil, dorée s'il a fini.
- Le bandonéon messages[]
- L'historique, un pli coloré par message. Le soufflet grandit à chaque tour, et l'Oracle écoute chaque pli à chaque fois. Ce sont les notes qui montent vers l'Oracle.
- Le banc ToolRegistry
-
Un oiseau par outil :
launch_red,launch_bombet un troisième dont personne n'a besoin aujourd'hui. Astor lance celui que la note désigne et montre le résultat : vert si ça a marché. - Maman oiseau agent.run()
- Votre code. Elle pose la question et attend.
- Trajectoires, score et oiseaux historique, tokens, maxTurns
- Chaque tir laisse sa trajectoire dans le ciel, comme l'historique garde chaque résultat. Le score, ce sont les tokens, et il grimpe davantage à chaque tour parce que tout l'historique est renvoyé. Chaque tour coûte un oiseau de la rangée de la barre du haut, le budget de tours. Les chiffres sont illustratifs.
Le panneau EventBus montre les événements que la vraie boucle émet à chaque étape de l'animation.
Le code
Avec astorlm : La même boucle vit dans src/agent/loop.ts, avec le streaming, les
nouvelles tentatives, les hooks, l'exécution des outils en parallèle et l'annulation. Vue de l'extérieur, elle
ressemble à ceci.
À partir de zéro : Une quarantaine de lignes contre n'importe quel endpoint compatible OpenAI,
sans SDK : du fetch brut en TypeScript, la bibliothèque standard en Python. Les trois étapes
ci-dessus sont signalées dans les commentaires. Remplissez le bloc LLM du début avec votre propre
endpoint, votre modèle et votre clé.
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")
Remarquez qu'une erreur d'outil n'arrête pas la boucle : elle revient au modèle sous forme de texte, pour qu'il puisse se corriger au tour suivant.
Quand l'utiliser, et à quoi faire attention
Chaque fois que le modèle doit agir (lire, chercher, exécuter quelque chose) avant de pouvoir répondre. Si vous avez seulement besoin de transformer du texte, un seul appel suffit, et coûte moins cher.
- Le coût augmente à chaque tour. Surveillez le bandonéon et le compteur de tokens : l'Oracle écoute chaque pli à chaque tour, donc chaque tour renvoie tout ce qui précède. Avec assez de tours, le contexte se remplit.
-
Définissez toujours
maxTurns. Un modèle perdu peut continuer à demander des outils indéfiniment. Sans limite, la boucle tourne indéfiniment aussi.