Niveau 16
Les agents proactifs
- user
- assistant
- tool_result
EventBus
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,playetbeep_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.
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')
})
// 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)
# 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.
runTimeoutMsvaut 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.