Action is proposed as structured data (tool + args).
Le problème (côté prod)
Ton agent fait la mauvaise chose.
Pas « la réponse est un peu off ». Mauvaise chose comme :
- envoyer des emails en double
- créer des tickets en masse
- marteler une API jusqu’au rate limit
Et maintenant le point important : tu n’as pas le temps de « fixer le prompt et redeployer ».
Il te faut un kill switch qui :
- marche tout de suite
- est auditable (qui a switché, quand, pourquoi)
- stoppe les side effects, pas juste l’UI
Si ton kill switch vit seulement dans le frontend, ce n’est pas un kill switch. C’est un placebo. Si ton kill switch est une variable d’env, c’est un deploy. Les incidents n’attendent pas tes deploys.
Pourquoi ça casse en prod
1) Les équipes font des boutons “pause” qui ne pausent rien
Anti-design :
- l’UI cache le bouton
- l’API continue la run loop
- le tool gateway exécute toujours les writes
Si les tool calls passent encore, tu n’as pas stoppé l’incident. Tu l’as renommé.
2) Un kill switch non enforce dans le tool gateway fuite
Si tu checks le kill switch :
- dans une route
- mais pas dans les jobs background
- et pas dans le tool gateway
…tu rates un chemin.
3) “Stop the run” n’est pas suffisant
Il y a des calls en cours :
- HTTP longs
- sessions navigateur
- workers déjà en train d’exécuter
Il te faut des semantics :
- stop new runs
- stop new tool calls
- optionnellement force-cancel in-flight (best-effort)
4) Scope : global vs par tenant
Tu ne veux pas arrêter tout le produit parce qu’un tenant loop. Tu veux :
- global switch (nucléaire)
- par-tenant (chirurgical)
- disable list par tool (ex : “pas de browser aujourd’hui”)
Exemple d’implémentation (code réel)
Ce pattern :
- lit l’état depuis un store partagé (pseudo)
- check à deux endroits : loop + tool gateway
- distingue “stop all” vs “disable writes”
from dataclasses import dataclass
from typing import Any
@dataclass(frozen=True)
class KillState:
stop_all: bool = False
disable_writes: bool = True
disabled_tools: set[str] = None
class Killed(RuntimeError):
pass
def load_kill_state(*, tenant_id: str) -> KillState:
# Pseudo: Redis/DB/feature-flag service. Must be fast + reliable.
# Split global + per-tenant state.
global_state = read_flag("agent_kill_global") # (pseudo)
tenant_state = read_flag(f"agent_kill_tenant:{tenant_id}") # (pseudo)
disabled_tools = set(read_list("agent_disabled_tools")) # (pseudo)
return KillState(
stop_all=bool(global_state or tenant_state),
disable_writes=True,
disabled_tools=disabled_tools,
)
WRITE_TOOLS = {"email.send", "db.write", "ticket.create", "ticket.close"}
def guard_tool_call(*, kill: KillState, tool: str) -> None:
if kill.stop_all:
raise Killed("killed: stop_all")
if tool in (kill.disabled_tools or set()):
raise Killed(f"killed: tool_disabled:{tool}")
if kill.disable_writes and tool in WRITE_TOOLS:
raise Killed(f"killed: writes_disabled:{tool}")
def run(task: str, *, tenant_id: str, tools) -> dict[str, Any]:
kill = load_kill_state(tenant_id=tenant_id)
for _ in range(1000):
if kill.stop_all:
return {"status": "stopped", "stop_reason": "killed"}
action = llm_decide(task) # (pseudo)
if action.kind != "tool":
return {"status": "ok", "answer": action.final_answer}
guard_tool_call(kill=kill, tool=action.name)
obs = tools.call(action.name, action.args) # (pseudo)
task = update(task, action, obs) # (pseudo)
return {"status": "stopped", "stop_reason": "max_steps"}const WRITE_TOOLS = new Set(["email.send", "db.write", "ticket.create", "ticket.close"]);
export class Killed extends Error {}
export function loadKillState({ tenantId }) {
// Pseudo: feature-flag store. Must be fast + reliable.
const globalStop = readFlag("agent_kill_global"); // (pseudo)
const tenantStop = readFlag("agent_kill_tenant:" + tenantId); // (pseudo)
const disabledTools = new Set(readList("agent_disabled_tools")); // (pseudo)
return { stopAll: Boolean(globalStop || tenantStop), disableWrites: true, disabledTools };
}
export function guardToolCall({ kill, tool }) {
if (kill.stopAll) throw new Killed("killed: stop_all");
if (kill.disabledTools && kill.disabledTools.has(tool)) throw new Killed("killed: tool_disabled:" + tool);
if (kill.disableWrites && WRITE_TOOLS.has(tool)) throw new Killed("killed: writes_disabled:" + tool);
}Incident réel (avec chiffres)
On avait un agent qui rédigeait et envoyait des emails de follow-up. Il était derrière un tool “send_email” et (oops) pas encore de gate d’approbation.
Un changement de prompt a interprété “follow up” comme “send now”.
Impact en 22 minutes :
- 117 emails envoyés (dont des doublons)
- ~4 heures de damage control client
- le modèle n’était pas “hacké” — il était juste faux, bruyant
Le kill switch qu’on pensait avoir était un toggle UI. Les workers background l’ignoraient.
Fix :
- kill switch enforce dans le tool gateway (writes disabled)
- stop par tenant
- audit logs quand le kill state bloque un tool call
- runbook : kill switch d’abord, questions après
Compromis
- Les kill switches réduisent la dispo pendant un incident. C’est mieux que des writes irréversibles.
- Il faut tester le chemin kill. Un kill switch non testé échoue au pire moment.
- Lire un store partagé ajoute de la latence ; garde ça rapide et cache court (secondes, pas minutes).
Quand NE PAS l’utiliser
- Ne remplace pas une vraie gouvernance (permissions, approvals, budgets).
- Ne fais pas un kill switch purement client-side. Il te mentira.
- Ne te repose pas dessus pour le flux normal. C’est “stop the bleeding”.
Checklist (copier-coller)
- [ ] Global kill switch (stop new runs)
- [ ] Par tenant (chirurgical)
- [ ] Enforced dans le tool gateway (stop side effects)
- [ ] Mode “disable writes” (read-only degrade)
- [ ] Disable list par tool (ex : “no browser”)
- [ ] Audit logs (blocks + actions opérateur)
- [ ] Runbook testé : flip, verify, drain, recover
Config par défaut sûre (JSON/YAML)
kill_switch:
global_flag: "agent_kill_global"
per_tenant_flag_prefix: "agent_kill_tenant:"
mode_when_enabled: "disable_writes"
disabled_tools_key: "agent_disabled_tools"
cache_ttl_s: 2
FAQ (3–5)
Utilisé par les patterns
Pannes associées
Q: Le kill switch doit tout arrêter ou seulement les writes ?
A: Par défaut : disable writes. Stop tout, c’est l’option nucléaire quand tu ne peux plus faire confiance à la loop.
Q: Où enforce le kill switch ?
A: Dans le tool gateway et dans la run loop. S’il n’est pas enforce sur les tool calls, ce n’est pas réel.
Q: On peut cacher l’état ?
A: Oui, mais TTL en secondes. Les incidents se mesurent en secondes, pas en minutes.
Q: On a besoin du scope par tenant ?
A: Si tu es multi-tenant : oui. Sinon l’incident d’un client devient un outage pour tous.
Pages liées (3–6 liens)
- Foundations: What makes an agent production-ready · Why agents fail in production
- Failure: Cascading tool failures · Tool spam loops
- Governance: Budget controls · Tool permissions
- Production stack: Production agent stack