Niveau 12
Planifier et réfléchir
- user
- assistant
- tool_result
EventBus
Le problème
Confiez à un agent un travail en plusieurs parties, et il commence par ce qui se trouve devant lui. À mi-parcours, les premières étapes sont loin dans l'historique, et il en oublie une. À la fin, il répond « fini ! » avec une assurance totale, parce que rien ne l'oblige à regarder.
C'est Scatterbrain. Deux échecs en un : pas de plan, donc des étapes se perdent ; pas de vérification, donc une étape qui a mal tourné est déclarée faite. Dans la rue Tango, l'outil a dit clairement que le journal était tombé dans les buissons. Le modèle l'a lu et a coché la case quand même.
La solution
D'abord, planifier. Avant la première vraie action, l'agent écrit le travail sous forme de liste de
tâches, et coche chacune au fur et à mesure. L'astuce qui fait marcher le tout : le plan complet est ajouté à chaque
requête, donc le modèle voit toujours ce qui est fait et ce qui reste, quelle que soit la longueur de
l'historique. Dans astorlm, c'est pattern: 'PLAN_EXECUTE' : deux outils, add_plan_item
et update_plan_item, et le plan dans le system prompt à chaque tour.
Réfléchir avant d'accepter. Une case cochée, ce n'est que ce que dit le modèle. Quand
run() rend la main, votre code vérifie le résultat avant de l'accepter. S'il manque quelque chose, le
constat repart sous forme de nouveau message dans la même session : l'agent garde son historique et son
plan, et ne corrige que ce qui ne va pas. Plafonnez le nombre de rounds. Il y a trois façons de vérifier, de la plus
solide à la plus faible :
-
Vérifier le monde
Votre code regarde le résultat lui-même : les perrons, les lignes de la base de données, la suite de tests, le fichier sur le disque.
Le meilleur vérificateur quand vous pouvez l'avoir : peu coûteux, exact, et impossible à baratiner. Il faut que le travail soit vérifiable par du code.
-
Un modèle critique
Un second appel lit la tâche, la réponse et une checklist, et liste ce qui ne va pas ou ce qui manque.
Pour un travail qu'aucun code ne peut vérifier : un résumé, un e-mail, un plan. Ça coûte un appel, ça peut aussi rater des choses, et il faut des critères concrets, pas « est-ce que c'est bien ? ».
-
Demander à l'agent
Le system prompt dit à l'agent de relire son travail avant de répondre.
Gratuit, et parfois suffisant. Mais c'est le même modèle qui se note lui-même, avec les mêmes angles morts : il a déjà coché le n° 14 une fois.
C'est la même idée qu'une évaluation du niveau 11, utilisée à l'exécution : une évaluation note des exécutions après coup pour améliorer l'agent ; une vérification note cette exécution-ci avant que l'utilisateur ne la voie.
Les personnages
Les mêmes personnages que d'habitude, cette fois sur une tournée de journaux.
- Le kiosque le modèle
- L'Oracle, derrière le comptoir. Il décide de chaque étape, et ne pédale jamais.
- La feuille de tournée le plan
- Les tâches et leurs cases. Elle s'illumine en doré à chaque tour : elle part avec chaque requête.
- Le vélo d'Astor la boucle
- Il porte chaque appel d'outil à l'aller et au retour, avec l'historique dans son bandonéon.
- Un lancer deliver
- Un outil ordinaire. Il dit où le journal a atterri.
- Le rédacteur en chef votre code
- Il confie le travail, et inspecte les perrons avant d'accepter la réponse. Sa loupe, c'est
review().
Le code
Avec astorlm : pattern: 'PLAN_EXECUTE' ajoute les outils du plan et met le plan dans
chaque requête ; getPlan() le relit. La vérification est du code ordinaire après run(),
et un second run() sur le même agent poursuit la même session.
À partir de zéro : Une liste, deux outils qui la modifient, et un system prompt reconstruit avec la
liste à chaque tour. L'historique vit en dehors de run(), donc la correction poursuit la même
conversation.
import { OpenAIProvider, createLocalAgent, tool } from 'astorlm'
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
const SUBSCRIBERS = [12, 14, 18]
const porches = new Set<number>() // the real world: which porches have a paper
const deliver = tool({
name: 'deliver',
description: 'Ride to a house and throw today’s paper onto its porch. Says where the paper landed.',
schema: z.object({ house: z.number() }),
execute: async ({ house }) => {
const landed = throwPaper(house) // your code: 'porch' or 'bushes'
if (landed === 'porch') porches.add(house)
return landed === 'porch' ? `Paper on the porch at #${house}.` : `Paper landed in the bushes at #${house}.`
},
})
// PLAN: the agent gets add_plan_item and update_plan_item,
// and the current plan is added to the system prompt on every turn.
const agent = await createLocalAgent({
provider: new OpenAIProvider({ ...LLM, model: 'your-model' }), // e.g. 'llama3.1', 'gpt-4o-mini'
pattern: 'PLAN_EXECUTE',
systemPrompt: 'You deliver newspapers. Plan every stop before you start, and tick each task as you go.',
tools: [deliver],
maxTurns: 20,
})
// REFLECT: check the work itself before accepting the answer. Deterministic when you can;
// a second model with a rubric when you can't.
const review = (): string[] => SUBSCRIBERS.filter((house) => !porches.has(house)).map((house) => `#${house} has no paper on the porch`)
let answer = await agent.run(`Deliver today’s paper to every subscriber on Tango Street: ${SUBSCRIBERS.join(', ')}.`)
for (let round = 1; round <= 2; round++) {
const problems = review()
if (problems.length === 0) break
// Same agent, same session: it keeps its history and its plan, and fixes what's missing.
answer = await agent.run(`Review found: ${problems.join('; ')}. Fix it.`)
}
console.log(agent.getPlan()) // [{ id: '1', description: 'Deliver to #12', status: 'completed' }, …]
console.log(answer.content)
// Plan and reflect, 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
}
const SUBSCRIBERS = [12, 14, 18]
const porches = new Set<number>() // the real world: which porches have a paper
// 1. The plan: a list the model writes and ticks with two tools.
type Task = { id: number; description: string; status: 'pending' | 'completed' }
const plan: Task[] = []
type ToolFn = (args: Record<string, string | number>) => string
const tools: Record<string, ToolFn> = {
add_plan_item: ({ description }) => {
plan.push({ id: plan.length + 1, description: String(description), status: 'pending' })
return `Task added with ID: ${plan.length}`
},
update_plan_item: ({ id, status }) => {
const task = plan.find((t) => t.id === Number(id))
if (!task) return `No task ${id}`
task.status = status === 'completed' ? 'completed' : 'pending'
return `Task ${id} status updated to ${task.status}.`
},
deliver: ({ house }) => {
const landed = throwPaper(Number(house)) // your code: 'porch' or 'bushes'
if (landed === 'porch') porches.add(Number(house))
return landed === 'porch' ? `Paper on the porch at #${house}.` : `Paper landed in the bushes at #${house}.`
},
}
const toolSchemas = [/* one JSON Schema per tool: add_plan_item(description), update_plan_item(id, status), deliver(house) */]
// 2. The plan goes into the system prompt on EVERY turn, so the model never loses track.
const system = (): string =>
'You deliver newspapers. Plan every stop with add_plan_item before you start, and tick each task as you go.\n' +
(plan.length ? plan.map((t) => `- [${t.status}] ${t.description} (id ${t.id})`).join('\n') : '(no plan yet)')
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 }
// The history lives outside run(), so a second run continues the same session.
const messages: Message[] = []
async function run(prompt: string, maxTurns = 20): Promise<string> {
messages.push({ 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: [{ role: 'system', content: system() }, ...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 fn = tools[call.function.name]
const output = fn ? fn(JSON.parse(call.function.arguments)) : `Unknown tool: ${call.function.name}`
messages.push({ role: 'tool', tool_call_id: call.id, content: output })
}
}
throw new Error(`No answer after ${maxTurns} turns`)
}
// 3. Reflect: check the work itself before accepting the answer, and send what's missing back.
const review = (): string[] => SUBSCRIBERS.filter((house) => !porches.has(house)).map((house) => `#${house} has no paper on the porch`)
let answer = await run(`Deliver today’s paper to every subscriber on Tango Street: ${SUBSCRIBERS.join(', ')}.`)
for (let round = 1; round <= 2; round++) {
const problems = review()
if (problems.length === 0) break
answer = await run(`Review found: ${problems.join('; ')}. Fix it.`)
}
console.log(answer)
# Plan and reflect, 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
}
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)
SUBSCRIBERS = [12, 14, 18]
PORCHES = set() # the real world: which porches have a paper
# 1. The plan: a list the model writes and ticks with two tools.
PLAN = []
def add_plan_item(description):
PLAN.append({"id": len(PLAN) + 1, "description": description, "status": "pending"})
return f"Task added with ID: {len(PLAN)}"
def update_plan_item(id, status):
task = next((t for t in PLAN if t["id"] == int(id)), None)
if task is None:
return f"No task {id}"
task["status"] = "completed" if status == "completed" else "pending"
return f"Task {id} status updated to {task['status']}."
def deliver(house):
landed = throw_paper(house) # your code: "porch" or "bushes"
if landed == "porch":
PORCHES.add(house)
return f"Paper on the porch at #{house}."
return f"Paper landed in the bushes at #{house}."
TOOLS = {"add_plan_item": add_plan_item, "update_plan_item": update_plan_item, "deliver": deliver}
TOOL_SCHEMAS = [...] # one JSON Schema per tool: add_plan_item(description), update_plan_item(id, status), deliver(house)
# 2. The plan goes into the system prompt on EVERY turn, so the model never loses track.
def system():
lines = [f"- [{t['status']}] {t['description']} (id {t['id']})" for t in PLAN] or ["(no plan yet)"]
return "You deliver newspapers. Plan every stop with add_plan_item before you start, and tick each task as you go.\n" + "\n".join(lines)
# The history lives outside run(), so a second run continues the same session.
MESSAGES = []
def run(prompt, max_turns=20):
MESSAGES.append({"role": "user", "content": prompt})
for _ in range(max_turns):
request = [{"role": "system", "content": system()}, *MESSAGES]
choice = post("/chat/completions", {"model": LLM["model"], "messages": request, "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", []):
output = TOOLS[call["function"]["name"]](**json.loads(call["function"]["arguments"]))
MESSAGES.append({"role": "tool", "tool_call_id": call["id"], "content": output})
raise RuntimeError(f"No answer after {max_turns} turns")
# 3. Reflect: check the work itself before accepting the answer, and send what's missing back.
def review():
return [f"#{house} has no paper on the porch" for house in SUBSCRIBERS if house not in PORCHES]
answer = run(f"Deliver today's paper to every subscriber on Tango Street: {', '.join(map(str, SUBSCRIBERS))}.")
for _ in range(2):
problems = review()
if not problems:
break
answer = run(f"Review found: {'; '.join(problems)}. Fix it.")
print(answer)
Points de vigilance
- Gardez des tâches petites et vérifiables. « Livrer au n° 14 » se vérifie ; « s'occuper de la rue », non. Une tâche que vous pouvez vérifier est une tâche que la vérification peut attraper.
- Laissez le plan évoluer. Les plans se heurtent à la réalité : une rue est fermée, un client annule. L'agent doit pouvoir ajouter, retirer ou réordonner des tâches, pas suivre une liste périmée.
- Vérifiez le monde, pas le plan. La feuille affichait trois coches. Vérifier la feuille aurait réussi. La vérification doit regarder le résultat lui-même.
- Plafonnez les rounds. Une vérification qui ne peut jamais passer, ou un agent incapable de corriger ce qu'il trouve, tourne en boucle indéfiniment. Deux ou trois rounds, puis passez la main à une personne (niveau 13).
- Ne planifiez pas une tâche d'une ligne. Planifier coûte des tours et des tokens. Pour une simple recherche, passez-vous-en. Ça vaut le coup quand le travail a plusieurs étapes faciles à perdre.