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

# Les hooks

Les événements vous permettent d'observer la boucle. Les hooks vous permettent de la changer. Un hook est une fonction à vous que la boucle appelle à un point fixe, et quoi qu'elle renvoie, la boucle obéit.

## Le problème

Votre assistant de voyages fonctionne. Puis l'entreprise ajoute une règle : pas de billets en première classe sans l'accord d'un responsable. Et le service juridique en ajoute une autre : le numéro d'identité du passager ne doit jamais atteindre le modèle.

Aucune de ces règles ne concerne le modèle. On peut le lui dire, mais un prompt est une demande, pas un verrou. Les deux règles portent sur ce que fait la *boucle* : quels appels d'outils elle exécute, et ce qu'elle met dans l'historique. Si la boucle ne vous offre aucune entrée, la seule option restante est de copier son code et de le modifier. C'est Ironclad, la boucle scellée.

Les événements n'aident pas non plus. Un événement vous dit que `book_ticket` est sur le point de s'exécuter. Le temps que votre listener le reçoive, rien de ce que vous y faites ne peut l'arrêter.

## La solution

La boucle appelle vos fonctions à des points fixes de chaque tour, et utilise ce qu'elles renvoient. Cinq points couvrent presque tout :

- `beforeTurn`
   **Au début de chaque tour.**
   Vérifier un budget, journaliser le tour, arrêter une exécution qui dure depuis trop longtemps.
- `beforeProviderCall`
   **Juste avant que la requête parte vers le modèle.**
   Changer ce qui est envoyé : élaguer les vieux messages, ajouter la date du jour, cacher un outil pour ce tour.
- `beforeToolExecution`
   **Après que le modèle a demandé un outil, avant qu'il ne s'exécute.**
   Le laisser passer, le refuser (le modèle reçoit votre raison à la place) ou répondre avec un résultat tout prêt.
- `afterToolExecution`
   **Après l'exécution de l'outil, avant que le résultat n'entre dans l'historique.**
   Réécrire ce que le modèle va lire : masquer des données personnelles, raccourcir une sortie énorme.
- `afterTurn`
   **Une fois arrivés la réponse du modèle et les résultats d'outils.**
   Sauvegarder la progression, mettre à jour un tableau de bord, compter le coût.

La règle d'or : **les événements observent, les hooks changent.** Utilisez un événement quand vous voulez seulement savoir ce qui s'est passé. Utilisez un hook quand vous devez décider de ce qui se passe.

## Les personnages

Les mêmes personnages que d'habitude, cette fois sur un train miniature.

- **Le circuit** (la boucle): Un anneau de rails fermé qui ne tourne que dans un sens. Chaque tour de piste est un tour : devant l'Oracle, devant les outils, et on recommence.
- **Astor** (celui qui parcourt la boucle): Il pompe la draisine sur le circuit, avec le bandonéon des messages sur le dos.
- **Les cabines** (hooks): Une par point de hook. Une cabine vide ne fait rien. Une cabine tenue arrête la draisine, vérifie ce qu'elle transporte, et peut baisser sa barrière ou tamponner le chargement. Cette exécution en tient deux : `beforeToolExecution` et `afterToolExecution`.
- **Les tribunes** (EventBus): Trois spectateurs qui notent tout ce qui passe. Ils voient tout, et ne peuvent toucher à rien.
- **Les quais** (outils): `find_trains` et `book_ticket`, dans la courbe du fond.

Dans le panneau EventBus, les lignes `hook` et `code` signalent vos propres fonctions en train de s'exécuter : vos hooks et vos outils. astorlm n'émet pas d'événements pour elles. Remarquez où elles tombent : `tool_execution_end` arrive après `afterToolExecution`, donc il porte déjà le texte tamponné.

## Le code

**Avec astorlm :** Passez un objet `hooks` à l'agent. `beforeToolExecution` renvoie `{ authorize: false }` pour refuser un appel, et `afterToolExecution` renvoie le texte que lira le modèle.

**À partir de zéro :** La boucle du niveau 2, avec un appel à chacun des cinq hooks. Un hook que personne n'a défini est simplement ignoré.

**Avec astorlm**

```ts
import { OpenAIProvider, createLocalAgent, tool } from 'astorlm'
import { z } from 'zod'

const findTrains = tool({
  name: 'find_trains',
  description: 'List the trains to a destination on a date, with the fare for each class.',
  schema: z.object({ to: z.string(), date: z.string() }),
  execute: async ({ to, date }) => searchTimetable(to, date), // your code
})

const bookTicket = tool({
  name: 'book_ticket',
  description: 'Book one seat on a train for the employee who is asking.',
  schema: z.object({ train: z.number().int(), seat_class: z.enum(['first', 'tourist']) }),
  execute: async ({ train, seat_class }) => reserveSeat(train, seat_class), // 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: [findTrains, bookTicket],
  maxTurns: 10,
  hooks: {
    // The first booth: runs before every tool call, and decides whether it runs at all.
    beforeToolExecution: async ({ toolName, input }) => {
      const { seat_class } = input as { seat_class?: string }
      if (toolName === 'book_ticket' && seat_class === 'first') {
        // The tool never runs. The model reads this text as an error result instead.
        return { authorize: false, mockResult: 'Blocked by policy: first class needs a manager’s approval. Book tourist instead.' }
      }
      return { authorize: true }
    },
    // The second booth: runs after every tool call. What you return is what the model reads.
    afterToolExecution: async ({ output }) => output.replace(/DNI [\d.]+/g, 'DNI ***'),
  },
})

// Events only watch. By the time this fires, the hook has already stamped over the DNI.
agent.on('tool-end', ({ name, output, isError }) => console.log(name, isError ? 'refused:' : 'ok:', output))

const last = await agent.run('Book me the most comfortable seat to Mar del Plata on Friday.')
console.log(last.content)
```

**TypeScript**

```ts
// The agent loop with hooks, 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 Args = Record<string, string | number>
type ToolFn = (args: Args) => Promise<string>
const tools: Record<string, ToolFn> = { find_trains: findTrains, book_ticket: bookTicket }
const toolSchemas = [/* one JSON Schema per tool */]

// The five points where the loop lets your code in. Every one is optional.
type Hooks = {
  beforeTurn?: (turn: number, messages: Message[]) => Promise<void>
  // Return the messages to send: trim them, add context, or pass them through.
  beforeProviderCall?: (messages: Message[]) => Promise<Message[]>
  // Say no, and the tool never runs: `result` goes back to the model instead.
  beforeToolExecution?: (name: string, args: Args) => Promise<{ authorize: boolean; result?: string }>
  // Whatever you return is what the model reads.
  afterToolExecution?: (name: string, output: string) => Promise<string>
  afterTurn?: (turn: number, reply: Message) => Promise<void>
}

export async function runAgent(prompt: string, hooks: Hooks = {}, maxTurns = 10): Promise<string> {
  const messages: Message[] = [{ role: 'user', content: prompt }]

  for (let turn = 1; turn <= maxTurns; turn++) {
    await hooks.beforeTurn?.(turn, messages)
    const outgoing = (await hooks.beforeProviderCall?.(messages)) ?? messages

    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: outgoing, tools: toolSchemas }),
    })
    const [choice] = (await res.json()).choices
    const reply: Message = choice.message
    messages.push(reply)

    if (choice.finish_reason !== 'tool_calls') {
      await hooks.afterTurn?.(turn, reply)
      return reply.content ?? ''
    }

    for (const call of reply.tool_calls ?? []) {
      const name = call.function.name
      const run = tools[name]
      let output = `Unknown tool: ${name}`
      try {
        const args: Args = JSON.parse(call.function.arguments)
        // Booth 1: before the tool runs.
        const gate = (await hooks.beforeToolExecution?.(name, args)) ?? { authorize: true }
        if (!gate.authorize) output = gate.result ?? 'Rejected by policy.'
        else if (run) output = await run(args)
      } catch (err) {
        output = `Error: ${err instanceof Error ? err.message : err}`
      }
      // Booth 2: before the result joins the history.
      output = (await hooks.afterToolExecution?.(name, output)) ?? output
      messages.push({ role: 'tool', tool_call_id: call.id, content: output })
    }
    await hooks.afterTurn?.(turn, reply)
  }
  throw new Error(`No answer after ${maxTurns} turns`)
}

// The two booths from the animation.
const answer = await runAgent('Book me the most comfortable seat to Mar del Plata on Friday.', {
  beforeToolExecution: async (name, args) =>
    name === 'book_ticket' && args.seat_class === 'first'
      ? { authorize: false, result: 'Blocked by policy: first class needs a manager’s approval. Book tourist instead.' }
      : { authorize: true },
  afterToolExecution: async (_name, output) => output.replace(/DNI [\d.]+/g, 'DNI ***'),
})
```

**Python**

```python
# The agent loop with hooks, from scratch. Standard library only, no SDK.
import json
import re
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 = {"find_trains": find_trains, "book_ticket": book_ticket}
TOOL_SCHEMAS = [...]  # one JSON Schema per tool

# The five points where the loop lets your code in. Every one is optional:
#   before_turn(turn, messages)
#   before_provider_call(messages) -> the messages to send
#   before_tool_execution(name, args) -> {"authorize": bool, "result": str}
#   after_tool_execution(name, output) -> the text the model will read
#   after_turn(turn, reply)

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, hooks=None, max_turns=10):
    hooks = hooks or {}

    def call_hook(point, *args):
        return hooks[point](*args) if point in hooks else None

    messages = [{"role": "user", "content": prompt}]

    for turn in range(1, max_turns + 1):
        call_hook("before_turn", turn, messages)
        outgoing = call_hook("before_provider_call", messages) or messages

        choice = chat(outgoing)
        reply = choice["message"]
        messages.append(reply)

        if choice["finish_reason"] != "tool_calls":
            call_hook("after_turn", turn, reply)
            return reply.get("content") or ""

        for call in reply.get("tool_calls", []):
            name = call["function"]["name"]
            run = TOOLS.get(name)
            try:
                args = json.loads(call["function"]["arguments"])
                # Booth 1: before the tool runs. Say no, and it never does.
                gate = call_hook("before_tool_execution", name, args) or {"authorize": True}
                if not gate["authorize"]:
                    output = gate.get("result", "Rejected by policy.")
                else:
                    output = run(**args) if run else f"Unknown tool: {name}"
            except Exception as err:
                output = f"Error: {err}"
            # Booth 2: before the result joins the history. What it returns is what the model reads.
            output = call_hook("after_tool_execution", name, output) or output
            messages.append({"role": "tool", "tool_call_id": call["id"], "content": output})

        call_hook("after_turn", turn, reply)

    raise RuntimeError(f"No answer after {max_turns} turns")

# The two booths from the animation.
def check_policy(name, args):
    if name == "book_ticket" and args.get("seat_class") == "first":
        return {"authorize": False, "result": "Blocked by policy: first class needs a manager's approval. Book tourist instead."}
    return {"authorize": True}

def hide_ids(name, output):
    return re.sub(r"DNI [\d.]+", "DNI ***", output)

answer = run_agent(
    "Book me the most comfortable seat to Mar del Plata on Friday.",
    hooks={"before_tool_execution": check_policy, "after_tool_execution": hide_ids},
)
```

## Points de vigilance

- **Dites pourquoi quand vous refusez.** Le refus revient au modèle comme un résultat en erreur. « Bloqué par la politique : réservez en touriste à la place » vous vaut un billet touriste. Un simple « refusé » vous vaut le même appel une fois de plus.
- **Les hooks s'exécutent à chaque appel, alors gardez-les rapides.** Un hook qui interroge une base de données ajoute ce délai à chaque outil et à chaque tour.
- **Un hook qui lève une exception fait tomber l'exécution.** La boucle attrape les erreurs de vos outils, pas celles de vos hooks. Enveloppez tout ce qui peut échouer.
- **N'utilisez pas un hook pour observer.** Si vous ne faites que journaliser, écoutez les événements. Gardez les hooks pour les moments où vous devez changer quelque chose.

## Patterns liés

- [2 · La boucle de l'agent](https://harnesspatterns.dev/fr/patterns/agent-loop.md)
- [5 · Les erreurs dans la boucle](https://harnesspatterns.dev/fr/patterns/errors-in-the-loop.md)
- [7 · Le sac à dos déborde](https://harnesspatterns.dev/fr/patterns/compaction.md)
- [13 · L'humain dans la boucle](https://harnesspatterns.dev/fr/patterns/human-in-the-loop.md)
- [14 · Sécurité et sandboxing](https://harnesspatterns.dev/fr/patterns/security.md)
