Le problĂšme
Ton premier agent va essayer de tout faire.
Il va naviguer sur le web, Ă©crire dans des bases, âoptimiserâ des configs⊠bref, se comporter comme un stagiaire avec un accĂšs root.
Ne commence pas comme ça.
Commence avec un seul tool, read-only, avec des budgets et des logs.
Pourquoi ça compte dans la vraie vie
La premiÚre fois que tu shippes du tool calling, tu vas découvrir :
- des timeouts manquants
- une idempotence inexistante
- des logs âprĂ©sentsâ, mais inutiles
Autant le dĂ©couvrir avec un tool plutĂŽt quâavec douze.
Le playbook (20 minutes, sans héroïsme)
- Choisis un seul tool read-only (
web.searchoukb.search). - Ajoute des budgets : steps + time (+ $ si tu peux).
- Logge chaque tool call (nom, hash des args, durée, statut).
- Détecte les boucles via les args identiques.
- Nâajoute pas encore de tools âsendâ ou âwriteâ.
Ce que âun seul toolâ veut vraiment dire
âUn seul toolâ, ce nâest pas âun tool + une porte dĂ©robĂ©eâ.
Ăa veut dire :
- tu peux pointer lâallowlist et la compter sur une main
- pas de tool générique
http.requestavec internet complet - pas de
run_shellâpour dĂ©bugâ
Si ton premier agent peut fetch nâimporte quelle URL, tu as dĂ©jĂ sautĂ© la phase âsafeâ.
Ătape 0 : choisis une tĂąche qui ne va pas te ruiner
Bonnes premiĂšres tĂąches :
- âtrouve 5 docs pertinents dans notre KBâ
- ârĂ©sume les incidents rĂ©cents depuis le dossier de postmortemsâ
- ârĂ©dige une rĂ©ponse en utilisant des templates connusâ
Mauvaises premiĂšres tĂąches :
- âbrowse le web jusquâĂ ĂȘtre confiantâ
- âcorrige la config prodâ
- âexĂ©cute des commandes sur des serveursâ
Ton premier agent doit ĂȘtre boring et pas cher.
Ătape 1 : budgets (ce qui transforme une boucle en systĂšme)
Les budgets ne sont pas un ânice to haveâ. Câest la diffĂ©rence entre :
- une requĂȘte qui sâarrĂȘte
- une requĂȘte qui continue de te faire payer
On met au minimum :
- max steps
- max seconds
Si tu peux estimer le coĂ»t en $, ajoute-le tĂŽt. Ăa se rentabilise.
Ătape 2 : logge la trace dâactions
Tu nâas pas besoin dâun systĂšme de tracing parfait le premier jour. Tu as besoin dâassez pour rĂ©pondre :
- quels tools a-t-il appelés ?
- avec quels arguments ?
- combien de temps chaque call a pris ?
- pourquoi il sâest arrĂȘtĂ© ?
Si tu ne peux pas répondre à ça, tu ne peux pas le shipper.
Ătape 2.5 : rends les tools dĂ©terministes (sinon tu ne peux pas dĂ©bug)
Un agent, câest une boucle. Les boucles sont dĂ©jĂ assez pĂ©nibles. Ajoute des tools non dĂ©terministes (timeouts, APIs flaky, pages qui changent) et tu as gagnĂ©. Bravo : tu as construit un systĂšme non dĂ©terministe et tu vas le dĂ©bugger Ă 03:00.
Le truc quâon utilise tĂŽt : record/replay. Pour un run, enregistre :
- le nom du tool
- le hash des args
- la rĂ©ponse (ou lâerreur)
Ensuite tu peux rejouer le mĂȘme âmondeâ pour tester :
- changements de prompt
- changements de modĂšle
- réglages du loop guard
Code conceptuel minimal :
class TapeTools(SafeTools):
def __init__(self, allow: set[str], impl: dict, tape: list[dict] | None = None):
super().__init__(allow=allow, impl=impl)
self.tape = tape if tape is not None else []
def call(self, name: str, *, args: dict[str, Any], request_id: str) -> Any:
try:
out = super().call(name, args=args, request_id=request_id)
self.tape.append({"tool": name, "args": args, "ok": True, "out": out})
return out
except Exception as e:
self.tape.append({"tool": name, "args": args, "ok": False, "err": str(e)})
raise
def replay(tape: list[dict]):
# Replace real tools with deterministic playback.
i = 0
def impl(name: str, **args):
nonlocal i
item = tape[i]
i += 1
assert item["tool"] == name
if not item["ok"]:
raise RuntimeError(item["err"])
return item["out"]
return implexport class TapeTools extends SafeTools {
constructor({ allow, impl, tape }) {
super({ allow, impl });
this.tape = tape || [];
}
async call(name, { args, requestId }) {
try {
const out = await super.call(name, { args, requestId });
this.tape.push({ tool: name, args, ok: true, out });
return out;
} catch (e) {
this.tape.push({
tool: name,
args,
ok: false,
err: String(e && e.message ? e.message : e),
});
throw e;
}
}
}
export function replay(tape) {
let i = 0;
return async function call(name, args) {
const item = tape[i];
i += 1;
if (!item || item.tool !== name) throw new Error("tape mismatch");
if (!item.ok) throw new Error(item.err);
return item.out;
};
}Câest glamour ? Non. Est-ce que ça te permet de lancer une âgolden suiteâ de 20 tĂąches avant de shipper ? Oui. Câest comme ça que tu Ă©vites dâapprendre les rĂ©gressions via de vrais clients.
Code (minimal, mais âprodâshapedâ)
from dataclasses import dataclass
import time
from typing import Any
@dataclass(frozen=True)
class Budget:
max_steps: int = 12
max_seconds: int = 20
class Tools:
def __init__(self):
self.allow = {"web.search"}
def call(self, name: str, *, args: dict[str, Any]) -> Any:
if name not in self.allow:
raise RuntimeError(f"tool not allowed: {name}")
return tool_impl(name, args=args) # (pseudo)
def first_agent(question: str, *, tools: Tools, budget: Budget) -> str:
started = time.time()
last = None
for step in range(budget.max_steps):
if time.time() - started > budget.max_seconds:
return "stopped: time budget exceeded"
action = llm_pick_action(question) # (pseudo): either {"tool":..., "args":...} or {"finish":...}
if action.get("finish"):
return action["finish"]
key = f"{action['tool']}:{action['args']}"
if key == last:
return "stopped: loop guard (same call twice)"
last = key
obs = tools.call(action["tool"], args=action["args"])
question = update_state(question, action, obs) # (pseudo)
return "stopped: step budget exceeded"export class Tools {
constructor() {
this.allow = new Set(["web.search"]);
}
async call(name, { args }) {
if (!this.allow.has(name)) throw new Error("tool not allowed: " + name);
return toolImpl(name, { args }); // (pseudo)
}
}
export async function firstAgent(question, { tools, budget }) {
const started = Date.now();
let last = null;
for (let step = 0; step < budget.max_steps; step++) {
if ((Date.now() - started) / 1000 > budget.max_seconds) {
return "stopped: time budget exceeded";
}
const action = await llmPickAction(question); // (pseudo): { tool, args } or { finish }
if (action.finish) return action.finish;
const key = String(action.tool) + ":" + JSON.stringify(action.args);
if (key === last) return "stopped: loop guard (same call twice)";
last = key;
const obs = await tools.call(action.tool, { args: action.args });
question = updateState(question, action, obs); // (pseudo)
}
return "stopped: step budget exceeded";
}Rends ça réel (un petit skeleton exécutable)
Lâexemple minimal au-dessus est volontairement court. Voici un skeleton âsingle fileâ un peu plus complet, qui ressemble Ă quelque chose que tu peux vraiment mettre derriĂšre une API :
from dataclasses import dataclass
import hashlib
import json
import time
import uuid
from typing import Any, Callable
@dataclass(frozen=True)
class Budget:
max_steps: int = 12
max_seconds: int = 20
def args_hash(args: dict[str, Any]) -> str:
raw = json.dumps(args, sort_keys=True, default=str).encode("utf-8")
return hashlib.sha256(raw).hexdigest()[:12]
class LoopGuard(RuntimeError):
pass
class Monitor:
def __init__(self, *, max_repeat: int = 2):
self.max_repeat = max_repeat
self.counts: dict[str, int] = {}
def mark(self, tool: str, args: dict[str, Any]) -> None:
key = f"{tool}:{args_hash(args)}"
self.counts[key] = self.counts.get(key, 0) + 1
if self.counts[key] >= self.max_repeat:
raise LoopGuard(f"loop guard: {key} repeated {self.counts[key]}x")
class SafeTools:
def __init__(self, allow: set[str], impl: dict[str, Callable[..., Any]]):
self.allow = allow
self.impl = impl
def call(self, name: str, *, args: dict[str, Any], request_id: str) -> Any:
if name not in self.allow:
raise RuntimeError(f"[{request_id}] tool not allowed: {name}")
started = time.time()
try:
return self.impl[name](**args)
finally:
ms = int((time.time() - started) * 1000)
print(f"[{request_id}] tool={name} ms={ms} args_hash={args_hash(args)}")
def run_first_agent(question: str, *, tools: SafeTools, budget: Budget) -> str:
request_id = uuid.uuid4().hex
started = time.time()
monitor = Monitor(max_repeat=2)
state = {"question": question, "notes": []}
for step in range(budget.max_steps):
if time.time() - started > budget.max_seconds:
return f"[{request_id}] stopped: time budget"
action = llm_pick_action(state) # (pseudo)
if action.get("finish"):
return action["finish"]
tool = action["tool"]
args = action["args"]
monitor.mark(tool, args)
obs = tools.call(tool, args=args, request_id=request_id)
state = update_state(state, action, obs) # (pseudo)
return f"[{request_id}] stopped: step budget"import crypto from "node:crypto";
function stableStringify(obj) {
if (obj === null || typeof obj !== "object") return JSON.stringify(obj);
if (Array.isArray(obj)) return "[" + obj.map(stableStringify).join(",") + "]";
const keys = Object.keys(obj).sort();
return "{" + keys.map((k) => JSON.stringify(k) + ":" + stableStringify(obj[k])).join(",") + "}";
}
export function argsHash(args) {
return crypto.createHash("sha256").update(stableStringify(args)).digest("hex").slice(0, 12);
}
export class LoopGuard extends Error {}
export class Monitor {
constructor({ maxRepeat = 2 } = {}) {
this.maxRepeat = maxRepeat;
this.counts = new Map();
}
mark(tool, args) {
const key = tool + ":" + argsHash(args);
const next = (this.counts.get(key) || 0) + 1;
this.counts.set(key, next);
if (next >= this.maxRepeat) throw new LoopGuard("loop guard: " + key + " repeated " + next + "x");
}
}
export class SafeTools {
constructor({ allow, impl }) {
this.allow = allow;
this.impl = impl;
}
async call(name, { args, requestId }) {
if (!this.allow.has(name)) throw new Error("[" + requestId + "] tool not allowed: " + name);
const started = Date.now();
try {
return await this.impl[name](args);
} finally {
const ms = Date.now() - started;
console.log("[" + requestId + "] tool=" + name + " ms=" + ms + " args_hash=" + argsHash(args));
}
}
}
export async function runFirstAgent(question, { tools, budget }) {
const requestId = crypto.randomUUID().replace(/-/g, "");
const started = Date.now();
const monitor = new Monitor({ maxRepeat: 2 });
let state = { question, notes: [] };
for (let step = 0; step < budget.max_steps; step++) {
if ((Date.now() - started) / 1000 > budget.max_seconds) return "[" + requestId + "] stopped: time budget";
const action = await llmPickAction(state); // (pseudo)
if (action.finish) return action.finish;
const tool = action.tool;
const args = action.args;
monitor.mark(tool, args);
const obs = await tools.call(tool, { args, requestId });
state = updateState(state, action, obs); // (pseudo)
}
return "[" + requestId + "] stopped: step budget";
}Ce nâest pas âle framework parfaitâ. Câest une petite boucle bornĂ©e avec :
- allowlisted tools
- budgets
- loop guard
- basic logs
Ăa suffit pour apprendre sans cramer la prod.
Ătape 3 : une petite âtool policyâ (mĂȘme en read-only)
MĂȘme des tools read-only doivent ĂȘtre allowlistĂ©s explicitement. Ăa te force Ă rendre la surface dâattaque visible.
Tu peux faire Ă©voluer la classe jouet Tools vers quelque chose dâopĂ©rable :
- add args hashing
- add timing
- add error classification (retryable vs fatal)
Ne sur-engineer pas. Rends juste ça débuggable.
Tool contracts (make the model play inside the lines)
The model will happily invent args:
- wrong field names
- huge strings
- weird nested objects
Si ton wrapper accepte âany dictâ, ta runtime devient la couche de validation. Ăa veut dire : tu vas dĂ©bugger des erreurs de validation en prod.
Commence simple :
- define an args shape per tool
- validate types and bounds
- reject unknown fields
Exemple conceptuel :
def validate_web_search(args: dict[str, Any]) -> dict[str, Any]:
q = str(args.get("q", "")).strip()
if not q or len(q) > 200:
raise ValueError("invalid q")
k = int(args.get("k", 5))
if k < 1 or k > 8:
raise ValueError("invalid k")
return {"q": q, "k": k}export function validateWebSearch(args) {
const q = String((args && args.q) || "").trim();
if (!q || q.length > 200) throw new Error("invalid q");
const k = Number((args && args.k) ?? 5);
if (!Number.isFinite(k) || k < 1 || k > 8) throw new Error("invalid k");
return { q, k };
}Ăa a lâair bĂȘte. Câest aussi ce qui empĂȘche ton agent dâappeler web.search avec un prompt dump de 10 000 caractĂšres.
Et ça te donne une erreur propre que tu peux traiter comme fatale (pas âon retry Ă lâinfiniâ).
Ătape 4 : un rollout qui ne fait pas mal
VoilĂ comment on shippe la premiĂšre version :
- Interne uniquement (toi + un collĂšgue)
- Read-only (pas de write tools)
- Canary (petit slice de trafic)
- Kill switch (arrĂȘt opĂ©rateur)
Si tu sautes le canary, la premiĂšre boucle sera chez ton client le plus important.
Ătape 4.5 : annulation (arrĂȘte de payer quand lâutilisateur part)
Si tu as une UI, des utilisateurs vont abandonner des requĂȘtes. Si tu ne propages pas lâannulation, lâagent continue quand mĂȘme. Il continue dâappeler des tools. Il continue de brĂ»ler des tokens. Il continue de gĂ©nĂ©rer un output que personne ne lit.
Fais de lâannulation un stop reason de premiĂšre classe :
- client disconnect â abort du run
- lâabort doit se propager aux appels modĂšle et aux tool calls
- logge
stop_reason = client_cancel
MĂȘme une implĂ©mentation grossiĂšre vaut mieux que âon dĂ©pense jusquâĂ tuer le budgetâ.
Ătape 5 : mĂ©triques de succĂšs (pour savoir si ça sâamĂ©liore)
Choisis 3 chiffres et suis-les :
- taux de complétion (est-ce que ça finit ?)
- coût moyen par run ($ ou tokens / crédits outils)
- p95 runtime
Si le taux de complétion est haut mais que le coût explose, tes budgets sont mal faits. Si le coût est bas mais que le taux de complétion est catastrophique, ton contrat de tool est probablement mauvais.
OĂč lâexĂ©cuter (serverless vs workers)
Ton premier agent va probablement tourner en API route. Câest ok⊠jusquâĂ ce que ça ne le soit plus.
Deux piĂšges classiques :
- cold starts : cold start long + boucle = mauvais UX
- time limits : les plateformes serverless limitent lâexĂ©cution ; ton âbudget 90 secondesâ peut ne pas rentrer
Souvent on sépare :
- la requĂȘte arrive (rapide)
- le run agent tourne dans un worker (budgété)
- la UI poll / stream le progrĂšs
Si ça sonne comme âtrop dâarchiâ, garde simple : mets des budgets assez bas pour que ta plateforme puisse rĂ©ellement arrĂȘter le run. La maniĂšre la plus rapide dâavoir des 500, câest une longue boucle dans un request handler sans garde-fous.
Hardening v2 (toujours petit, toujours safe)
Une fois la version âun toolâ stable, voilĂ ce quâon ajoute ensuite :
Hash des args + dédup
Si lâagent appelle le mĂȘme tool avec les mĂȘmes args en boucle, stoppe-le. Ăa attrape les boucles infinies les plus bĂȘtes.
Add per-tool budgets
Les budgets globaux arrĂȘtent le run. Les budgets par tool empĂȘchent une dĂ©pendance flaky de manger tout le run.
Example:
web.searchmax 3 calls
Add a kill switch
MĂȘme pour un petit agent. Sâil tourne en prod, tu veux pouvoir le stopper sans dĂ©ployer.
Add a âstop reasonâ
Rends le stop reason visible dans les logs et la UI :
- time budget
- step budget
- loop detected
- tool denied
Ăa Ă©vite que les utilisateurs martĂšlent refresh.
Le premier write tool (comment ne pas te planter)
Quand tu ajoutes enfin un write tool :
- make it a separate tool (donât reuse read tool name)
- add idempotency keys
- require approval by default
- log an audit event for the intended write
Si tu sautes lâidempotence, tu vas shipper des Ă©critures dupliquĂ©es. Ce nâest pas âpeutâĂȘtreâ. Câest âquandâ.
Erreurs classiques du âpremier agentâ (on les a faites)
- Glissement âjuste un tool de plusâ. Tu commences avec
web.searchet tu finis avechttp.request+ un token admin. - Retries sans limites. Tu âgĂšres les erreursâ et tu construis une boucle infinie.
- Pas de stop reason dans la UI. Les gens refresh parce quâils ne savent pas ce qui sâest passĂ©, et tu payes deux fois.
- Logger seulement la réponse finale. Ensuite tu ne peux pas expliquer pourquoi le tool a été appelé 17 fois.
Si tu Ă©vites ces quatre-lĂ , tu es dĂ©jĂ devant la plupart des âagent demosâ. Et : teste-le avec un faux tool qui Ă©choue une fois. Si la boucle survit sans spammer les retries, câest bon. Si ça sâĂ©croule, fixe maintenant. Vraiment, maintenant.
Ăchec rĂ©el
On a vu un âpremier agentâ partir en prod avec 0 budgets. Il a reçu un prompt bizarre, a commencĂ© Ă browsÂer, puis sâest coincĂ©.
Résultat :
- lâutilisateur a attendu ~2 minutes
- lâagent a brĂ»lĂ© de lâargent
- tout le monde a blùmé le modÚle
Ce nâĂ©tait pas le modĂšle. CâĂ©taient les budgets absents.
Quand NE PAS construire un agent (pour lâinstant)
- Si un script suffit, écris le script.
- Si un workflow suffit, construis le workflow.
- Si tu ne peux pas mettre des tool permissions + des audit logs, ne shippe pas du tool calling.
Ensuite
- Commencer : Quâestâce quâun agent IA ?
- Pattern : Research agent
- Failure mode : Boucle infinie