You are on call when staging lights up at 2:14 a.m., and the pager is not reporting a crash. The agent keeps running, stays polite in chat, and still fires the same HTTP tool with a payload the API already rejected. By the time you open the logs, that retry loop has been spinning for fourteen minutes without a human in the path. This write-up treats the night as a control-plane failure rather than a mysterious model hallucination.
You already know this shape if you have wired tool calling into a chat agent and then trusted the transcript too much. The model does not decide to spend money; it only emits another function call because the last observation still looks recoverable. Your orchestration layer treats that emission as truth, executes it, and then feeds the error back as fresh context. The loop feels intelligent in Slack and looks catastrophic on the vendor dashboard a few minutes later.
Timeline
The timeline starts at 02:14:03, when a teammate asks the staging bot to create a project from a half-finished JSON blob. At 02:14:07 the model calls create_project with a missing required field, and the internal API returns 422 with a validation body. At 02:14:09 that tool result is appended to the messages, and the model retries with a slightly renamed field. From 02:14:09 through 02:28:11 the same pattern repeats, sometimes with whitespace that your deduper never treats as identical.
You finally send SIGTERM at 02:28:40, after the gateway has already accepted a few hundred nearly identical POSTs. Nothing in Kubernetes looks unhealthy, which is why the cluster never pages you about CPU or memory pressure. The only red graph lives in the LLM console, where output tokens and tool round-trips climb in a straight line. If you only watch application metrics, this incident stays invisible until finance forwards the invoice.
Read the transcript again and you will notice the agent never panicked, never crashed, and never produced a stack trace you could grep. Each turn looks locally reasonable, like a driver making a tiny steering correction on a wet road. The problem is that the road is a circle, and every correction is a billable HTTP round-trip with a generation sitting in front of it. A postmortem that only says "the model retried" is a recap; the durable question is why nothing in your process was allowed to refuse.
Contributing factors
Several ordinary design choices combine here, and none of them look reckless when you read the original pull request. The tool schema allows retries because transient 503 responses are real, and you did not want the agent to quit early. The model is steered toward being helpful, so a validation error reads like a puzzle instead of a stop sign. Your gateway has retries of its own, which means one model call can become three HTTP attempts before the next step.
There is also no per-turn budget, so a single user message can legally produce unbounded tool calls until the process dies. Identical arguments are not hashed, so a renamed key or extra space looks like a fresh attempt rather than a repeated failure. Four-hundred-class errors are passed back as prose, which invites the model to keep editing instead of ending the plan. Taken together, you built a polite infinite loop with a credit card attached to every iteration.
A quieter factor sits in how you observe the system, and it is the reason this page arrived from a human instead of from monitoring. Application dashboards tracked process liveness, request success inside the agent container, and Kubernetes restart counts, all of which stayed green. They did not track tool-call cardinality per user message, repeated argument fingerprints, or terminal HTTP statuses coming back from internal APIs. You cannot page on a bound that you never named, and you cannot name a bound that you never stored beside the trace.
Durable fix
The durable fix is not a better system prompt, because the model will keep emitting calls until something outside it refuses. You need a guard that sits between the model and every tool, including the HTTP client that feels too boring to wrap. Treat the next module as a proposal you can drop into a Python agent loop, and keep it labeled unexecuted until you run it against saved traces. The guard tracks a per-turn ceiling, a fingerprint of repeated arguments, and a terminal mapping for 4xx responses.
# proposal: unexecuted until you wire it into your agent loop
from dataclasses import dataclass, field
from hashlib import sha256
import json
from typing import Any, Callable
@dataclass
class ToolBudget:
max_calls: int = 8
max_repeats: int = 2
terminal_status: tuple[int, ...] = (400, 401, 403, 404, 409, 422)
calls: int = 0
fingerprints: dict[str, int] = field(default_factory=dict)
def fingerprint(self, name: str, args: dict[str, Any]) -> str:
payload = json.dumps(
{"name": name, "args": args}, sort_keys=True, separators=(",", ":")
)
return sha256(payload.encode("utf-8")).hexdigest()[:16]
def allow(self, name: str, args: dict[str, Any]) -> tuple[bool, str]:
if self.calls >= self.max_calls:
return False, f"tool budget exhausted after {self.calls} calls"
fp = self.fingerprint(name, args)
seen = self.fingerprints.get(fp, 0)
if seen >= self.max_repeats:
return False, f"repeated tool call {name} fingerprint={fp}"
self.calls += 1
self.fingerprints[fp] = seen + 1
return True, "ok"
def observe_http(self, status: int) -> tuple[bool, str]:
if status in self.terminal_status:
return False, f"terminal HTTP {status}; do not retry"
return True, "retry_permitted"
def guarded_call(
budget: ToolBudget,
name: str,
args: dict[str, Any],
runner: Callable[..., dict],
) -> dict:
ok, reason = budget.allow(name, args)
if not ok:
return {"ok": False, "stop": True, "reason": reason}
result = runner(name, args)
status = int(result.get("status", 0))
cont, http_reason = budget.observe_http(status)
if not cont:
return {
"ok": False,
"stop": True,
"reason": http_reason,
"result": result,
}
return {"ok": True, "stop": False, "result": result}
Once those three checks fail, the orchestrator must stop calling tools and must return a structured incident to the caller. You should log the fingerprint, the HTTP status, and the remaining budget on every attempt for the next timeline. A regression test then becomes the artifact that a prompt change cannot silently delete. The test below is a proposal: it does not hit a live vendor, and it should stay that way in CI.
# proposal: CI test, no live vendor and no production secrets
def test_422_is_terminal_on_the_first_call():
budget = ToolBudget(max_calls=8, max_repeats=2)
def runner(name, args):
return {"status": 422, "body": {"error": "name required"}}
first = guarded_call(budget, "create_project", {"owner": "ada"}, runner)
assert first["stop"] is True
assert "terminal HTTP 422" in first["reason"]
def test_identical_503s_trip_the_repeat_limit():
budget = ToolBudget(max_calls=8, max_repeats=2)
hits = {"n": 0}
def runner(name, args):
hits["n"] += 1
return {"status": 503, "body": {"error": "upstream busy"}}
args = {"owner": "ada", "name": "demo"}
first = guarded_call(budget, "create_project", args, runner)
second = guarded_call(budget, "create_project", args, runner)
third = guarded_call(budget, "create_project", args, runner)
assert first["stop"] is False and second["stop"] is False
assert third["stop"] is True
assert hits["n"] == 2
def test_turn_ceiling_covers_unique_arguments():
budget = ToolBudget(max_calls=3, max_repeats=9)
def runner(name, args):
return {"status": 200, "body": {"ok": True}}
for i in range(3):
out = guarded_call(budget, "create_project", {"name": f"p{i}"}, runner)
assert out["stop"] is False
fourth = guarded_call(budget, "create_project", {"name": "p9"}, runner)
assert fourth["stop"] is True
assert "tool budget exhausted" in fourth["reason"]
When the live logs are messy, reconstruct the fingerprints from JSONL before you argue about what the model intended that night. The command below is a proposal you can run against a redacted fixture, not against production traffic with secrets still inside. That reconstruction is the difference between a recap and a postmortem, because it shows the loop as data instead of as a vibe. You will usually find one fingerprint owning most of the cost, which is the call you should have blocked.
# proposal: reconstruct fingerprints from a redacted JSONL tool log
python - <<'PY'
import json, hashlib, collections
from pathlib import Path
counts = collections.Counter()
log = Path("toolcalls.jsonl")
for line in log.read_text().splitlines():
row = json.loads(line)
payload = json.dumps({"name": row["name"], "args": row["args"]}, sort_keys=True)
fp = hashlib.sha256(payload.encode()).hexdigest()[:16]
counts[fp] += 1
print(row.get("ts"), row["name"], fp, row.get("status"))
print("top", counts.most_common(5))
PY
After the guard ships, keep the fixture next to the test so the next refactor cannot reintroduce unbounded retries. Wire the same stop reason into whatever you use for pages, even if that is only a Slack webhook on staging today. If the guard cannot fail closed in CI, it will not fail closed at 2:14 a.m. either, and you will be reading another transcript that sounds helpful. The bound has to live in code that runs on every tool call, not in a dashboard you remember to open after the damage.
Replaying the failure without a second invoice
After you ship the guard, you still need a way to replay the original transcript without paying for another burn. Save the raw messages, tool names, and HTTP statuses as a fixture, then run the orchestrator against a fake tool runner first. When you want the model back in the loop, point that replay at a disposable environment instead of production credentials. Disclosure: This article was prepared as part of MonkeyCode's product outreach. MonkeyCode's free model access and free server option can host that replay if you want the experiment off your paid vendor account.
Keep production keys out of that scratch box, and keep customer payloads out of it as well, even when the incident feels urgent. The replay is for proving the guard trips, not for cloning a production agent onto a shared machine. If a fixture needs secrets to be interesting, the fixture is too wide and you should shrink it until a fake runner is enough. A postmortem that leaks credentials while explaining a billing incident is a second incident with better prose.
Who should not copy this
This approach will annoy you if your tools are genuinely long-running workflows that need dozens of coordinated calls. A hard ceiling of eight may be too low for a refactor agent and too high for a billing API that charges per write. A free shared server is the wrong place for customer tokens, production env files, or anything covered by a data agreement. If you handle healthcare, payments, or unpublished security findings, keep the replay local and keep the secrets off that box.
You should not use this pattern as an excuse to skip vendor rate limits or to hide a runaway agent from finance. The circuit breaker reduces damage; it does not make unbounded tool calling a reasonable default for user-facing bots. Teams that already have an AI gateway with spend caps may only need the fingerprint and the 4xx mapping. Everyone else should put the budget in code, because a dashboard alert that fires after the invoice is not a control.
The quiet lesson is that tool calling looks like intelligence while it is really an HTTP client with a language model as the steering wheel. If you do not bound the wheel, the client will drive in circles, and those circles are what you pay for. Write the postmortem around the missing bound, ship the guard, and keep a fixture so a later cleanup cannot delete it. Then you can let the model stay helpful without letting it stay expensive in a way you only notice at month end.
Top comments (0)