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

# Les agents proactifs

Tous les agents jusqu'ici attendaient que quelqu'un tape. Un agent proactif se réveille avec un minuteur, regarde autour de lui, et ne prend la parole que quand il y a quelque chose qui vaut la peine d'être dit.

## Le problème

Certains travaux n'ont aucun moment où une personne penserait à demander : un animal qui a faim pendant que sa propriétaire est à l'école, une commande qui reste bloquée, un serveur qui se met à flancher la nuit. Un agent qui ne répond que quand on lui parle ne sert à rien dans ces cas-là.

La solution évidente, c'est un minuteur qui lance l'agent de temps en temps. Fait sans soin, c'est le Coucou Détraqué : chaque tick réveille le modèle, chaque exécution coûte des tokens, et chaque exécution vous envoie « tout va bien ». Au troisième message, vous arrêtez de les lire, et celui qui comptait reste sans lecteur.

## La solution

Un heartbeat : un minuteur qui donne à l'agent un prompt fixe, le `checkPrompt`, comme si quelqu'un l'avait tapé. Ce qui le rend utile plutôt que bruyant, c'est ce que vous mettez autour de ce minuteur :

- **Vérifier avant de réveiller**
   localCondition
   Du code ordinaire qui s'exécute à chaque tick, avant le modèle : lire une jauge, un fichier, une ligne. Tant qu'il dit non, le tick ne coûte rien.
- **Une exécution à la fois**
   intégré
   Un tick qui se déclenche pendant que l'exécution précédente tourne encore est abandonné, pas mis en file. Deux exécutions ne partagent jamais l'historique en même temps.
- **Des fusibles**
   maxTicks, timeoutMs, runTimeoutMs
   Un budget de ticks, de temps réel et de temps par exécution, pour qu'il s'arrête même si vous oubliez de le couper.
- **Parler une fois, et s’éteindre**
   stopHeartbeat()
   N'envoyer un message à la personne que quand quelque chose s'est passé, avec la façon dont ça s'est terminé. Quand le travail est fini, couper le heartbeat.

Le premier fait l'essentiel du travail. La plupart des ticks ne trouvent rien à faire, et décider cela ne demande pas de modèle : ça demande un `if`. Dans l'animation, cinq ticks passent et un seul d'entre eux appelle le modèle.

## Les personnages

Les mêmes personnages que d'habitude, dans un animal de poche.

- **L'horloge** (le heartbeat): Sa cloche sonne toutes les deux heures, et reste allumée tant que le heartbeat est actif.
- **Le crochet** (localCondition): Il clignote autour des cœurs à chaque tick : votre propre code qui lit les jauges. Aucun modèle là-dedans.
- **Astor** (la boucle): Il fait la sieste sur son tapis jusqu'à ce qu'un tick dise qu'une jauge est basse, puis fait tourner la boucle comme d'habitude.
- **L'Oracle** (le modèle): Endormi jusqu'à ce qu'Astor lui apporte le checkPrompt.
- **Les icônes** (les outils): Statut, nourriture, jeu et la lumière d'appel : `check_status`, `feed`, `play` et `beep_owner`.
- **La propriétaire** (la personne): À l'école toute la journée. Elle reçoit un seul bip, et c'est déjà une bonne nouvelle.

Dans le panneau EventBus, les ticks calmes ne sont que votre code : astorlm n'émet rien pour un tick que votre vérification a refusé. `heartbeat_tick` n'apparaît qu'une fois, quand le modèle est vraiment réveillé.

## Le code

**Avec astorlm :** passez `heartbeat` à l'agent et il démarre tout seul. Les protections sont des options : `localCondition`, `maxTicks` et `timeoutMs` ; les ticks qui se chevauchent sont abandonnés pour vous. Votre app appelle `stopHeartbeat()` quand la propriétaire revient.

**À partir de zéro :** un minuteur autour de la boucle du niveau 2. Un drapeau empêche les exécutions de se chevaucher, un compteur et une échéance servent de fusibles, et une simple fonction décide si le modèle est appelé ou non.

**Avec astorlm**

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

const HOUR = 60 * 60_000

const checkStatus = tool({
  name: 'check_status',
  description: 'Open the status screen: hunger and happiness in hearts, and whether the pet is sick or asleep.',
  schema: z.object({}),
  execute: async () => pet.status(), // your code: the pet lives in your app
})

const feed = tool({
  name: 'feed',
  description: 'Feed the pet a meal or a snack. A meal fills hunger; a snack only cheers it up.',
  schema: z.object({ food: z.enum(['meal', 'snack']) }),
  execute: async ({ food }) => pet.feed(food),
})

const play = tool({
  name: 'play',
  description: 'Play the left-or-right game with the pet. Winning fills happiness.',
  schema: z.object({}),
  execute: async () => pet.play(),
})

const beepOwner = tool({
  name: 'beep_owner',
  description: 'Beep the owner with a short message. They are at school: only when something happened.',
  schema: z.object({ text: z.string() }),
  execute: async ({ text }) => {
    await sendPush(text) // your code
    return 'Beeped.'
  },
})

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: [checkStatus, feed, play, beepOwner],
  maxTurns: 8,
  // Starts on its own as soon as the agent is created. Nobody types anything.
  heartbeat: {
    intervalMs: 2 * HOUR,
    checkPrompt: 'Check on Milonga. Take care of whatever is low, then beep her owner with one line.',
    // Runs on every tick, before the model. While it says no, a tick costs 0 tokens.
    localCondition: () => pet.hunger <= 1 || pet.happy <= 1,
    maxTicks: 6, // the fuses: a school day of ticks at most…
    timeoutMs: 10 * HOUR, // …and of wall-clock time
  },
})

// The owner is home: the app takes over and switches the heartbeat off.
onOwnerHome(() => agent.stopHeartbeat())

// Quiet ticks emit nothing. The ones that wake the model do:
agent.on('event', (event) => {
  if (event.type === 'heartbeat_tick') console.log('heartbeat woke the agent')
})
```

**TypeScript**

```ts
// A heartbeat, from scratch. Plain fetch and timers, 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 HOUR = 60 * 60_000

// 1. The tools, as in level 3. The pet lives in your app.
type ToolFn = (args: Record<string, string | number>) => Promise<string>
const tools: Record<string, ToolFn> = {
  check_status: async () => pet.status(),
  feed: async ({ food }) => pet.feed(String(food)),
  play: async () => pet.play(),
  beep_owner: async ({ text }) => {
    await sendPush(String(text)) // your code
    return 'Beeped.'
  },
}
const toolSchemas = [/* one JSON Schema per tool: check_status(), feed(food), play(), beep_owner(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. Each tick that gets through is one run of it.
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 = 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`)
}

// 3. The heartbeat: a timer, a cheap check before the model, and fuses.
const CHECK_PROMPT = 'Check on Milonga. Take care of whatever is low, then beep her owner with one line.'
const MAX_TICKS = 6

// Plain code, no model: while it says no, a tick costs 0 tokens.
const localCondition = (): boolean => pet.hunger <= 1 || pet.happy <= 1

let running = false
let ticks = 0

async function tick(): Promise<void> {
  if (running) return // still busy with the last tick: skip this one, never overlap
  if (++ticks > MAX_TICKS) return stop() // fuse: a budget of ticks
  if (!localCondition()) return // nothing low: let the model sleep

  running = true
  try {
    console.log(await runAgent(CHECK_PROMPT))
  } catch (err) {
    console.error('heartbeat run failed:', err) // log it, and let the next tick try again
  } finally {
    running = false
  }
}

const timer = setInterval(tick, 2 * HOUR)
const deadline = setTimeout(stop, 10 * HOUR) // fuse: wall-clock time

function stop(): void {
  clearInterval(timer)
  clearTimeout(deadline)
}

// The owner is home: the app takes over.
onOwnerHome(stop)
```

**Python**

```python
# A heartbeat, from scratch. Standard library only, no SDK.
import json
import threading
import time
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
}

HOUR = 60 * 60

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 tools, as in level 3. The pet lives in your app.
def beep_owner(text):
    send_push(text)  # your code
    return "Beeped."

TOOLS = {
    "check_status": lambda: pet.status(),
    "feed": lambda food: pet.feed(food),
    "play": lambda: pet.play(),
    "beep_owner": beep_owner,
}
TOOL_SCHEMAS = [...]  # one JSON Schema per tool: check_status(), feed(food), play(), beep_owner(text)

# 2. The loop from level 2, unchanged. Each tick that gets through is one run of it.
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 heartbeat: a timer, a cheap check before the model, and fuses.
CHECK_PROMPT = "Check on Milonga. Take care of whatever is low, then beep her owner with one line."
INTERVAL = 2 * HOUR
MAX_TICKS = 6
DEADLINE = time.monotonic() + 10 * HOUR  # fuse: wall-clock time
stopped = threading.Event()
on_owner_home(stopped.set)  # the owner is home: the app takes over

def local_condition():
    """Plain code, no model: while it says no, a tick costs 0 tokens."""
    return pet.hunger <= 1 or pet.happy <= 1

# One thread, one run at a time: a tick can never overlap the last one.
ticks = 0
while not stopped.wait(INTERVAL):
    ticks += 1
    if ticks > MAX_TICKS or time.monotonic() > DEADLINE:
        break  # the fuses
    if not local_condition():
        continue  # nothing low: let the model sleep
    try:
        print(run_agent(CHECK_PROMPT))
    except Exception as err:
        print("heartbeat run failed:", err)  # log it, and let the next tick try again
```

## Points de vigilance

- **Le heartbeat d'astorlm garde une seule session.** Chaque tick qui réveille le modèle s'ajoute au même historique, donc un heartbeat qui le réveille souvent fait grossir son contexte, et sa facture, à chaque exécution. Gardez le checkPrompt court, ajoutez de la compaction, ou démarrez chaque exécution sur un agent neuf (c'est ce que font les versions à partir de zéro ci-dessus).
- **Attention au timeout par exécution.** `runTimeoutMs` vaut 60 secondes par défaut. Une exécution dont les outils attendent quelque chose de lent a besoin de plus, sinon elle sera interrompue à mi-chemin.
- **Un heartbeat n'est pas un cron.** Il vit dans votre processus : si le processus s'arrête, le heartbeat aussi, et les ticks manqués sont perdus. Pour les travaux qui doivent survivre aux redémarrages, laissez un vrai ordonnanceur lancer l'agent, et gardez les mêmes protections.
- **Décidez de ce qui mérite un message avant d'écrire le prompt.** « Préviens-moi si tu as dû faire quelque chose », pas « dis-moi comment ça se passe ». Un message qui ne dit rien apprend à la personne à ignorer le suivant.
- **Agir seul demande aussi des limites.** Personne ne regarde. Nourrir l'animal, ça va ; tout ce qui ne peut pas être défait devrait attendre le oui d'une personne.

## 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)
- [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)
- [11 · Observabilité et évaluations](https://harnesspatterns.dev/fr/patterns/observability.md)
