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

# Des tours tout neufs

Certains travaux sont trop longs pour une seule session. Exécutez-les plutôt en tours : un agent neuf à chaque tour, et la progression notée là où le suivant pourra la trouver.

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

**Avec astorlm**

```ts
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: '…' }
```

**TypeScript**

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

**Python**

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

## Patterns liés

- [4 · Quand s'arrêter](https://harnesspatterns.dev/fr/patterns/when-to-stop.md)
- [7 · Le sac à dos déborde](https://harnesspatterns.dev/fr/patterns/compaction.md)
- [9 · La mémoire](https://harnesspatterns.dev/fr/patterns/memory.md)
- [12 · Planifier et réfléchir](https://harnesspatterns.dev/fr/patterns/plan-and-reflect.md)
- [16 · Les agents proactifs](https://harnesspatterns.dev/fr/patterns/proactive-agents.md)
