Agents that act on their own need a reliable way to stop them or at least pause their activities for human governance and intervention. This post tests four ways of doing this (a hard stop, a spend ceiling, a graceful pause and a pattern breaker) and also looks at audit trails, fleet-wide kill switches, quarantine and monitoring.

TLDR: This post is a bit code heavy, but it does include explainations of what each experiment does and how it works.

Why this experiment

Following on from my post series “Key Questions Boards Must Ask Before Scaling AI Agents“, which included my post on “Should organisations build their own AI kill switches?“, I decided to investigate and test how a kill switch works and what can be built for an AI agent.

Autonomous agents fail in a way that traditional software mostly does not, they fail while working perfectly or appear to be working perfectly.

There is a now infamous account of a four AI agent research system that operated in a production environment for eleven days, with every health check running against it showing as green. Meanwhile two of the agents where quietly passing messages back and forth between themselves in a recursive loop and over thousands of times a day. Nothing had crashed and nothing had timed out, but this was not what was expected and this was a failure that looked exactly like normal operations. Nobody noticed until someone opened the invoice which came to $47,000. Source: Dev.to

“…a failure that looked exactly like normal operation.” Source: Brian Hall, dev.to

This is one of the things we are experimenting defending against in this post.

Experiment setup

Theses experiments build an agent that burns money in a loop and then bring in four potential ways to stop it. By the way, I have no intention of racking up a huge bill either, so the code imitates the costs rather than hitting the wallet, but the intentions are the same.

The experiments start with an installation of Python. There are different ways of installing Python depending on the O/S and version you have and want to use. This is where I would refer you to the official Python documentation on installation for your build.

Recommended set up is Python 3.10 or later for your O/S and the latest version of pip.

Next is the use of LangGraph, which is an open-source, low-level orchestration framework and runtime built by LangChain for creating stateful, multi-agent and complex AI applications.

Install the dependencies below (the interrupt() code was tested against LangGraph 0.2.55):

# Python 3.10+
pip install "langgraph>=0.2.55" pytest redis
# Optional for the production PostgresSaver:
pip install langgraph-checkpoint-postgres

For disclosure, I have used AI to help write the code in the experiments (vibe coding) and I have run in two different frontier AI’s to test it. The code is an example of a suggested approach only and an experiment rather than full production code.

Baseline Agent

The agent that will not stop is a deliberately naive agent loop, with no iteration cap, no budget and no off switch. This is the shape of a potential agent that results in an eleven day loop, as referred to earlier in this post.

import time, random
def tool_call(query: str) -> str:
# Pretend that each call costs money and takes time to make
time.sleep(0.2)
return f"result for {query}"
def naive_agent(goal: str):
"""The dangerous version: loop until it 'decides' it's done.
A drifting model may never decide it is done!"""
step = 0
while True: # <-- No termination guarantee
step += 1
out = tool_call(goal)
# A real agent asks the LLM 'are we done?'. A looping one keeps saying 'No'.
done = random.random() < 0.0 # Force it to never finish, for the demo
print(f"step {step}: {out}")
if done:
break
naive_agent("summarise market data") # <-- would run forever

How the baseline agent works

• tool_call(): Stands in for a paid action, such as an LLM call or API request. It sleeps for 0.2 seconds and returns a dummy result, so nothing is actually charged. I am not looking to rack up the same costs!

• naive_agent(): Runs a while True loop that only ends when the agent decides it is “done”.

• The done check: random.random() < 0.0 is always false. This simulates a drifting model that keeps answering “not finished yet”.

The result is an agent with no step limit, no budget and no off switch. Left running, it calls the paid tool five times a second, forever.

Every experiment that follows adds one control that stops this loop from the outside and everything below in this post is about putting a stop to this from the outside.

Each experiment aims to test and teach a different lesson.

Experiment 1: The hard stop (an external flag)

The simplest kill switch is a boolean the agent checks before every action and crucially, cannot write to itself.

It lives outside the process, for example in an environment variable, a Redis key or a feature flag service. This ensures that a misbehaving agent cannot turn it’s own switch back on.

“First principle: the switch must live externally so the agent can’t modify it.” Source: SakuraSky

import os
def kill_switch_enabled(agent_id: str) -> bool:
# External source of truth. Here: an env var. In prod: Redis / feature flag.
# Missing value defaults to 'enabled'.
return os.environ.get(f"AGENT_{agent_id}_KILL", "off") != "on"
def guarded_agent(agent_id: str, goal: str, max_steps: int = 50):
for step in range(1, max_steps + 1): # Belt: a hard iteration cap
if not kill_switch_enabled(agent_id): # Braces: the external kill switch
print(f"step {step}: kill switch thrown — halting")
return "halted"
print(f"step {step}: {tool_call(goal)}")
print(f"reached max_steps ({max_steps}) — stopping")
return "max_steps"

What the code does

This experiment adds two independent brakes to the loop (belt and braces).

A belt and braces approach is a British idiom. The use of two or more actions in order to be extra careful about something, although only one is really necessary. Source: Cambridge Dictionary

• Brake 1: A hard step limit (the belt): the while True loop becomes for step in range(1, max_steps + 1). Even if every other control fails and nobody is watching, the agent stops after max_steps actions.

• Brake 2, an external kill flag (the braces): before every action the agent calls kill_switch_enabled(), which reads the environment variable AGENT_<id>_KILL. If it is “on”, the agent halts and returns “halted” before making the next tool call. If it is missing or anything else, the agent carries on.

The flag is checked before the action (not after) so a thrown switch prevents the next paid call rather than reporting on it. The agent can only read the flag (not write it) so a misbehaving agent cannot switch itself back on.

One important catch is that an environment variable is read once, when the process starts. Setting it from another terminal will not stop an agent that is already running.

Use the environment variable to block an agent at launch:

# macOS / Linux: Set the flag and launch in one command
AGENT_demo_KILL=on python guarded_agent.py
# Windows PowerShell
$env:AGENT_demo_KILL = "on"; python guarded_agent.py

The agent halts at step 1 without making a single tool call.

For a live emergency stop, read the flag from Redis on every step instead:

import os, redis
r = redis.from_url(os.environ.get("REDIS_URL", "redis://localhost:6379"))
def kill_switch_enabled_live(agent_id: str) -> bool:
"""Read the flag from Redis on EVERY check so changes apply immediately."""
return r.get(f"agent:{agent_id}:kill_switch") != b"on"
def guarded_agent_live(agent_id: str, goal: str, max_steps: int = 50):
for step in range(1, max_steps + 1): # Belt: hard iteration cap
if not kill_switch_enabled_live(agent_id): # Braces: live external flag
print(f"step {step}: kill switch thrown — halting")
return "halted"
print(f"step {step}: {tool_call(goal)}")
print(f"reached max_steps ({max_steps}) — stopping")
return "max_steps"
if __name__ == "__main__":
r.delete("agent:demo:kill_switch") # Start with the switch clear
print("return:", guarded_agent_live("demo", "summarise market data"))

While it runs, throw (and later release) the switch from a second terminal:

redis-cli SET agent:demo:kill_switch on # Halt
redis-cli DEL agent:demo:kill_switch # Release

What actually happens when you run the live Redis version (switch thrown after step 4):

step 1: result for summarise market data
step 2: result for summarise market data
step 3: result for summarise market data
step 4: result for summarise market data
step 5: kill switch thrown - halting
return: halted

And with the flag left alone, the iteration cap still stops it (max_steps=5):

step 1: result for summarise market data
step 2: result for summarise market data
step 3: result for summarise market data
step 4: result for summarise market data
step 5: result for summarise market data
reached max_steps (5) - stopping
return: max_steps

Lesson

Two independent brakes of a hard iteration cap and an external flag.

  • The cap stops the infinite loop even if nobody’s watching
  • The flag gives a human an emergency stop

The hard stop should be considered as a last resort as using this indiscriminately could impact legitimate workloads.

Production note

For distributed systems, replace the env var with Redis.

import redis
def kill_switch_enabled_redis(agent_id: str, redis_client) -> bool:
# Check Redis instead of env vars - works across multiple processes
return redis_client.get(f"agent:{agent_id}:kill_switch") != b"on"
# Usage: export REDIS_URL=redis://localhost:6379
redis_client = redis.from_url(os.environ.get("REDIS_URL", "redis://localhost"))

What the code does

kill_switch_enabled_redis() does the same job as the environment variable check, but reads the key agent:<id>:kill_switch from Redis, a shared data store outside every agent process.

  • It changes live (every running instance sees it on its next check, with no restart)
  • It is shared (one key stops ten copies of the agent on ten servers)
  • It is outside the agent’s reach (give the agent read only credentials and it cannot clear its own flag)

Production note

Fail closed, not open.

In the previous post series I argued that the EU AI Act requires a halt to bring the system to a “safe state” and the halt itself must not create new hazards.

The same logic applies to the kill switch infrastructure. If the agent cannot reach Redis or the env var check throws an exception, the default must be STOP (not CONTINUE).

import os, time, json, hmac, hashlib, random
from dataclasses import dataclass, field, asdict
from datetime import datetime, timedelta, timezone
from threading import Lock
from collections import deque
import redis
# One shared client, created once. decode_responses=False (the default)
# Means Redis returns bytes, which is why the checks compare with b"on".
REDIS = redis.from_url(os.environ.get("REDIS_URL", "redis://localhost:6379"))
def kill_switch_check(agent_id: str, client=REDIS) -> tuple[bool, str]:
"""Return (allowed, reason). Fails CLOSED: any error means stop."""
try:
val = client.get(f"agent:{agent_id}:kill_switch")
if val == b"on":
return False, "kill switch flag set to 'on'"
return True, ""
except Exception as e:
print(f"⚠️ kill switch check failed ({e}) — defaulting to HALT")
return False, f"kill switch check failed: {e}"
def kill_switch_enabled_failclosed(agent_id: str, client=REDIS) -> bool:
"""Boolean wrapper, kept for the tests and earlier examples."""
return kill_switch_check(agent_id, client)[0]

What the code does

kill_switch_check() reads the flag from a shared Redis client and returns two values. Whether the agent may continue and, if not why.

  • If the flag is “on” it returns False with the reason “kill switch flag set to ‘on’”
  • If Redis is down, the network drops or the read throws any error, the except block logs a warning and returns False with the error as the reason, which means stop
  • kill_switch_enabled_failclosed() wraps it for code that only needs True or False.

Without this, an outage in the kill switch infrastructure silently becomes permission to run unsupervised.

This leads to a principle of “An agent that cannot confirm it is allowed to run must not run”.

“To intervene in the operation of the high-risk AI system or interrupt the system through a ‘stop’ button or a similar procedure that allows the system to come to a halt in a safe state.” Source: EU AI Act’s Article 14(4)(e)

Production note

Implement a fleet wide kill switch

A per agent flag is necessary but not sufficient. You also need a global kill switch that can halt every agent simultaneously.

def fleet_kill_switch(client, agent_id: str, team: str | None = None) -> bool:
"""True = allowed to run. Checks global, team and agent in one call.
Key scheme (used everywhere in the post):
kill_switch:global
kill_switch:team:<name>
agent:<id>:kill_switch
"""
keys = ["kill_switch:global"]
if team:
keys.append(f"kill_switch:team:{team}")
keys.append(f"agent:{agent_id}:kill_switch")
try:
return all(v != b"on" for v in client.mget(keys))
except Exception as e:
print(f"⚠️ fleet kill switch check failed ({e}) — defaulting to HALT")
return False
# To halt every agent in the organisation from a single command:
# redis-cli SET kill_switch:global on
#
# To halt all agents in the finance team:
# redis-cli SET kill_switch:team:finance on
#
# To release:
# redis-cli DEL kill_switch:global

What the code does

  • fleet_kill_switch(): Checks three levels of Redis keys in a single MGET call
  • kill_switch: Global stops every agent in the organisation, whatever its other flags say
  • kill_switch:team:<name> stops every agent in that team (checked only when you pass a team)
  • agent:<id>: kill_switch stops one agent, the same key Experiment 1 uses.

The function returns False (stop) if any level is “on”, and True only if all are clear.

Operators can control it with single commands:

  • SET …. on to halt
  • DEL …. to release

Three levels of scope ( global, team and per agent) provide a graduated response. Principle 6: Design for Graduated Autonomy. Not Binary On/Off.

  • Global switch: Big Red STOP Button
  • Per agent: Targetted controlled on/off

Note that the fleet switch also fails closed, if Redis is unreachable, every agent stops.

Cross-cutting: the audit trail

In the previous post “Should organisations build their own AI kill switches?“, I discussed that “a kill switch that fires but leaves no record is a governance gap rather than a control.”

None of the experiments above are complete without a structured, immutable record of every activation. The same utility can be wired into all four experiments.

# In production, load from a secrets manager. The agent must not have it.
AUDIT_KEY = os.environ.get("AUDIT_HMAC_KEY", "dev-only-key").encode()
@dataclass(frozen=True)
class KillSwitchEvent:
"""Audit record. frozen=True blocks edits in code; the HMAC signature
detects edits to the stored copy (a plain hash could just be recomputed)."""
timestamp: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
agent_id: str = ""
trigger_type: str = "" # external_flag | spend_ceiling | pattern_breaker | human_reject
# | iteration_cap | approval_timeout | quarantine
trigger_reason: str = ""
agent_action: str = ""
agent_step: int = 0
agent_state: str = "" # halted | deferred | paused_awaiting_human | tripped | quarantined
triggered_by: str = "" # system | operator:<name> | human_reviewer
signature: str = ""
def _payload(self) -> bytes:
body = {k: v for k, v in asdict(self).items() if k != "signature"}
return json.dumps(body, sort_keys=True).encode()
def __post_init__(self):
sig = hmac.new(AUDIT_KEY, self._payload(), hashlib.sha256).hexdigest()
object.__setattr__(self, "signature", sig) # allowed once, at creation
def verify(self) -> bool:
expected = hmac.new(AUDIT_KEY, self._payload(), hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, self.signature)
def log_kill_switch_event(event: KillSwitchEvent, sink: str = "stdout") -> str:
record = json.dumps(asdict(event), indent=2)
if sink == "stdout":
print(f"📋 KILL SWITCH AUDIT LOG:\n{record}")
# Production: write to an append-only store (CloudWatch, Splunk,
# S3 Object Lock, or a table with INSERT-only permissions).
return record
def guarded_agent_audited(agent_id: str, goal: str, max_steps: int = 50):
for step in range(1, max_steps + 1):
allowed, reason = kill_switch_check(agent_id)
if not allowed:
failed = reason.startswith("kill switch check failed")
log_kill_switch_event(KillSwitchEvent(
agent_id=agent_id,
trigger_type="control_plane_failure" if failed else "external_flag",
trigger_reason=reason,
agent_action=f"about to execute tool_call('{goal}')",
agent_step=step,
agent_state="halted",
triggered_by="system" if failed else "operator",
))
print(f"step {step}: halting — {reason}")
return "halted"
print(f"step {step}: {tool_call(goal)}")
log_kill_switch_event(KillSwitchEvent(
agent_id=agent_id, trigger_type="iteration_cap",
trigger_reason=f"reached max_steps ({max_steps})",
agent_action=f"completed step {max_steps}", agent_step=max_steps,
agent_state="halted", triggered_by="system",
))
print(f"reached max_steps ({max_steps}) — stopping")
return "max_steps"

What the code does

• KillSwitchEvent: a typed record with one field per governance question: when (timestamp, set automatically in UTC), which agent (agent_id), what fired (trigger_type), why (trigger_reason), what it was doing (agent_action, agent_step), what state it was left in (agent_state) and who triggered it (triggered_by).

• Tamper detection: the record is frozen, so code cannot edit it. When it is created, _post_init_ signs every field with an HMAC-SHA256 key held outside the agent. If anyone edits the stored copy, verify() fails, and without the key they cannot forge a new signature.

• log_kill_switch_event(): writes the record out. The demo prints it; in production, send it to an append-only store (CloudWatch, Splunk, an immutable S3 bucket or a database the logger can insert into but not update or delete).

• guarded_agent_audited(): Experiment 1 with a record written at every exit point. When the kill flag fires, it logs trigger_type=”external_flag” and triggered_by=”operator”. When Redis cannot be reached, it logs trigger_type=”control_plane_failure” and triggered_by=”system”, with the real error as the reason. When the step limit is reached, it logs trigger_type=”iteration_cap”. Sample audit output when the external flag fires at step 5:

# KILL SWITCH AUDIT LOG:
{
"timestamp": "2026-10-24T12:00:00.000000+00:00",
"agent_id": "demo",
"trigger_type": "external_flag",
"trigger_reason": "kill switch flag set to 'on'",
"agent_action": "about to execute tool_call('summarise market data')",
"agent_step": 5,
"agent_state": "halted",
"triggered_by": "operator",
"signature": "e96a...e1db"
}

Every field answers one of the five questions from Should organisations build their own AI kill switches

  • who (triggered_by)
  • when (timestamp)
  • why (trigger_reason)
  • what was happening (agent_action + agent_step)
  • what state was left (agent_state)

The HMAC signature, made with a key the agent does not hold allows you to detect tampering after the fact.

The same KillSwitchEvent should be built into Experiments 2, 3 and 4 at their respective halt points. The trigger_type and trigger_reason change and the structure stays the same.

Experiment 2: The spend ceiling that lives outside the agent

The subtle lesson from the $47K loop highlights that a budget check inside the agent is worthless, because the agent is the thing that’s broken.

“And the other common fix, putting a budget check inside the agent, has its own problem: the agent is the thing that’s misbehaving. A loop that’s lost the plot isn’t going to cleanly evaluate its own “am I allowed to keep going” check. You’re asking the runaway process to stop itself. Sometimes it does. The eleven-day ones are when it doesn’t.” Source: Dev.to

The fix is a hard ceiling in front of the tool call that executes before execution and not a billing alert after it.

We can model that control plane as a wrapper the agent has to go through where it never sees the budget and just gets allowed or denied.

This mirrors the token bucket circuit breaker pattern from SakuraSky, rate limiting a specific action to break retry storms.

class SpendGate:
"""Control plane between the agent and its tools. Tracks spend in
whole cents to avoid floating-point drift."""
def __init__(self, daily_max_usd: float, cost_per_call: float, agent_id: str = "default"):
self.daily_max_c = round(daily_max_usd * 100)
self.cost_c = round(cost_per_call * 100)
self.spent_c = 0
self.agent_id = agent_id
self.current_day = datetime.now(timezone.utc).date()
self.lock = Lock()
self.call_history = deque(maxlen=10_000) # bounded
@property
def daily_max(self) -> float: return self.daily_max_c / 100
@property
def spent(self) -> float: return self.spent_c / 100
def _reset_if_new_day(self):
today = datetime.now(timezone.utc).date()
if today != self.current_day:
self.current_day, self.spent_c = today, 0
def call(self, fn, *args, **kwargs):
with self.lock:
self._reset_if_new_day()
if self.spent_c + self.cost_c > self.daily_max_c:
raise RuntimeError(
f"budget ceiling ${self.daily_max:.2f} reached "
f"(spent ${self.spent:.2f}) — deferring to human")
self.spent_c += self.cost_c
self.call_history.append({"timestamp": datetime.now(timezone.utc),
"cost": self.cost_c / 100,
"total_spent": self.spent})
return fn(*args, **kwargs) # run the tool outside the lock
def get_spend_rate(self, window_minutes: int = 5) -> float:
cutoff = datetime.now(timezone.utc) - timedelta(minutes=window_minutes)
recent = [x for x in self.call_history if x["timestamp"] > cutoff]
return len(recent) / window_minutes if window_minutes else 0.0
gate = SpendGate(daily_max_usd=50.0, cost_per_call=0.10, agent_id="demo")
def budgeted_agent(goal: str):
step = 0
while True:
step += 1
try:
out = gate.call(tool_call, goal) # every call passes the gate first
except RuntimeError as e:
print(f"step {step}: {e}")
return "deferred"
print(f"step {step}: {out} (spent ${gate.spent:.2f})")

What the code does

The agent keeps its infinite while True loop. The only change is that it can no longer call the tool directly. Every call goes through gate.call(tool_call, goal).

• Private budget: daily_max and spent live inside the gate, tracked in whole cents so there is no rounding drift. The agent never sees or changes them and it is only told allowed or denied.

• Check before spend: On every call the gate asks whether spent + cost would exceed daily_max. If so it raises an error without running the tool. The over budget call never happens.

• Record, then run: If the call is affordable, the gate adds the cost, logs a timestamped entry in call_history (capped at 10,000 entries) and only then executes the tool.

• Thread safety: The Lock makes the check and the update one atomic step. Without it, two parallel calls could both see $49.95 spent, both pass and breach the ceiling.

• Daily reset: _reset_if_new_day() zeroes the counter when the UTC date changes.

• Monitoring hook: get_spend_rate() returns calls per minute over a recent window, feeding the early-warning alerts later in this post.

In budgeted_agent(), the denied call raises an error that the loop catches, prints and turns into a “deferred” return, handing the decision to a human.

What actually happens when you run it (cost $0.10/call, ceiling $50):

step 1: result for summarise market data (spent $0.10)
step 2: result for summarise market data (spent $0.20)
step 3: result for summarise market data (spent $0.30)
step 100: result for summarise market data (spent $10.00)
step 200: result for summarise market data (spent $20.00)
step 300: result for summarise market data (spent $30.00)
step 400: result for summarise market data (spent $40.00)
step 500: result for summarise market data (spent $50.00)
step 501: budget ceiling $50.00 reached (spent $50.00) — deferring to human
return: deferred at step 501
total spent before stop: $50.00 over 500 successful calls

The loop would have run forever, instead it stopped itself after 500 calls and exactly $50.00. The 501st call was blocked before it executed.

“On the $47K incident specifically: a daily cap would have turned an eleven-day, $47,000 silent loop into a one-day, $50 pause and a notification. Same loop. Same bug. Wildly different outcome, because the ceiling didn’t depend on anyone noticing”. Source: Dev.to

Lesson

Put the limit where the runaway code can’t route around it.

Place it in front of the action, enforced deterministically and with no model in the decision path.

Production note

  • Store spend state in Redis/database, not in-process so it survives restarts
  • Reset budget daily at a fixed time (not per-run)
  • Add alerting. If spend rate jumps 3x in 5 minutes, warn before hitting ceiling
  • Account for batched calls: a single tool invocation might call the API multiple times

Production note

Use a Redis backed spend gate

The in-process SpendGate forgets it’s spend on restart and every copy of the agent keeps its own counter.

RedisSpendGate keeps the running total in Redis so it survives restarts and all instances share one budget.

# Spend is stored in integer cents, so there is no float drift in Redis.
SPEND_SCRIPT = """
local spent = tonumber(redis.call('GET', KEYS[1]) or '0')
local cost = tonumber(ARGV[1])
local cap = tonumber(ARGV[2])
if spent + cost > cap then
return {0, spent}
end
local new = redis.call('INCRBY', KEYS[1], cost)
redis.call('EXPIRE', KEYS[1], 172800) -- clean up after 2 days
return {1, new}
"""
class RedisSpendGate:
"""Shared, persistent spend gate. One budget per agent per UTC day."""
def __init__(self, agent_id: str, redis_client, daily_max_usd: float = 50.0,
cost_per_call: float = 0.10):
self.agent_id = agent_id
self.redis = redis_client
self.daily_max_c = round(daily_max_usd * 100)
self.cost_c = round(cost_per_call * 100)
self._spend = redis_client.register_script(SPEND_SCRIPT)
@property
def daily_max(self) -> float: return self.daily_max_c / 100
def _key(self) -> str:
day = datetime.now(timezone.utc).strftime("%Y-%m-%d")
return f"agent:{self.agent_id}:spend:{day}"
@property
def spent(self) -> float:
return int(self.redis.get(self._key()) or 0) / 100
def call(self, fn, *args, **kwargs):
try:
allowed, total = self._spend(keys=[self._key()],
args=[self.cost_c, self.daily_max_c])
except redis.RedisError as e:
raise RuntimeError(f"spend check failed ({e}) — deferring to human")
if not int(allowed):
raise RuntimeError(
f"budget ceiling ${self.daily_max:.2f} reached "
f"(spent ${int(total) / 100:.2f}) — deferring to human")
return fn(*args, **kwargs)
# Usage: a drop-in replacement for SpendGate in budgeted_agent()
r = redis.from_url(os.environ.get("REDIS_URL", "redis://localhost:6379"))
gate = RedisSpendGate("demo", r, daily_max_usd=50.0, cost_per_call=0.10)

What the code does

• Shared budget: The running total lives in a Redis key, agent:<id>:spend:<date>. Every instance of the agent reads and updates the same key and the total survives restarts.

• Atomic check and spend: The Lua script reads the total, compares it with the ceiling and adds the cost in one step inside Redis. Two instances can never both see $49.95 and both pass. This replaces the in-process Lock, which only protects threads within one process.

• Midnight reset without a timer: The date is part of the key so at midnight UTC the gate starts writing to a fresh key at $0. Old keys expire after two days.

• Fail closed: If Redis cannot be reached, the call is refused rather than allowed. The same principle as the kill switch in Experiment 1.

• Whole cents: Spend is stored as an integer number of cents so there is no rounding drift.

• Drop in: It has the same call() and spent interface as SpendGate so budgeted_agent() works unchanged.

Throw a manual override at any time with redis-cli SET agent:demo:spend:<today> 5000 (spend is stored in cents) which immediately blocks all further spend for that agent today.

Experiment 3: The graceful pause (LangGraph interrupt)

The first two experiments could be considered as blunt as they halt the agent. Often what you really want is to pause before a dangerous action, ask a human (HITL – Human in the Loop) and resume exactly where you left off.

LangGraph’s interrupt() allows this to happen. Called from inside a node, it suspends the graph, checkpoints state and hands control back to the caller. A later Command(resume=value) re-enters the same node with the human’s answer injected. Source: Markaicode.

Below is an agent that pauses only for destructive tools, adapted from the Markaicode walkthrough:

from typing import TypedDict
from langgraph.types import interrupt, Command
from langgraph.graph import StateGraph, END
from langgraph.checkpoint.memory import MemorySaver
# Production: pip install langgraph-checkpoint-postgres, then
# from langgraph.checkpoint.postgres import PostgresSaver
class ToolState(TypedDict):
tool_name: str
approved: bool
result: str | None
DESTRUCTIVE = {"delete_records", "send_email", "execute_trade"}
def approval_node(state: ToolState) -> ToolState:
if state["tool_name"] not in DESTRUCTIVE:
return {"approved": True} # low-risk tools pass straight through
answer = interrupt({ # <-- graph suspends here
"message": f"Approve '{state['tool_name']}'?",
"options": ["approve", "reject"],
})
return {"approved": answer == "approve"}
def execute_node(state: ToolState) -> ToolState:
if not state["approved"]:
return {"result": "Blocked by human."}
return {"result": f"ran {state['tool_name']}"}
builder = StateGraph(ToolState)
builder.add_node("approval", approval_node)
builder.add_node("execute", execute_node)
builder.set_entry_point("approval")
builder.add_edge("approval", "execute")
builder.add_edge("execute", END)
# A checkpointer is REQUIRED — without it there's no state to resume from.
# MemorySaver for dev/testing only. Use PostgresSaver in production.
graph = builder.compile(checkpointer=MemorySaver())
config = {"configurable": {"thread_id": "demo-1"}} # ties the two calls together
first = graph.invoke(
{"tool_name": "send_email", "approved": False, "result": None},
config=config,
)
print(first) # -> contains '__interrupt__': the graph is paused, waiting
# ...surface that question to a human, then resume:
final = graph.invoke(Command(resume="reject"), config=config)
print(final["result"]) # -> 'Blocked by human.'

What the code does

• ToolState: The shared state passed between steps. Which tool the agent wants and whether it is approved and the result.

• DESTRUCTIVE: The tools that need sign off. Everything else runs without asking.

• approval_node: Low risk tools are approved immediately. For a destructive tool it calls interrupt() and the graph freezes at that line. LangGraph saves the full state and hands control back to the caller with the question attached.

• execute_node: Runs the tool if approved, otherwise returns “Blocked by human.”

• The checkpointer: Compile(checkpointer=MemorySaver()) stores the frozen state. Without it there is nothing to resume.

• Pause and resume: The first invoke() stops at the interrupt and returns __interrupt__. Your application shows the question to a human. The second invoke(Command(resume=”reject”)) re-enters approval_node, interrupt() returns the human’s answer and execute_node blocks the email. The thread_id links both calls to the same paused run.

Critical gotchas before you build on this:

  • A checkpointer is mandatory. No checkpointer, no persisted state and nothing to resume. MemorySaver is fine for dev but will lose state on restart. Use PostgresSaver or SqliteSaver in production.
  • It’s not free at scale. For very high frequency agents, every interrupt adds latency and checkpointer I/O. Interrupts inside parallel branches are undefined. Batch approvals or go async instead.
  • Timeout strategy. If a human does not respond, the safe default is to reject, but interrupt() does not wait inside the node. It freezes the graph and hands control back immediately and it never raises TimeoutError. The timeout must live outside the graph, record when you asked and have a background job auto-reject anything unanswered after N seconds
import time
from langgraph.types import Command
APPROVAL_TIMEOUT_S = 300 # 5 minutes
pending_approvals = {} # thread_id -> request time. In production: Redis/DB
def start_run(graph, thread_id: str, tool_name: str):
"""Start a run. If it pauses for approval, record when."""
config = {"configurable": {"thread_id": thread_id}}
result = graph.invoke(
{"tool_name": tool_name, "approved": False, "result": None},
config=config,
)
if "__interrupt__" in result:
pending_approvals[thread_id] = time.time()
question = result["__interrupt__"][0].value["message"]
print(f"[{thread_id}] waiting for human: {question}")
return result
def human_responds(graph, thread_id: str, answer: str):
"""Called by your UI / Slack bot when a human answers."""
if pending_approvals.pop(thread_id, None) is None:
print(f"[{thread_id}] too late — already auto-rejected")
return None
config = {"configurable": {"thread_id": thread_id}}
return graph.invoke(Command(resume=answer), config=config)
def expire_stale_approvals(graph):
"""Run every few seconds from a scheduler or background worker.
Any approval older than the timeout is resumed with 'reject'."""
now = time.time()
for thread_id, asked_at in list(pending_approvals.items()):
if now - asked_at > APPROVAL_TIMEOUT_S:
pending_approvals.pop(thread_id)
config = {"configurable": {"thread_id": thread_id}}
final = graph.invoke(Command(resume="reject"), config=config)
print(f"[{thread_id}] no response in {APPROVAL_TIMEOUT_S}s — "
f"auto-rejected: {final['result']}")
# Log a KillSwitchEvent(trigger_type="approval_timeout", ...) here

With the timeout shortened to 2 seconds and nobody answering, the output is:

[demo-timeout] waiting for human: Approve 'send_email'?
[demo-timeout] no response in 2s — auto-rejected: Blocked by human.

The agent fails safe: Silence means no.

Lesson

A kill switch doesn’t have to mean “destroy everything.”

The best stop is often a reversible pause with a human in the loop and a clean resume.

Experiment 4: The pattern breaker (catching the slow loop)

Experiments 1, 2 and 3 have a blind spot. This is that a hard iteration cap resets every run, a spend gate only trips when calls are expensive or fast and an approval gate only fires on actions you flagged as destructive.

The eleven day loop was not fast or obviously destructive, but was patient. The same couple of actions repeated over and over, slowly.

“Rate limits would not stop these behaviors because the calls are spaced out. A pattern breaker examines what the agent is doing, not just how often.” Source:SakuraSky

The idea is to keep a short sliding window of recent actions per agent and trip if the same action repeats more than a threshold within that window. Slow loops get caught just as reliably as fast ones.

from collections import deque
import time
from threading import Lock
class PatternBreaker:
"""Detects when an agent repeats the same action too many times.
Catches slow loops that slip past rate limiters."""
def __init__(self, agent_id: str, window_s: float = 2.0, max_repeats: int = 5):
self.agent_id = agent_id
self.window_s = window_s
self.max_repeats = max_repeats
self.history = deque(maxlen=1000)
self.lock = Lock()
self.allowlist = set() # actions that legitimately repeat (polling, pagination)
def add_to_allowlist(self, action: str):
"""Legitimate repeating actions (e.g., 'retry_request', 'fetch_page') won't trip."""
self.allowlist.add(action)
def should_trip(self, action: str) -> bool:
"""Trip if the same action occurs > max_repeats times within window_s."""
if action in self.allowlist:
return False
now = time.time()
with self.lock:
self.history.append((action, now))
recent = [x for x in self.history if now - x[1] < self.window_s]
repeated = [x for x in recent if x[0] == action]
if len(repeated) > self.max_repeats:
return True
return False
# Per-agent instance to isolate agents from each other
breakers = {} # in production, keyed by agent_id
def get_breaker(agent_id: str) -> PatternBreaker:
if agent_id not in breakers:
breakers[agent_id] = PatternBreaker(agent_id, window_s=2.0, max_repeats=5)
return breakers[agent_id]
def pattern_guarded_agent(agent_id: str, goal: str, max_steps: int = 50):
breaker = get_breaker(agent_id)
for step in range(1, max_steps + 1):
action = "query_market_data" # agent stuck repeating one action
if breaker.should_trip(action):
print(f"step {step}: circuit breaker tripped — "
f"'{action}' repeated too often — halting")
return "tripped"
print(f"step {step}: ran {action}")
time.sleep(0.05) # slow loop: well under any sane rate limit
print("reached max_steps — stopping")
return "max_steps"

What the code does

• Remembers recent actions: Each action is stored with a timestamp in a deque, a fixed-size list (capped at 1,000 entries) that drops the oldest automatically.

• Sliding time window: On each check it keeps only the actions from the last window_s seconds.

• Counts repeats: If the current action appears more than max_repeats times in that window, should_trip() returns True.

• Allowlist: Actions that legitimately repeat (polling, retries, fetch_next_page) can be exempted so they never trip.

• Per agent and thread-safe: A lock protects the history and get_breaker() gives each agent its own breaker so one agent never trips anothers.

In pattern_guarded_agent() the agent is stuck on query_market_data, running every 0.05 seconds which is slow enough to pass any sensible rate limit.

On the sixth attempt inside the 2 second window the breaker trips and the agent halts before executing it.

What actually happens when you run it (threshold: more than 5 identical actions in a 2s window):

step 1: ran query_market_data
step 2: ran query_market_data
step 3: ran query_market_data
step 4: ran query_market_data
step 5: ran query_market_data
step 6: circuit breaker tripped — 'query_market_data' repeated too often — halting
return: tripped at step 6

The agent never hit a rate limit and never spent much, but repeating one action gave it away. Tune window_s and max_repeats to the task: legitimate work that genuinely repeats an action (polling, pagination) needs a looser threshold or an allowlist or you’ll trip on normal behaviour.

Tuning examples:

  • Research agent doing reconnaissance: window_s=2, max_repeats=3. This catches loops quickly
  • Polling agent checking for updates: window_s=60, max_repeats=10. This allows for legitimate retries
  • Pagination agent fetching pages: Add “fetch_next_page” to allowlist instead of tuning window
  • Retry loop with backoff: window_s=120, max_repeats=5 — looser, since retries are spaced out

Production note

Store pattern state per agent in Redis so distributed instances share it.

import uuid
class RedisPatternBreaker:
"""Shared repetition breaker. One sorted set per agent and action, so
'fetch' never counts 'fetch_next_page'."""
def __init__(self, agent_id: str, redis_client, window_s: float = 2.0, max_repeats: int = 5):
self.agent_id = agent_id
self.redis = redis_client
self.window_s = window_s
self.max_repeats = max_repeats
self.allowlist: set[str] = set()
def add_to_allowlist(self, action: str):
self.allowlist.add(action)
def should_trip(self, action: str) -> bool:
if action in self.allowlist:
return False
now = time.time()
key = f"agent:{self.agent_id}:actions:{action}"
pipe = self.redis.pipeline(transaction=True) # MULTI/EXEC: atomic
pipe.zadd(key, {f"{now}:{uuid.uuid4().hex}": now}) # unique member
pipe.zremrangebyscore(key, 0, now - self.window_s)
pipe.zcard(key)
pipe.expire(key, max(60, int(self.window_s * 2)))
_, _, count, _ = pipe.execute()
return count > self.max_repeats

What the code does

RedisPatternBreaker moves the history into Redis, with one sorted set per agent and action (entries scored by timestamp) so every copy of the agent shares one view of its behaviour.

Because each action has its own key, “fetch” is never confused with “fetch_next_page”. On each check it adds the action, deletes entries older than the window, counts what is left and refreshes the key’s expiry, all in one atomic MULTI/EXEC pipeline.

Allowlisted actions skip the check entirely.

Lesson

Frequency breakers catch bursts and pattern breakers catch persistence. Real loops often look like the second, so you want both.

Production extension: session quarantine and rollback

Most of the experiments above halt the agent. In the previous post on Kill Switches I laid out an option for layered responses:

“Pair graceful interventions (rate limits, session quarantine and rollback) with a genuine global hard stop for the worst case.Do not rely on the big red “Stop” button alone. This is the fail safe defaults principle from Part 2 made concrete”. Source: Should organisations build their own AI kill switches?

Quarantine isolates a misbehaving agent without killing it and rollback undoes what it did before the brake fired. Neither is a toy example (both require integration with your infrastructure) but the patterns are straightforward.

from enum import Enum
from typing import Any
class AgentMode(Enum):
RUNNING = "running"
QUARANTINED = "quarantined" # isolated: can't call tools, but state preserved
HALTED = "halted" # fully stopped
class QuarantineManager:
"""Isolates a misbehaving agent without destroying its state.
A quarantined agent can be inspected, debugged, and either
resumed or terminated — unlike a hard kill, which loses context."""
def __init__(self):
self.agent_modes: dict[str, AgentMode] = {}
def quarantine(self, agent_id: str, reason: str):
"""Move agent to quarantine: it can no longer call tools,
but its state is preserved for inspection."""
self.agent_modes[agent_id] = AgentMode.QUARANTINED
event = KillSwitchEvent(
agent_id=agent_id,
trigger_type="quarantine",
trigger_reason=reason,
agent_state="quarantined",
triggered_by="system",
)
log_kill_switch_event(event)
print(f"🔒 Agent {agent_id} quarantined: {reason}")
def release(self, agent_id: str):
"""Release from quarantine after human review."""
self.agent_modes[agent_id] = AgentMode.RUNNING
print(f"🔓 Agent {agent_id} released from quarantine")
def is_allowed(self, agent_id: str) -> bool:
mode = self.agent_modes.get(agent_id, AgentMode.RUNNING)
return mode == AgentMode.RUNNING
class TransactionLog:
"""Append-only log of agent actions that supports rollback.
Each action records enough context to undo it."""
def __init__(self):
self.log: list[dict] = []
def record(self, agent_id: str, action: str, target: str,
before_state: Any, after_state: Any):
"""Record an action with its before/after state for rollback."""
self.log.append({
"timestamp": datetime.now(timezone.utc).isoformat(),
"agent_id": agent_id,
"action": action,
"target": target,
"before": before_state,
"after": after_state,
"status": "pending", # becomes "committed" once executed
"rolled_back": False,
})
return len(self.log) - 1 # entry index, used to commit
def commit(self, index: int):
"""Mark an entry as actually executed. Only committed entries roll back."""
self.log[index]["status"] = "committed"
def rollback(self, agent_id: str, steps: int = 1) -> list[dict]:
"""Undo the last N actions by this agent.
Returns the list of rolled-back entries."""
agent_entries = [e for e in reversed(self.log)
if e["agent_id"] == agent_id and e["status"] == "committed" and not e["rolled_back"]]
rolled_back = []
for entry in agent_entries[:steps]:
# In production: actually restore entry["before"] to entry["target"]
# e.g., db.update(entry["target"], entry["before"])
entry["rolled_back"] = True
rolled_back.append(entry)
print(f"↩️ Rolled back: {entry['action']} on {entry['target']} "
f"(restored to {entry['before']})")
return rolled_back
# --- Example: quarantine + rollback in action ---
qm = QuarantineManager()
txlog = TransactionLog()
def quarantine_aware_agent(agent_id: str, goal: str, max_steps: int = 10):
"""An agent that records actions and can be quarantined mid-run."""
breaker = get_breaker(agent_id)
for step in range(1, max_steps + 1):
# Check quarantine status before every action
if not qm.is_allowed(agent_id):
print(f"step {step}: agent is quarantined — pausing")
return "quarantined"
action = "update_customer_record"
before = {"status": "active", "balance": 100}
after = {"status": "active", "balance": 90}
# Check pattern breaker FIRST, so a blocked action is never logged
if breaker.should_trip(action):
qm.quarantine(agent_id, f"'{action}' repeated too often")
txlog.rollback(agent_id, steps=3) # undo the last 3 committed actions
return "quarantined_and_rolled_back"
# Record BEFORE executing (pending), then commit once it succeeds
idx = txlog.record(agent_id, action, target="customer:12345",
before_state=before, after_state=after)
print(f"step {step}: executed {action}") # in production: the real write
txlog.commit(idx)
return "completed"

What the code does

• QuarantineManager: Tracks each agent as RUNNING, QUARANTINED or HALTED. quarantine() isolates the agent and writes an audit event. is_allowed() is checked before every action and only passes in RUNNING mode. release() restores the agent after human review. A quarantined agent cannot call tools, but its memory and context are kept for investigation.

• TransactionLog: Before each action record() writes the action, the target and its before and after state, marked pending. commit() marks it as executed once it succeeds. rollback() walks backwards through the agent’s committed entries and (in production) writes each before state back to the target system. Only actions that really happened are ever undone.

• quarantine_aware_agent(): Checks quarantine status, then asks the pattern breaker. If the action is repeating too often, it quarantines the agent and rolls back its last three changes. Otherwise it records the change, executes it and commits it.

The result is that five updates succeed. The sixth trips the breaker, the agent is quarantined and the last three changes to the customer record are reversed.

What actually happens when you run it (pattern breaker trips at step 6):

step 1: executed update_customer_record
step 2: executed update_customer_record
step 3: executed update_customer_record
step 4: executed update_customer_record
step 5: executed update_customer_record
🔒 Agent demo quarantined: 'update_customer_record' repeated too often
↩️ Rolled back: update_customer_record on customer:12345 (restored to {'status': 'active', 'balance': 100})
↩️ Rolled back: update_customer_record on customer:12345 (restored to {'status': 'active', 'balance': 100})
↩️ Rolled back: update_customer_record on customer:12345 (restored to {'status': 'active', 'balance': 100})
return: quarantined_and_rolled_back

The agent is isolated (not destroyed), it’s last three actions are undone and a human can inspect the quarantined state, decide whether to release or terminate and review the transaction log.

This is the layered response the previous post called for:

  • Quarantine first
  • Hard kill only if needed

Lesson

A kill switch does not have to be binary.

The best production systems offer a number of options (from “pause and inspect” through “isolate and undo” to “halt everything”) and choose the lightest intervention that contains the damage.

Monitoring and observability: catch the loop before it costs $47K

The kill switches above are reactive: they kick in after the agent goes rogue. Good production systems alert before hitting the brakes.

Key metrics to monitor

from typing import Dict
@dataclass
class AgentMetrics:
agent_id: str
total_calls: int = 0
total_cost: float = 0.0
start_time: float = field(default_factory=time.time)
action_frequency: Dict[str, int] = field(default_factory=dict)
last_action: str = ""
last_action_time: float = 0.0
def record_action(self, action: str, cost: float):
self.total_calls += 1
self.total_cost += cost
self.action_frequency[action] = self.action_frequency.get(action, 0) + 1
self.last_action, self.last_action_time = action, time.time()
def cost_rate_per_minute(self, min_elapsed_s: float = 60.0) -> float:
elapsed = time.time() - self.start_time
if elapsed < min_elapsed_s: # too early to judge a rate
return 0.0
return self.total_cost / (elapsed / 60)
def get_most_common_action(self) -> str:
return max(self.action_frequency, key=self.action_frequency.get) if self.action_frequency else ""
COST_ALERT_MULTIPLIER = 3.0 # keep in sync with the checklist
REPETITION_ALERT_RATIO = 0.8 # keep in sync with the checklist
def check_cost_anomaly(m: AgentMetrics, baseline_rate: float) -> bool:
rate = m.cost_rate_per_minute()
if rate > baseline_rate * COST_ALERT_MULTIPLIER:
print(f"⚠️ ALERT: cost rate ${rate:.2f}/min vs baseline ${baseline_rate:.2f}/min")
return True
return False
def check_repetition_anomaly(m: AgentMetrics) -> bool:
if m.total_calls < 5:
return False
top = m.get_most_common_action()
ratio = m.action_frequency[top] / m.total_calls
if ratio > REPETITION_ALERT_RATIO:
print(f"⚠️ ALERT: '{top}' is {ratio:.0%} of calls — likely loop")
return True
return False

What the code does

The brakes react once a limit is crossed. Monitoring aims to warn a human before that point, while the problem is still cheap.

AgentMetrics keeps a running profile of each agent, total calls, total cost, how often each action has been used and when it started.

• check_cost_anomaly(): Compares current spend per minute with a normal baseline and alerts if it has more than tripled. The first minute is ignored, because a rate measured over a few seconds is meaningless. This catches retry storms and sudden bursts.

• check_repetition_anomaly(): Alerts if one action makes up more than 80% of all calls (after at least five). This provides the statistical fingerprint of a loop.

In the agent loop every action is recorded and both checks run every 10 steps. A hit goes to send_alert() which in production posts to a comms channel or incident management system.

Example alerting (integrate with a comms channel or incident management system, etc.):

def send_alert(channel: str, message: str):
print(f"[ALERT to {channel}] {message}") # replace with Slack / PagerDuty
# In your agent loop:
max_steps, action, cost = 200, "query_market_data", 0.10
metrics = AgentMetrics(agent_id="demo")
for step in range(1, max_steps + 1):
# ... agent logic ...
metrics.record_action(action, cost)
if step % 10 == 0: # steps 10, 20, 30...
if check_cost_anomaly(metrics, baseline_rate=1.0):
send_alert("ops", f"Agent {metrics.agent_id} cost spike — ${metrics.total_cost:.2f} spent")
if check_repetition_anomaly(metrics):
send_alert("ops", f"Agent {metrics.agent_id} looping on '{metrics.get_most_common_action()}'")

Combining the Brakes: A Production Decision Tree

Each brake catches different failure modes. In production, layer them:

TechniqueWhat it stopsLives whereReversible?LatencyEffort
1. External kill flag + capInfinite loops, rogue agentsOutside process (Redis/env)No – halts< 1sLow
1b. Fail-closed defaultControl plane failureException handler in flag checkNo – halts< 1sLow
1c. Fleet-wide kill switchOrganisation wide emergencyRedis (global/team/agent keys)No – halts< 1sLow
2. Spend gate / circuit breakerRunaway cost, retry stormsControl plane in front of tools (in-process, or Redis-backed for fleets)No – defers< 1sMedium
3. LangGraph interrupt()Dangerous individual actionsFramework node + checkpointerYes – pause & resumeHumanHigh
4. Pattern / repetition breakerSlow loops repeating one actionSupervisor around actionsNo – halts< 1sMedium
5. Session quarantine + rollbackMisbehaviour needing inspectionQuarantine manager + tx logYes – inspect/resume< 1sMedium
6. Audit trail (cross-cutting)Governance gaps, compliance riskAppend only log sinkN/A – record only< 1sLow
7. Cost/repetition alertingLoop before brake tripsMonitoring dashboardN/A – human action1–5 minMedium

Before choosing which brakes to implement, map your agent’s impact radius (what can it affect:

  • What data can it read and write?
  • Which systems can it call?
  • What transactions can it initiate?
  • What is the financial exposure of each?

If you followed the board questions from Key Questions Boards Must Ask Before Scaling AI Agents, you already have the start of the agent impact map.

The map determines which failure modes matter and therefore which of the experiments above you actually need.

  • A research agent with read only access to public data needs an iteration cap and a pattern breaker.
  • An agent with write access to financial systems needs all four experiments plus the quarantine and rollback extension.

The risk assessment is the input; the kill switch design is the output.

How to choose which brakes are right for your agent

  • If cost is the top risk: Spend gate + monitoring. Iteration cap as fallback.
  • If correctness is the top risk: Pattern breaker + LangGraph interrupt on destructive actions.
  • If you have humans on-call 24/7: Add LangGraph interrupt for high stakes actions. Skip if not.
  • If agents run unattended: Rely on hard limits (iteration cap, spend gate, pattern breaker). Interrupt requires human response.

Edge Cases & Tuning Pitfalls

The false positive trap

A badly tuned pattern breaker will trip on legitimate retries or pagination. Avoid this by:

# DON'T: trip on all "retry_request" repeats
# DO: allowlist it
breaker.add_to_allowlist("retry_request")
breaker.add_to_allowlist("fetch_next_page")
# DON'T: set window_s=1 for all agents
# DO: tune by use case
if agent_type == "polling":
window_s = 60 # looser
elif agent_type == "research":
window_s = 2 # tighter

The spend ceiling problem

  • Set it too low and it blocks legitimate work
  • Set it too high and costs spike anyway
# DON'T: hard-code a ceiling for all agents
# DO: base it on historical usage
historical_daily_spend = 10.0
spend_gate = SpendGate(daily_max_usd=historical_daily_spend * 1.5, # 1.5x buffer
cost_per_call=0.10)

The approval timeout problem

# DON'T: pause and assume someone will eventually answer
answer = interrupt({"message": "Approve?"})
# DO: track the request and auto-reject it from outside the graph
start_run(graph, thread_id, "delete_records")
# A scheduler runs expire_stale_approvals(graph) every 10s; no answer in 5 min = reject

Distributed agents not sharing state

# DON'T: each process has its own deque/spend counter
# DO: store in Redis so all instances share it
pattern_breaker = RedisPatternBreaker(agent_id, redis_client)
spend_gate = RedisSpendGate(agent_id, redis_client) # see Experiment 2 production note

Testing Your Kill Switches

Before deploying you should verify each brake trips on cue by employing a minimal test suite.

import pytest
from unittest.mock import Mock, patch
class TestKillSwitches:
def test_iteration_cap_stops_loop(self):
"""Hard cap halts after N steps."""
result = guarded_agent("test_agent", "goal", max_steps=5)
assert result == "max_steps"
def test_external_flag_halts_immediately(self):
"""Kill flag halts on next check."""
with patch.dict(os.environ, {"AGENT_test_KILL": "on"}):
result = guarded_agent("test", "goal", max_steps=100)
# Should stop before max_steps
assert result == "halted"
def test_spend_gate_blocks_at_ceiling(self):
"""Spend gate allows exactly $1.00 of calls, blocks the next."""
gate = SpendGate(daily_max_usd=1.0, cost_per_call=0.10)
tool = MagicMock()
with pytest.raises(RuntimeError):
for _ in range(20):
gate.call(tool)
assert tool.call_count == 10
assert gate.spent == 1.0
def test_pattern_breaker_trips_on_repetition(self):
"""Pattern breaker catches repeated actions."""
breaker = PatternBreaker("test", window_s=2.0, max_repeats=3)
# First 3 calls OK
assert not breaker.should_trip("fetch")
assert not breaker.should_trip("fetch")
assert not breaker.should_trip("fetch")
# 4th call trips
assert breaker.should_trip("fetch")
def test_pattern_breaker_allowlist_works(self):
"""Allowlisted actions don't trip."""
breaker = PatternBreaker("test", window_s=2.0, max_repeats=3)
breaker.add_to_allowlist("retry")
# Repeat allowlisted action 100x — no trip
for _ in range(100):
assert not breaker.should_trip("retry")
def test_langgraph_interrupt_pauses_and_resumes(self):
"""Interrupt suspends; resume continues with new state."""
# (Requires LangGraph setup; see experiment 3)
config = {"configurable": {"thread_id": "test-1"}}
first = graph.invoke(
{"tool_name": "delete_records", "approved": False, "result": None},
config=config,
)
assert "__interrupt__" in str(first) # paused
final = graph.invoke(Command(resume="approve"), config=config)
assert "delete_records" in final["result"]
def test_approval_auto_rejects_on_timeout(self):
"""Unanswered approvals are auto-rejected from outside the graph."""
global APPROVAL_TIMEOUT_S
APPROVAL_TIMEOUT_S = 0
start_run(graph, "test-timeout", "send_email")
assert "test-timeout" in pending_approvals
expire_stale_approvals(graph)
assert "test-timeout" not in pending_approvals
# Run with: pytest test_kill_switches.py -v
import pytest
from unittest.mock import patch, MagicMock
class TestKillSwitchesAdditions:
"""Additional tests for the new code blocks."""
def test_fail_closed_on_redis_failure(self):
"""If Redis is unreachable, kill switch defaults to HALT."""
bad = MagicMock()
bad.get.side_effect = ConnectionError("Redis down")
assert kill_switch_enabled_failclosed("test_agent", client=bad) is False
def test_audit_event_has_all_fields(self):
"""Every audit event captures who, when, why, what, and state."""
event = KillSwitchEvent(
agent_id="test",
trigger_type="external_flag",
trigger_reason="manual halt",
agent_action="tool_call('query')",
agent_step=5,
agent_state="halted",
triggered_by="operator:jane",
)
assert event.agent_id == "test"
assert event.trigger_type == "external_flag"
assert event.triggered_by == "operator:jane"
assert event.agent_state == "halted"
assert event.verify() and len(event.signature) == 64 # HMAC-SHA256
def test_audit_signature_detects_tampering(self):
"""Edits are blocked in code, and a tampered stored copy fails verify()."""
event = KillSwitchEvent(agent_id="test", trigger_type="spend_ceiling",
trigger_reason="budget exceeded", agent_state="deferred",
triggered_by="system")
assert event.verify()
with pytest.raises(Exception): # frozen dataclass
event.trigger_reason = "everything is fine"
stored = asdict(event)
stored["trigger_reason"] = "everything is fine" # tamper with the stored copy
forged = KillSwitchEvent.__new__(KillSwitchEvent)
for k, v in stored.items():
object.__setattr__(forged, k, v)
assert not forged.verify()
def test_quarantine_blocks_agent(self):
"""Quarantined agent cannot execute actions."""
qm_test = QuarantineManager()
assert qm_test.is_allowed("agent_1") is True
qm_test.quarantine("agent_1", "loop detected")
assert qm_test.is_allowed("agent_1") is False
qm_test.release("agent_1")
assert qm_test.is_allowed("agent_1") is True
def test_rollback_undoes_actions(self):
"""Transaction log rollback restores previous state."""
txlog_test = TransactionLog()
i1 = txlog_test.record("agent_1", "update", "customer:1",
before_state={"balance": 100}, after_state={"balance": 90})
txlog_test.commit(i1)
i2 = txlog_test.record("agent_1", "update", "customer:1",
before_state={"balance": 90}, after_state={"balance": 80})
txlog_test.commit(i2)
rolled = txlog_test.rollback("agent_1", steps=2)
assert len(rolled) == 2
assert rolled[0]["before"] == {"balance": 90} # most recent first
assert rolled[1]["before"] == {"balance": 100}
def test_fleet_kill_switch_global(self):
"""Global kill switch halts all agents."""
r = MagicMock()
r.mget.side_effect = lambda keys: [b"on" if k == "kill_switch:global" else None for k in keys]
assert fleet_kill_switch(r, "a1") is False
def test_fleet_kill_switch_team_scope(self):
"""Team-level kill switch halts only that team's agents."""
r = MagicMock()
r.mget.side_effect = lambda keys: [b"on" if k == "kill_switch:team:finance" else None for k in keys]
assert fleet_kill_switch(r, "a1", team="finance") is False
assert fleet_kill_switch(r, "a1", team="engineering") is True
def test_fleet_kill_switch_fails_closed(self):
"""Fleet kill switch fails closed on Redis error."""
r = MagicMock()
r.mget.side_effect = ConnectionError("Redis down")
assert fleet_kill_switch(r, "a1") is False
# --- RedisSpendGate (Experiment 2 production note) ---
# The Lua script runs inside Redis, so these tests mock register_script()
# and simulate the script's behaviour with a shared in-memory total.
def _fake_redis(self):
store = {}
def script(keys, args): # amounts in integer cents
key, cost, cap = keys[0], int(args[0]), int(args[1])
spent = store.get(key, 0)
if spent + cost > cap:
return [0, spent]
store[key] = spent + cost
return [1, store[key]]
mock_redis = MagicMock()
mock_redis.register_script.return_value = script
mock_redis.get.side_effect = lambda k: store.get(k)
return mock_redis, store
def test_redis_spend_gate_blocks_at_ceiling(self):
"""Allows calls up to the ceiling, blocks the next one before it runs."""
mock_redis, _ = self._fake_redis()
gate = RedisSpendGate("test", mock_redis, daily_max_usd=1.0, cost_per_call=0.10)
tool = MagicMock(return_value="ok")
with pytest.raises(RuntimeError, match="budget ceiling"):
for _ in range(20):
gate.call(tool)
assert tool.call_count == 10 # exactly $1.00 of calls ran
assert gate.spent <= 1.0
def test_redis_spend_gate_shared_across_instances(self):
"""Two gates for the same agent share one budget."""
mock_redis, _ = self._fake_redis()
a = RedisSpendGate("shared", mock_redis, daily_max_usd=0.50, cost_per_call=0.10)
b = RedisSpendGate("shared", mock_redis, daily_max_usd=0.50, cost_per_call=0.10)
tool = MagicMock()
for gate in (a, b, a, b, a): # 5 calls = $0.50 across both
gate.call(tool)
with pytest.raises(RuntimeError):
b.call(tool) # 6th call blocked, whichever instance
assert tool.call_count == 5
def test_redis_spend_gate_key_resets_daily(self):
"""The key includes the UTC date, so a new day starts a new budget."""
mock_redis, _ = self._fake_redis()
gate = RedisSpendGate("test", mock_redis)
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
assert gate._key() == f"agent:test:spend:{today}"
def test_redis_spend_gate_fails_closed(self):
"""If Redis errors, the call is refused and the tool never runs."""
import redis
mock_redis = MagicMock()
mock_redis.register_script.return_value = MagicMock(
side_effect=redis.ConnectionError("Redis down"))
gate = RedisSpendGate("test", mock_redis)
tool = MagicMock()
with pytest.raises(RuntimeError, match="spend check failed"):
gate.call(tool)
tool.assert_not_called()
# Run with: pytest test_kill_switches.py -v

What the four experiments add up to

The through line is one idea in every experiment. The stop control must live outside the agent’s reasoning loop.

In Key Questions Boards Must Ask Before Scaling AI Agents, I asked a number of board questions. Adding to those this post raises another question to be answered:

Board Question: “Can you show me that we can stop any agent today in production within 60 seconds (from outside the agent’s own control) and that every activation is logged and auditable?”

This post has set out experiments and using these they answer the question by:

ExperimentTime to stopOutside agent’s control?Logged & auditable?
1. External flag + cap< 1 second (next check)Yes (env var / Redis)Yes (with KillSwitchEvent)
2. Spend ceiling< 1 second (next call)Yes (SpendGate / RedisSpendGate wrapper)Yes (with KillSwitchEvent)
3. LangGraph interruptDepends on humanYes (external caller)Yes (checkpointer + event)
4. Pattern breaker< 1 second (next check)Yes (supervisor layer)Yes (with KillSwitchEvent)
Quarantine + rollback< 1 second (next check)Yes (QuarantineManager)Yes (transaction log)
Fleet-wide kill switch< 1 second (next check)Yes (Redis global key)Yes (with KillSwitchEvent)

Every automated brake fires in under one second when implemented correctly and tested, which is well within the 60 second benchmark.

The LangGraph interrupt depends on human response time and pairs it with the timeout wrapper from Experiment 3 to auto reject after a configurable deadline.

Each experiment maps directly to the governance principles from Seven Principles for Governing Agentic AI Successfully:

ExperimentSeries PrinciplePart
1. External flag + iteration capBounded autonomy. Fail-safe defaults2
1b. Fail-closed defaultFail-safe defaults2
2. Spend ceilingBounded autonomy2
3. LangGraph interruptHuman oversight2
4. Pattern breakerReconstruction. Architecture must lead3
Audit trail (cross-cutting)Reconstruction3
Quarantine + rollbackFail-safe defaults. Layered response2
Fleet-wide kill switchArchitecture must lead3

Connecting to regulation

These experiments should not really be treated as academic exercises as the EU AI Act states as previously quoted in this post:

“To intervene in the operation of the high-risk AI system or interrupt the system through a ‘stop’ button or a similar procedure that allows the system to come to a halt in a safe state.” Source: EU AI Act’s Article 14(4)(e)

The experiments show it is possible to implement exactly that:

  • Experiments 1 and 4 provide the stop mechanism
  • The fail closed default (Experiment 1) ensures the halt reaches a safe state
  • The audit trail satisfies the reconstruction requirement for compliance demonstration
  • The quarantine and rollback extension provides the graduated intervention the Act’s “effectively overseen by natural persons” language implies

The code above gives you a working starting point.

The production checklist:

  • [ ] Iteration cap: Yes, hardcoded, per-agent
  • [ ] External kill flag: Yes, per-agent, fail-closed if unreachable
  • [ ] Fleet-wide kill switch: Yes, global + team + per-agent scopes
  • [ ] Spend gate: Yes, per-agent, daily reset at midnight UTC, atomic (RedisSpendGate), fails closed
  • [ ] Pattern breaker: Yes, per-agent, tuned for your task, with allowlist
  • [ ] Session quarantine: Yes, isolate before killing
  • [ ] Transaction rollback: Yes, undo last N actions on quarantine
  • [ ] Audit trail: Yes, KillSwitchEvent for every activation, append-only, HMAC-signed
  • [ ] Cost monitoring: Dashboards + alerts at 3x baseline
  • [ ] Repetition monitoring: alert if one action > 80% of calls
  • [ ] LangGraph interrupt: Yes, for destructive actions, with an external auto-reject timeout
  • [ ] On-call: Comms Channel/Incident Management System integration for alerts
  • [ ] Testing: Unit tests for each brake (see updated test suite above)
  • [ ] Distributed: Redis-backed state (kill flags, RedisSpendGate, RedisPatternBreaker), not in-process
  • [ ] 60-second benchmark: verified all automated brakes fire within 60s
  • [ ] Regulatory mapping: documented which controls satisfy Article 14

References

  • Python Installation Guide. Python
  • Build reliable agents with LangGraph and LangSmith. LangChain
  • LangGraph Interrupt: Pause and Resume Agent Execution. Markaicode
  • How to stop an AI agent from burning $47,000 in a loop nobody noticed. Dev.to
  • Trustworthy AI Agents: Kill Switches and Circuit Breakers. SakuraSky
  • Human-in-the-loop. LangChain

Leave a Reply

Trending