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

# La boucle de l'agent

À lui seul, un modèle ne peut rien faire : il ne fait que générer du texte. La boucle de l'agent est ce qui le transforme en agent. Elle donne l'historique au modèle, exécute les outils que le modèle demande, lui rend les résultats, et recommence jusqu'à ce que le modèle dise « terminé ».

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

1. Envoyez au modèle **tout l'historique** plus la liste des outils disponibles.
2. Si la réponse se termine par `stopReason: "end_turn"`, renvoyez-la. C'est la seule sortie normale.
3. Si elle se termine par `"tool_use"`, exécutez chaque outil demandé, ajoutez les résultats à l'historique sous forme de blocs `tool_result` et 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_bomb` et 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é.

**Avec astorlm**

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

**TypeScript**

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

**Python**

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

> Échec réel
>
> Pendant une exécution sur plusieurs tours, le proxy a basculé (failover) vers un modèle gratuit plus faible. Le modèle est tombé dans une boucle de raisonnement absurde et n'a jamais renvoyé `end_turn`. C'est un timeout de 60 secondes qui l'a arrêté, pas la boucle. `maxTurns` vous protège d'un trop grand nombre de tours, mais pas d'un tour qui ne finit jamais : pour cela, il vous faut un timeout ou un watchdog (quelque chose qui coupe l'exécution quand elle ne progresse plus).

## Patterns liés

- [4 · Quand s'arrêter](https://harnesspatterns.dev/fr/patterns/when-to-stop.md)
- [5 · Les erreurs dans la boucle](https://harnesspatterns.dev/fr/patterns/errors-in-the-loop.md)
- [6 · Les hooks](https://harnesspatterns.dev/fr/patterns/hooks.md)
- [10 · Des tours tout neufs](https://harnesspatterns.dev/fr/patterns/fresh-laps.md)
