> Niveau 12 de Agent Harness Patterns, un parcours de patterns sur le fonctionnement des agents d'IA. Version web : https://harnesspatterns.dev/fr/patterns/plan-and-reflect · Tous les patterns (en anglais) : https://harnesspatterns.dev/llms.txt

# Planifier et réfléchir

Écrivez le plan avant de toucher à quoi que ce soit, et vérifiez le travail avant d'accepter la réponse. Le plan garde l'agent sur les rails ; la vérification attrape ce qu'il dit avoir fait sans l'avoir fait.

## 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.

**Avec astorlm**

```ts
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)
```

**TypeScript**

```ts
// 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)
```

**Python**

```python
# 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.

## Patterns liés

- [2 · La boucle de l'agent](https://harnesspatterns.dev/fr/patterns/agent-loop.md)
- [11 · Observabilité et évaluations](https://harnesspatterns.dev/fr/patterns/observability.md)
- [10 · Des tours tout neufs](https://harnesspatterns.dev/fr/patterns/fresh-laps.md)
- [13 · L'humain dans la boucle](https://harnesspatterns.dev/fr/patterns/human-in-the-loop.md)
- [15 · Les sous-agents](https://harnesspatterns.dev/fr/patterns/subagents.md)
