En bref: Les “agents” single-step (un call modèle → exécuter → terminé) n’ont pas d’espace pour la validation, pas de boucle de recovery, pas de stop reasons. Si tu as des tools ou des effets secondaires (changements d'état), tu as besoin d’une boucle bornée + gouvernance.
Tu vas apprendre : Quand single-step est ok • la règle de routage minimale • une interface de boucle bornée • stop reasons • un smell test d’incident
Single-step : validation nulle part • recovery dans des prompts “malins” • writes trop tôt
Runner en boucle : budgets • tool gateway • stop reasons • safe-mode
Impact : moins d’incidents + des failures debuggables au lieu de “execute & pray”
Problème (d’abord)
Quelqu’un dit : “on a construit un agent”.
Le code :
- appeler le modèle une fois
- parser un tool call
- exécuter
- retourner ce qui s’est passé
Ce n’est pas un agent. C’est un appel de fonction avec des arguments imprévisibles.
En démo, c’est rapide. En prod, ça casse parce que les systèmes sont bruyants et tu as besoin de feedback + contrôle.
Pourquoi ça casse en production
1) Pas de boucle de feedback = pas de recovery
Timeouts, réponses partielles, 429, données stale, schema drift. En single-step, tu n’as nulle part où mettre la logique de recovery, donc elle finit “dans le prompt” et tu l’exécutes aveuglément.
2) Budgets et stop reasons arrivent trop tard
“Ça ne loop pas, donc pas besoin de budgets.”
Puis tu ajoutes des retries dans les tools, des retries dans le call modèle, et un deuxième tool call “au cas où”.
Tu as réinventé des boucles sans gouvernance.
3) Tool output ignoré ou mal utilisé
Un tool call, un output, et ensuite… tu le retournes. Pas de validation, pas d’invariants, pas de “on a vraiment résolu ?”.
4) Les writes deviennent un pile ou face
Single-step autorise une write immédiatement. Pas de policy “read first, write later”. Blast radius dès le début.
Quand single-step suffit (oui, parfois)
Single-step est ok si tout est vrai :
- pas de tools (ou tools strictement read-only)
- pas d’effets secondaires (changements d'état)
- output utilisé comme texte, pas comme commande
- output validable via schéma strict (ou non nécessaire)
Framework de décision : single-step est OK seulement si tout est vrai :
- ✅ Read-only (pas d’effets secondaires)
- ✅ Output fortement typé (ou pas de tools)
- ✅ Failure “cheap” (faible blast radius)
- ✅ Pas besoin de retry / boucle de recovery
Sinon, route vers un runner en boucle.
Règle de routage (celle qui te sauve)
Si la prochaine étape peut avoir des effets secondaires, single-step n’est pas autorisé.
if action.has_side_effects:
run_looped_runner()
else:
run_single_step()
Chemin de migration (single-step → boucle)
Voilà ce que les équipes ship, et pourquoi ça casse :
# v1: single-step (fast, unsafe)
result = tool(llm_decide(task)) # damage can happen before validation
# v2: add validation (still unsafe if the tool already ran)
result = tool(llm_decide(task))
if not valid(result):
raise RuntimeError("too late: side effect already happened")
# v3: bounded loop (safe enough to operate)
for step in range(max_steps):
action = llm_decide(state)
if action.kind == "tool":
obs = tool_gateway.call(action.name, action.args) # policy + budgets
state = update(state, obs)
else:
return action.final_answer
Implémentation (vrai code)
Le pattern garde single-step dans le safe (read-only) et route le reste vers un runner borné.
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Literal
@dataclass(frozen=True)
class Budgets:
max_steps: int = 25
max_tool_calls: int = 12
max_seconds: int = 60
class Stopped(RuntimeError):
def __init__(self, stop_reason: str):
super().__init__(stop_reason)
self.stop_reason = stop_reason
def is_side_effecting(action: dict[str, Any]) -> bool:
# Production: decide side-effect class in code, not by prompt vibes.
return action.get("kind") in {"write", "payment", "email", "ticket_close"}
def run_single_step(task: str, *, llm) -> dict[str, Any]:
"""
Safe single-step: no tools, no writes.
This is a completion, not an agent.
"""
text = llm.text({"task": task, "style": "direct"}) # (pseudo)
return {"status": "ok", "stop_reason": "single_step", "answer": text}
def run_looped(task: str, *, budgets: Budgets, runner) -> dict[str, Any]:
"""
Delegate to a bounded runner that has:
- tool gateway
- output validation
- stop reasons
"""
return runner.run(task, budgets=budgets) # (pseudo)
def route(task: str, *, llm, budgets: Budgets, runner) -> dict[str, Any]:
# First decision is read-only: are we about to do anything with side effects?
action = llm.json(
{
"task": task,
"rule": "Return JSON {kind: 'read_only'|'side_effects'} and nothing else.",
"examples": [{"task": "Summarize this text", "kind": "read_only"}, {"task": "Close ticket #123", "kind": "side_effects"}],
}
) # (pseudo)
if action.get("kind") == "side_effects":
return run_looped(task, budgets=budgets, runner=runner)
return run_single_step(task, llm=llm)export class Stopped extends Error {
constructor(stopReason) {
super(stopReason);
this.stop_reason = stopReason;
}
}
export function runSingleStep(task, { llm }) {
// Safe single-step: no tools, no writes.
return llm.text({ task, style: "direct" }).then((text) => ({ status: "ok", stop_reason: "single_step", answer: text })); // (pseudo)
}
export function runLooped(task, { budgets, runner }) {
// Delegate to a bounded runner with tool gateway + stop reasons.
return runner.run(task, { budgets }); // (pseudo)
}
export async function route(task, { llm, budgets, runner }) {
const action = await llm.json({
task,
rule: "Return JSON {kind: 'read_only'|'side_effects'} and nothing else.",
examples: [
{ task: "Summarize this text", kind: "read_only" },
{ task: "Close ticket #123", kind: "side_effects" },
],
}); // (pseudo)
if (action.kind === "side_effects") return await runLooped(task, { budgets, runner });
return await runSingleStep(task, { llm });
}Ça n’a pas l’air “agentic”. Ça a l’air opérable. C’est le but.
Failure evidence (à quoi ça ressemble)
Les failures single-step = “une mauvaise décision avec blast radius immédiat”.
{"run_id":"run_44a1","step":0,"event":"tool_call","tool":"ticket.close","args_hash":"b5d0aa","decision":"allow"}
{"run_id":"run_44a1","step":0,"event":"tool_result","tool":"ticket.close","ok":true}
{"run_id":"run_44a1","step":0,"event":"stop","reason":"success","note":"single-step"}
Example failure case (composite)
🚨 Incident: Premature ticket closure
System: agent single-step “close resolved tickets”
Duration: moins d’1 heure
Impact: 18 tickets fermés à tort
What happened
ticket.close a été exécuté immédiatement sur un snippet. Sarcasme lu comme “resolved”.
Le pire : aucune explication. Pas d’état de boucle, pas de stop reasons utiles, pas d’endroit pour valider.
Fix
- router les actions avec side effects vers un runner en boucle
- policy de tool gateway + audit logs
- approbations pour
ticket.close
Compromis
- Une boucle = plus de code qu’un call modèle.
- Plus d’étapes peut ajouter de la latence (budgets).
- Il faut de l’observabilité (de toute façon).
Quand NE PAS l’utiliser
- Si tu as un transform déterministe, ne l’appelle pas agent.
- Si tu as besoin de feedback tool + recovery, single-step sera fragile.
- Si tu ne peux pas logger traces + stop reasons, fixe l’observabilité d’abord.
Checklist (copier-coller)
- [ ] si side effects → runner en boucle obligatoire
- [ ] router les tasks side-effecting hors single-step
- [ ] budgets (steps, tool calls, seconds)
- [ ] tool gateway (deny by default)
- [ ] validation avant writes
- [ ] stop reasons retournées (et loggées)
- [ ] approbations pour writes
Safe default config
routing:
allow_single_step_only_when: "read_only"
budgets:
max_steps: 25
max_tool_calls: 12
max_seconds: 60
tools:
allow: ["search.read", "kb.read", "http.get"]
writes:
require_approval: true
stop_reasons:
return_to_user: true
FAQ
Related pages
Production takeaway
What breaks without this
- ❌ writes avant validation
- ❌ “recovery” dans des prompts + retries
- ❌ pas de stop reasons explicatives
What works with this
- ✅ side effects → runner borné
- ✅ budgets + tool gateway = contrôle
- ✅ failures explicables (stop reasons + traces)
Minimum to ship
- Règle de routage (read-only peut être single-step; side effects non)
- Runner borné (budgets + stop reasons)
- Tool gateway (deny by default)
- Validation layer (avant writes)