AI EngineeringZero to ProductionHome·About·What’s new·Contact
Advanced Challenges · Part 2

Debug a broken agent

You're handed an agent that misbehaves — it loops forever, calls the wrong tool, answers as if it never saw the tool result, blows its token budget, or reaches for a destructive action. Your job is to diagnose it systematically: is it the loop, the tool schemas, the context wiring, or the missing safety gate? Each failure below ships as runnable Python that reproduces the bug — and the one-line fix that ends it.

⏱️ ~2 hours🧪 5 challenges🎯 Advanced

Learning objectives

  • Diagnose a misbehaving agent by isolating the loop, tools, context, and gate.
  • Kill an infinite loop with a step cap and a no-progress detector.
  • Fix wrong-tool routing by sharpening tool descriptions, not the prompt.
  • Spot a dropped/mis-paired tool result that makes the agent 'ignore' its tools.
  • Bound an unbounded transcript so the agent stops blowing its context budget.
  • Insert the allow/ask/block gate that stops an unsafe action before it runs.

Every lab below is a self-contained script you can run with python3. The agent loop is modelled with a scripted fake model — a tiny class that returns pre-set tool calls — so the failure is deterministic and reproducible without an API key. Read each broken run, form a hypothesis, then check it against the fix.

0 · The diagnosis decision tree advanced

You've almost certainly done this: the agent misbehaves, so you open the system prompt and start rewording it. You add "always finish your task", you add "use the right tool", you add "be careful with destructive actions" — and you re-run, and it fails a slightly different way, and you reword again. An hour later the prompt is a wall of pleading and the bug is still there. The reason this feels like whack-a-mole is that most agent failures are not prompt failures. They're failures in the scaffolding around the model — the loop, the tool schemas, the message wiring, the safety gate — and no amount of prompt prose fixes a broken loop.

Think of an agent the way a mechanic thinks about a car that won't start. The mechanic doesn't randomly swap parts; they follow a fixed sequence — fuel, spark, compression — because that order isolates the fault fastest. An agent has the same layered anatomy, and the failures have the same layered causes. There are really only four places a request can break on its way to a good answer, plus one place it can do harm: it can spin without ending (the loop), it can pick the wrong action (the tool schemas), it can fail to see what its own tools returned (the message wiring), it can outgrow the window (the context budget), or it can run something it never should have (the safety gate).

The discipline this section teaches is to stop guessing and instead walk those layers in one fixed order every single time, ruling each out with a single cheap check before moving on. A symptom maps to a layer: "it hangs" is the loop, "it did the wrong thing" is routing or the gate, "it acts like the tool never ran" is wiring, "it dies on long runs" is the budget. The common mistake — the one that costs the hour — is jumping straight to the layer you suspect instead of the layer the symptom points to, and skipping the check that would have cleared an earlier layer in seconds. The tree below is that checklist. Read it not as trivia but as the debugging order you'll internalize: left to right, first match wins, name the layer, then open that section.

Don't debug an agent by re-reading the prompt and hoping. Walk the failure down a fixed decision tree — the same order every time — until you can name the broken layer:

Misbehaving symptom Looping? no cap / no progress Wrong tool? bad description/schema Ignores result? result not fed back Unsafe action? no risk gate Pinpointed fix the layer
🗺️ How to read this diagram

This is the order you should debug an agent in — not a random poke at the prompt. Start at the left with the symptom and move right, ruling out one layer at a time until a box matches.

  • Looping? — the agent never returns. Check the loop layer: is there a hard step cap, and a no-progress check so a stuck model stops early? (§1)
  • Wrong tool? — the loop is fine but it picks badly. Check the tool schemas: are the descriptions vague or overlapping so two tools look the same? (§2)
  • Ignores result? — it calls a tool then acts as if it never ran. Check the wiring: was the result appended, and does its tool_use_id match the request? (§3)
  • Dies mid-run? — a long run overflows. Check context management: the transcript is growing unbounded (§4). Unsafe action? — it did something destructive; there was no gate between intent and action (§5).

In short: One symptom points at one layer. You never need to guess — walk left to right, and the first box that matches your symptom is the layer to open.

Read it left to right as a checklist. Looping? → check for a step cap and a no-progress guard (§1). Right tool but wrong choice? → the tool descriptions are vague or overlapping (§2). Calls a tool then acts like it never ran? → the result isn't being fed back with the right id (§3). Dies mid-run? → the transcript grew past the budget (§4). Did something destructive? → there was no gate between intent and action (§5). One symptom, one layer, one fix.

1 · Symptom: it never stops advanced

Here's the moment you'll remember: you launch the agent, glance away, and come back to a terminal still scrolling tool calls — or worse, a cloud bill that quietly climbed while it looped all night. An agent that never stops is the single most common way a first agent goes wrong, and it's frightening precisely because nothing looks broken. Each individual step is valid: the model asks for a tool, the tool runs, the result comes back. The bug is that the sequence has no ending, and a plain while loop will happily obey that forever.

To see why, remember what an agent loop actually is: it's a normal program loop where the "next instruction" is chosen by a model instead of written by you. In an ordinary loop you control the exit condition — while not done, and you know exactly when done flips. In an agent loop, whether the model ever emits a final answer instead of another tool call is outside your control. So you must supply the termination guarantee yourself, from the outside. That is the whole lesson of this section, and it splits into two distinct guards that people constantly conflate.

The common mistake is shipping only the cap and calling it fixed. The cap makes the process terminate, so the hang disappears from your demo — but the underlying stuck loop is still there, now hidden behind "it eventually returned after 8 calls." A cap bounds the damage; the progress check bounds the waste, and it turns a silent budget-burn into an early, explainable stop you can log and alert on. You want both. Watch the scripted model below get deliberately stuck on one tool, and note how each guard changes the outcome.

The agent runs, calls a tool, calls it again, and again — the process pins a CPU and never returns. Two root causes, almost always: the loop has no hard step cap, and it has no no-progress check, so a model that keeps asking for the same thing spins forever. Here a scripted model is deliberately stuck on one tool:

same stuck model (re-asks get_pods forever) → two very different loops stuck model re-asks get_pods BROKEN · while True no cap, no progress check re-runs the same call… bails only at harness budget → 20 laps wasted FIXED · two guards (1) range(max_steps) → always ends (2) seen set of (tool, args) repeat = no progress → stop now → stops at step 1 A cap bounds the damage; a progress check bounds the waste. The same stuck model burns all 20 harness laps in the bare while True, but the capped loop with a seen set catches the repeated call and stops at step 1.
🗺️ How to read this diagram

Start at the top box — one stuck model that keeps re-asking for get_pods — and follow the two arrows down into the two loops it is fed to.

  • The red box on the left is the broken while True loop: no cap and no progress check, so it re-runs the identical call and only bails when the test harness trips its budget — 20 wasted laps.
  • The green box on the right is the fixed loop with two guards: guard (1), a for _ in range(max_steps) hard cap that always terminates; and guard (2), a seen set of (tool, args) signatures that spots the repeat.
  • The payoff is the last line of each box: same model, but the guards turn "20 laps wasted" into "stops at step 1" — the moment the model repeats itself with no new information.

In short: the cap guarantees the loop ends; the no-progress check makes it end early when the model is spinning.

Example code for learning — review, test, and adapt it before running against real or production systems. Commands can create, change, or delete resources. See the Terms & Disclaimer.
Python · reproduce the runaway, then bound it (runs)
loop_fix.py# A fake model: returns SCRIPTED tool calls, so the loop is deterministic.
class FakeModel:
    """Scripted stand-in for a real LLM. .respond() returns either a tool call
    or a final {'text':...}. If the script runs out it repeats the LAST step
    forever -- modelling a model that gets stuck."""
    def __init__(self, script):
        self.script, self.i = list(script), 0
    def respond(self, messages):
        step = self.script[min(self.i, len(self.script) - 1)]
        self.i += 1
        return step

def get_pods(namespace="staging"):
    return [{"name": "checkout-api", "status": "CrashLoopBackOff"}]
TOOLS = {"get_pods": get_pods}

STUCK = [{"tool": "get_pods", "input": {}}]   # model never answers, just re-asks

# BROKEN: `while True`, no cap, no progress check
def run_broken(model, budget=20):
    messages, calls = [{"role": "user", "content": "what's wrong?"}], 0
    while True:                       # no termination guarantee
        step = model.respond(messages)
        if "text" in step:
            return step["text"], calls
        calls += 1
        out = TOOLS[step["tool"]](**step["input"])
        messages.append({"role": "tool", "content": str(out)})
        if calls >= budget:           # only a test-harness safety net
            return "!! runaway: never terminated on its own", calls

# FIXED: hard step cap + no-progress detector
def run_fixed(model, max_steps=8):
    messages, seen = [{"role": "user", "content": "what's wrong?"}], set()
    for step_no in range(max_steps):          # (1) hard cap -- always terminates
        step = model.respond(messages)
        if "text" in step:
            return step["text"], step_no
        sig = (step["tool"], str(step["input"]))
        if sig in seen:                        # (2) repeated call = no progress
            return f"stopped: repeated {step['tool']} with no new info", step_no
        seen.add(sig)
        out = TOOLS[step["tool"]](**step["input"])
        messages.append({"role": "tool", "content": str(out)})
    return "stopped: hit step cap", max_steps

print("BROKEN:", run_broken(FakeModel(STUCK)))
print("FIXED :", run_fixed(FakeModel(STUCK)))
BROKEN: ('!! runaway: never terminated on its own', 20)
FIXED : ('stopped: repeated get_pods with no new info', 1)
▶ How this works

This lab reproduces the classic hang and then bounds it. The FakeModel is a scripted stand-in for a real LLM: it replays a list of tool calls, and when the script runs out it repeats the last step forever — which is exactly how a stuck model behaves. No API key, fully deterministic.

  1. run_broken is a bare while True. It has no way to stop on its own — the only reason it returns at all is the budget=20 safety net baked into the test harness. In a real agent that while True pins a CPU and never returns.
  2. run_fixed adds guard (1): for step_no in range(max_steps) is a hard cap — the loop can iterate at most 8 times, so it always terminates.
  3. It adds guard (2): a seen set of (tool, args) signatures. The moment the model re-issues an identical call it has made no progress, so the loop stops immediately — at step 1 here, not step 8.

What the output means: The broken run reports never terminated on its own after burning all 20 harness laps; the fixed run stops at step 1 with repeated get_pods with no new info. Same stuck model, two very different outcomes.

Try this: Give the fixed loop a script that actually finishes — e.g. [{'tool':'get_pods','input':{}}, {'text':'diagnosis'}] — and watch it return the answer before hitting either guard. The guards only fire when something is wrong.

The broken loop is a bare while True — it only stops because the test harness trips a runaway budget at 20 calls; on its own it would never terminate. The fixed loop adds two guards: a for _ in range(max_steps) hard cap that always terminates, and a seen set that detects the model re-issuing an identical call with no new information and stops at step 1. A cap alone bounds the damage; the progress check catches the loop early.

A cap bounds cost; a progress check bounds wasteThe step cap is your non-negotiable seatbelt — it guarantees termination and caps spend. But an agent that hits the cap every run is still broken: it's doing 8 useless laps. The no-progress detector (same tool + same args = no new state) is what turns "it eventually stopped" into "it stopped as soon as it was stuck."
✓ Knowledge check

An agent always terminates — it never hangs — but every run costs 8 tool calls and the answer is no better than after 2. The step cap is 8. What's still wrong, and what do you add?

Show answer
The step cap is doing its job (termination) but it's masking a stuck loop: the model is making no progress yet burns the whole budget every time. Add a no-progress check — track a signature of each (tool, args) call (or the state hash) and stop when it repeats, as in the seen set. That converts a silent 8-lap waste into an early, explainable stop.

2 · Symptom: it calls the wrong tool advanced

You ask the agent to fetch the error logs, and it lists pods instead. Your instinct — everyone's instinct — is that the model is being dumb, so you march to the system prompt and add "when the user asks for logs, use the get_logs tool." It helps for one query, then breaks on the next phrasing. What's really happening is subtler and more fixable: the model didn't ignore your instruction, it followed the tool descriptions, and those descriptions were lying to it.

The key mental shift is this: a tool description is not documentation, it is instruction. When the model decides which tool to call, the tool names and descriptions are the primary signal it routes on — far more than a line buried in a long system prompt. The model is, in effect, matching the user's request against each tool's advertised job and picking the best fit. If two tools describe overlapping jobs, or one tool's description leaks a keyword that belongs to another ("get pod info including recent logs"), then a request for logs genuinely looks like a match for the pods tool. The routing is working correctly on bad inputs.

Picture a hardware store where two aisles are both signed "tools and supplies." A customer looking for paint wanders into the wrong one — not because they can't read, but because the signs don't distinguish. The fix isn't to lecture the customer; it's to rewrite the signs so each names a distinct thing and, ideally, when not to go there. Good tool descriptions do exactly that: each names one clear job and the trigger for reaching for it ("Use to read the actual error"), and no two read as substitutes. The strongest versions even state the boundary — "never call this to check status" — so a destructive tool can't be selected for a read.

The common mistake, then, is debugging routing at the prompt when the bug lives in the schema. Before you touch the system prompt, diff the tool descriptions: hunt for overlapping verbs ("get info about…", "get data for…"), leaked keywords, and vague scope. In the lab below nothing about the loop or the model changes between the broken and fixed runs — only the description text does, and that alone flips the routing from wrong to right.

The loop is fine; the agent just chooses badly — asked for logs, it lists pods instead. The reflex is to rewrite the system prompt. The real fix is usually one layer down: the model routes on the tool descriptions, and vague or overlapping descriptions make two tools look interchangeable. We model routing the way a model does it — scoring the request against each tool's description:

request: "show me the recent error logs for the checkout pod" score request against each tool description VAGUE · overlapping get_pods: "…including logs" get_logs: "get data for a pod" "logs" leaks into get_pods → more overlap with pods routes to get_pods ✗ SHARP · disjoint get_pods: which pods? get_logs: read the error each names ONE job + when no shared keywords routes to get_logs ✓ The description is the routing logic, not the prompt. Same request, same loop, same model — only the description text differs, and that alone flips routing from the wrong tool to the right one.
🗺️ How to read this diagram

Start at the top: the request is scored against every tool's description, then follow the two arrows down to the two registries you could ship.

  • The red box on the left is the VAGUE registry: the descriptions overlap and get_pods even leaks the word "logs", so the logs request overlaps the pods tool more and routes to get_pods — wrong.
  • The green box on the right is the SHARP registry: each description names one distinct job and says when to use it, with no shared keywords, so the logs request lands on get_logs — right.
  • The payoff is the last line of each box: nothing about the loop or the model changed between them; editing the description text alone flipped the routing.

In short: when an agent picks the wrong tool, diff the descriptions before you touch the prompt — overlap and leaked keywords are the bug.

Python · reproduce mis-routing, then sharpen descriptions (runs)
route_fix.pyimport re
def words(s):
    return set(re.findall(r"[a-z]+", s.lower()))

def route(request, tools):
    """tools: {name: description}. The model picks the tool whose DESCRIPTION
    best overlaps the request -- so the description IS the routing logic."""
    req = words(request)
    scored = sorted(tools.items(),
                    key=lambda kv: (len(req & words(kv[1])), kv[0]),
                    reverse=True)
    return scored[0][0]

REQUEST = "show me the recent error logs for the checkout pod"

# BROKEN: vague, overlapping descriptions; get_pods even leaks the word 'logs'
VAGUE = {
    "get_pods": "get pod info including recent logs and status",
    "get_logs": "get data for a pod",
}
print("BROKEN routes to:", route(REQUEST, VAGUE))   # wrong: get_pods steals 'logs'

# FIXED: sharp, disjoint descriptions that say WHEN to use each tool
SHARP = {
    "get_pods": "List pods and their status. Use to see WHICH pods are unhealthy.",
    "get_logs": "Fetch recent error log lines for one pod. Use to read the actual error.",
}
print("FIXED routes to :", route(REQUEST, SHARP))
BROKEN routes to: get_pods
FIXED routes to : get_logs
▶ How this works

The loop here is perfect; the bug is that the agent chooses the wrong tool. This lab models how a model actually routes: it scores the request against each tool's description and picks the best overlap. So the descriptions, not the prompt, decide the routing.

  1. route turns the request and each description into sets of words and picks the tool with the most words in common. This is a crude stand-in for how a model reads tool descriptions to decide which to call.
  2. In VAGUE the descriptions are mushy and get_pods even contains the word logs. A request for error logs therefore overlaps the pods tool more — so it routes to the wrong tool.
  3. In SHARP nothing about the loop or model changed. The descriptions now name a distinct job and say when to use each ("Use to read the actual error"), so the logs request lands on get_logs.

What the output means: BROKEN routes to: get_pods (wrong) vs FIXED routes to: get_logs (right) — the fix was editing text, not code.

Try this: Add a third tool with an overlapping description (say get_events as "get pod data") and watch routing get worse. Then make each description disjoint and it recovers. Overlap is the enemy of routing.

In the broken registry both descriptions are mushy and get_pods even leaks the word "logs" — so a request for logs routes to the pods tool. Nothing about the loop or the model changed in the fix: only the descriptions did. Sharp, disjoint descriptions that say when to use each tool ("Use to read the actual error") send the request to the right place. The tool description is part of the model's instructions, not documentation.

Debug routing at the schema, not the promptWhen an agent picks the wrong tool, diff the tool descriptions before you touch the system prompt. Overlapping verbs ("get info about…", "get data for…") and leaked keywords are the usual culprits. Each tool's description should name a distinct job and the trigger for it; two tools should never read as substitutes.

3 · Symptom: it ignores the tool result expert

This one drives engineers to the edge, because your own eyes contradict the agent. You watch the tool run. You see it print the perfect answer to your logs. And the model's very next turn asks for that exact same tool again — or shrugs and answers as if it learned nothing. It looks like the model is stubbornly ignoring reality, and that framing sends people down the wrong road: they add prompt lines like "pay attention to the tool result." That never works, because the model isn't ignoring the result. It never received it.

To debug this you have to hold one fact firmly: the model has no memory and no access to your program's variables — it can only see the transcript you send it. Your Python function returned a value, your logs printed it, but none of that reaches the model unless you explicitly append that result into the messages list before the next call. If the append is missing, the model's view of the world is unchanged from before the tool ran, so of course it re-issues the same call. From the model's side, the tool never happened.

There's a second, sneakier version. In tool-use protocols each tool call the model emits carries a unique id (a tool_use_id), and the result you feed back must be tagged with that same id so the model can match answer to question. Think of it like a coat check: you hand over your coat and get ticket #42; when you come back, a coat tagged #999 is useless to you even though it's a real coat on the rack. If the result is appended with the wrong id, the model scans the transcript for a result matching the request it made, finds none, and re-asks. Both bugs — a missing append and a mismatched id — present identically as "it ignores the tool result," which is why you should always check the wiring before you blame the model.

The durable fix is three small rules that together keep the loop honest: append the assistant's turn to the transcript verbatim, pair every tool result with the exact tool_use_id of the call it answers, and return all of a turn's results in one user message. Get those right and the "ignoring" symptom vanishes — as the lab shows, a one-line id fix takes the agent from giving up after five wasted steps to naming the root cause on step one.

The agent calls the tool, the tool clearly returns the answer — and the model asks for the same tool again, or answers blind. The model can only use what's in the transcript, so this is almost always a wiring bug: the result was never appended, or it was appended with the wrong tool_use_id, so the model can't match the result to the request it made. Here the result is fed back with a mismatched id:

model asks get_logs tool_use id = call_1 BROKEN · id mismatch tool ran, got the logs append tool_use_id=wrong_id model scans for call_1 no match → re-asks → gives up at step 5 FIXED · ids paired tool ran, got the logs append tool_use_id=call_1 model finds call_1 matches result to request → answers at step 1 The tool ran in both branches — only the id pairing differed. A coat-check ticket: the model wants the result tagged with the same id it handed out (call_1); a result tagged wrong_id is useless even though it is a real result.
🗺️ How to read this diagram

Start at the top box — the model emits a tool call with id call_1 — then follow the two arrows down to the two ways the loop can wire the result back.

  • The red box on the left is the wiring bug: the tool ran and returned the logs, but the loop appends the result tagged wrong_id. The model scans the transcript for its id, call_1, finds nothing, and re-asks — looking exactly like it "ignored" the result.
  • The green box on the right pairs the result with tool_use_id = call_1, the same id the model used, so the model matches answer to question on the next pass.
  • The payoff is the last line: the tool executed in both branches — the only difference is the id tag, which is why "it ignores the result" is a wiring bug, not a model bug.

In short: a dropped append or a mismatched id both read as "it ignores the tool result" — check the transcript wiring before you blame the model.

Python · reproduce the dropped result, then pair the id (runs)
result_fix.pyclass FakeModel:
    """Requests get_logs, then looks for a tool_result carrying the SAME id it
    used. If it can't find it, it re-requests -- the 'ignoring' symptom."""
    def __init__(self):
        self.pending_id = None
    def respond(self, messages):
        if self.pending_id is None:
            self.pending_id = "call_1"
            return {"tool": "get_logs", "input": {"pod": "checkout"}, "id": "call_1"}
        for m in messages:
            if m.get("role") == "tool" and m.get("tool_use_id") == self.pending_id:
                return {"text": f"Root cause found in logs: {m['content']}"}
        return {"tool": "get_logs", "input": {"pod": "checkout"}, "id": "call_1"}

def get_logs(pod):
    return "ERROR: password authentication failed for user 'checkout'"

def run(model, wire_result_correctly, max_steps=5):
    messages = [{"role": "user", "content": "why is checkout down?"}]
    for step_no in range(max_steps):
        step = model.respond(messages)
        if "text" in step:
            return step["text"], step_no
        result = get_logs(**step["input"])
        if wire_result_correctly:
            # correct: pair the result with the SAME id the model used
            messages.append({"role": "tool", "tool_use_id": step["id"], "content": result})
        else:
            # bug: mismatched id -> the model can never find its own result
            messages.append({"role": "tool", "tool_use_id": "wrong_id", "content": result})
    return "!! gave up: never saw its own tool result", max_steps

print("BROKEN:", run(FakeModel(), wire_result_correctly=False))
print("FIXED :", run(FakeModel(), wire_result_correctly=True))
BROKEN: ('!! gave up: never saw its own tool result', 5)
FIXED : ("Root cause found in logs: ERROR: password authentication failed for user 'checkout'", 1)
▶ How this works

The agent calls a tool, the tool clearly returns the answer, and the model asks for the same tool again — the "it ignores the result" symptom. The lab shows this is a wiring bug: the model can only use what's in messages, paired with the right id.

  1. The FakeModel requests get_logs with id call_1, then on later turns scans messages for a tool_result carrying that id. If it finds it, it answers; if not, it re-asks.
  2. The broken run appends the result with tool_use_id="wrong_id". The model never finds a result tagged call_1, so it loops and finally gives up — looking for all the world like it "ignored" a tool it actually ran.
  3. The fix is one line: append with tool_use_id = step["id"], the same id the model used. Now the result is matched on the next pass and the model reports the root cause.

What the output means: BROKEN gives up after 5 wasted steps; FIXED reports the authentication error from the logs on step 1. The tool ran in both cases — only the id pairing differed.

Try this: Change the bug to a missing append entirely (skip adding the tool message) and observe the same symptom. Both "wrong id" and "never appended" present identically — always check the transcript wiring first.

The scripted model requests get_logs with id call_1, then looks for a tool_result carrying that id. In the broken run the loop tags the result wrong_id, the model never finds its answer, and it re-asks until it gives up. The fix is one line: append the result with tool_use_id = step["id"] — the same id the model used. Now it finds the logs on the next pass and reports the root cause. The three loop rules are: append the assistant turn verbatim, pair every result with its tool_use_id, and return all results in one user turn.

✓ Knowledge check

An agent calls a tool, you can see the tool ran and printed a good result to your logs — yet the model's next turn asks for the exact same tool again. The loop has a step cap and the descriptions are sharp. Where do you look?

Show answer
At how the result is fed back into messages. The model didn't "ignore" anything — it never received the result in a form it could use. Check: (1) is the tool_result actually appended to the transcript before the next model call? (2) does its tool_use_id exactly match the id of the tool_use block the model emitted? A missing append or a mismatched id both present as "it ignores the tool result."

4 · Symptom: it runs out of context / budget expert

This is the bug that passes every test you write and then detonates in production. Your short demos work. Your unit tests, which run two or three steps, are green. Then a real incident comes in, the agent works it for twenty steps, and on step twenty it dies with a context-length error — or the cost graph spikes because that final request re-sent nineteen steps of stale logs. The cruel part is the timing: it fails on the long run, which is exactly the run that mattered most.

The cause is structural, and once you see it you can't unsee it. Every pass of the loop appends two things to the transcript — the model's turn and the tool's result — and nothing is ever removed. So the transcript grows monotonically. Since you re-send the entire transcript on every call (that's how the model gets its "memory"), the request on step 20 carries steps 1 through 19 along with it, most of which are long-dead log dumps the model no longer needs. You're paying to re-transmit history, and eventually that history overflows the fixed context window.

The reframe that fixes it: the transcript is a buffer you manage, not a log you let grow. A log is append-only by definition; a working buffer has a budget and you decide what stays in it. The standard technique is a rolling window with a summary — keep the first message (the task, which anchors everything) and the last few turns verbatim (the recent state the model is actively reasoning over), and collapse everything in the middle into a short summary. That keeps the two things that matter — the goal and the present — at full fidelity while the stale middle is compressed to a line.

The common mistake is treating unbounded history as a style nit to clean up "later." It isn't a nit; it's a latent bug that hides through every short test and surfaces only under load. Bound the transcript from day one — a window plus a running summary — and, as the lab shows, a run that overflowed at step 4 finishes all 12 steps with its peak comfortably under budget.

The agent works for a few steps, then a long run dies with a context-length error (or the per-request cost balloons). The cause is structural: every pass appends the model turn and the tool result, so the transcript grows without bound. On step 20 you're re-sending 19 steps of stale logs. The fix is to bound the history — keep the task and the recent turns, summarize the middle:

BROKEN · append forever 200-tok window step 4: 212 > 200 ✗ nothing removed → overflow FIXED · window + summary head middle → 1 line last N turns keep task + recent verbatim collapse the stale middle peak 105 tok, all 12 steps ✓ The transcript is a buffer you manage, not a log you let grow. Left: unremoved turns pile up and blow the window at step 4. Right: keep the head + tail verbatim and summarize the middle, so peak size stays flat regardless of run length.
🗺️ How to read this diagram

Read left to right: the broken transcript on the left, the bounded one on the right, both against the same 200-token window line.

  • On the left, each bar is one more step's turn+result appended and never removed, so the stack grows until the last (red) bar crosses the window line — that is the overflow at step 4.
  • On the right, the transcript is three fixed pieces: the green head (the task, kept verbatim so the agent never forgets its goal), the muted middle collapsed to a single summary line, and the green last N turns (the live working set, kept verbatim).
  • The payoff is the bottom line: because only the middle grows and it is compressed to one line, peak size stays flat — 105 tokens across all 12 steps instead of overflowing.

In short: keep the goal and the present at full fidelity, summarize the stale middle, and the transcript stops growing without bound.

Python · reproduce the overflow, then trim the history (runs)
budget_fix.pyWINDOW = 200   # pretend token budget for the whole request

def est_tokens(messages):
    return sum(len(str(m["content"])) for m in messages) // 4  # ~1 tok / 4 chars

def big_tool_result(step):
    return f"log page {step}: " + "x" * 120

# BROKEN: append everything, forever
def run_broken(steps=12):
    messages = [{"role": "user", "content": "diagnose the incident " * 3}]
    for s in range(steps):
        messages.append({"role": "assistant", "content": f"calling get_logs (step {s})"})
        messages.append({"role": "tool", "content": big_tool_result(s)})
        if est_tokens(messages) > WINDOW:
            return f"!! context overflow at step {s}: {est_tokens(messages)} > {WINDOW} tok"
    return "finished"

print("BROKEN:", run_broken())

# FIXED: keep the task + recent turns, summarize the middle
def trim(messages, keep_recent=4):
    if len(messages) <= keep_recent + 1:
        return messages
    head, middle, tail = messages[:1], messages[1:-keep_recent], messages[-keep_recent:]
    summary = {"role": "assistant",
               "content": f"[summary of {len(middle)} earlier turns omitted]"}
    return head + [summary] + tail

def run_fixed(steps=12):
    messages, peak = [{"role": "user", "content": "diagnose the incident " * 3}], 0
    for s in range(steps):
        messages.append({"role": "assistant", "content": f"calling get_logs (step {s})"})
        messages.append({"role": "tool", "content": big_tool_result(s)})
        messages = trim(messages)                 # bound the history every pass
        peak = max(peak, est_tokens(messages))
    return f"finished all {steps} steps; peak {peak} tok stayed under {WINDOW}"

print("FIXED :", run_fixed())
BROKEN: !! context overflow at step 4: 212 > 200 tok
FIXED : finished all 12 steps; peak 105 tok stayed under 200
▶ How this works

This lab reproduces the overflow that only shows up on long runs. Every pass appends the model turn and the tool result, so the transcript grows without bound and eventually blows the context window — modelled here as a 200-token budget.

  1. est_tokens is a crude token estimate (~1 token per 4 characters). run_broken appends a chunky log result every step and checks the running total against WINDOW.
  2. The broken loop overflows at step 4: nothing is ever removed, so old log pages pile up and the budget is blown well before the run finishes.
  3. trim keeps the first (task) message and the last keep_recent turns verbatim, and collapses the middle into a single summary line. Calling it every pass bounds the transcript no matter how long the run.

What the output means: BROKEN overflows at step 4 (212 > 200 tok); FIXED finishes all 12 steps with a peak of 105 tokens — comfortably under budget.

Try this: Raise steps to 100 and re-run. The broken loop overflows sooner in relative terms; the fixed loop's peak barely moves — that flat memory profile is the whole point of bounding the history.

The broken loop overflows a 200-token pretend window at step 4 because nothing is ever removed. The fix keeps the first (task) message and the last few turns verbatim and collapses everything in between into a one-line summary, so the transcript stays bounded — peak 105 tokens across all 12 steps. Real systems summarize with a cheap model instead of a stub string, but the shape is identical: the transcript is a buffer you manage, not a log you let grow.

Unbounded history is a latent bug, not a style nitIt passes every short test and every demo, then fails on the one long incident that matters — exactly when you need the agent most. Bound the transcript from day one: a rolling window plus a running summary. The same discipline as context engineering (PE2) — you're budgeting the window across task, history, and tool output.

5 · Symptom: it takes an unsafe action tech-lead

Every failure so far has cost you time or money. This one can cost you a production system. A confused agent, reaching for anything that might help, calls the destructive tool — delete_pod, cancel_order, send_email to 400 customers — and the loop, dutifully, just runs it. There is no undo. This is the failure that keeps agents out of production, and it's the one that separates a demo from a system you'd trust near real infrastructure.

The seductive wrong answer is to handle it in the prompt: "you are not allowed to delete anything without permission." Internalize why that is not a control at all. A prompt instruction is a request to a probabilistic system — the model complies most of the time, which is exactly the trap, because "most of the time" is not a safety property. A model that's confused, jailbroken, or simply misreading an ambiguous instruction will ask for the destructive tool anyway, and a prompt cannot stop it because the prompt is advice, not a barrier. Asking the model to police itself is like asking a driver to promise not to speed instead of installing a speed limiter.

The only real control is a gate in code — a hard check that sits between "the model wants this tool" and "the tool actually runs," outside the model's influence entirely. And the gate shouldn't be a single yes/no; it should be a small decision table keyed on two things:

The design invariant to burn in: irreversible actions are blocked at every rung by default. That single rule, enforced in a table you can test without the model, is what makes an agent safe near real systems. Note too that a blocked verdict isn't a dead end — it's structured data the loop can hand back to the model ("that action was refused"), so the agent can adapt and propose a safe alternative (open a PR instead of deleting) rather than failing silently. The lab below runs the unsafe delete with no gate, then shows the same request refused the instant a gate is in place.

The most dangerous failure: the agent, when confused, reaches for a destructive tool and the loop just runs it. "I told the model in the prompt not to delete things" is not a control — a hijacked or simply mistaken model will ask anyway. The only real control is a gate in code between "model wants tool" and "tool runs", keyed on the tool's risk class and the agent's autonomy rung:

model wants a tool gate(risk, rung) table lookup, in code allow read-only · run it ask reversible · confirm block irreversible · refuse irreversible = block at EVERY rung (the invariant) The gate sits between intent and action, outside the model. A pure (risk × rung) lookup returns allow / ask / block — and the irreversible row is block at every rung, so no prompt the model emits can talk its way into a delete.
🗺️ How to read this diagram

Follow the arrow left to right: the model's requested tool enters the gate, and the gate — not the model — decides the verdict.

  • The left box is the model's request. It never runs directly; every call is routed through the gate first.
  • The amber gate is a pure table lookup keyed on two things: the tool's risk class (read-only / reversible / irreversible) and the agent's autonomy rung. It lives in code, outside the model's influence.
  • The three verdicts on the right are the payoff: allow (green — read-only runs), ask (amber — reversible needs human confirmation), and block (red — irreversible is refused). The bottom line is the invariant: irreversible is block at every rung.

In short: a prompt is a request the model can ignore; a gate is a barrier in code the model can't cross — which is why irreversible actions stay blocked.

Python · reproduce the unsafe delete, then gate it (runs)
gate_fix.pyRISK = {"get_pods": "read_only", "restart": "reversible", "delete_pod": "irreversible"}

# scripted model: investigates once, then (wrongly) tries to delete the pod
SCRIPT = [
    {"tool": "get_pods", "input": {}},
    {"tool": "delete_pod", "input": {"name": "checkout-api"}},
    {"text": "done"},
]

def run_pod_tool(tool, args):
    return f"EXECUTED {tool}({args})"

# BROKEN: no gate -- runs every requested tool
def run_broken():
    performed = []
    for step in SCRIPT:
        if "text" in step:
            break
        run_pod_tool(step["tool"], step["input"])       # runs unconditionally
        performed.append(step["tool"])
    return performed

print("BROKEN performed:", run_broken())   # includes the irreversible delete!

# FIXED: gate(risk, rung) -> allow / ask / block
MATRIX = {   # rung -> {risk: verdict}
    "observe": {"read_only": "allow", "reversible": "block", "irreversible": "block"},
    "act":     {"read_only": "allow", "reversible": "ask",   "irreversible": "block"},
}
def gate(tool, rung):
    return MATRIX[rung][RISK[tool]]

def run_fixed(rung="observe", approve=lambda t: False):
    audit = []
    for step in SCRIPT:
        if "text" in step:
            break
        verdict = gate(step["tool"], rung)
        allowed = verdict == "allow" or (verdict == "ask" and approve(step["tool"]))
        if allowed:
            run_pod_tool(step["tool"], step["input"])
        audit.append(f"{'OK ' if allowed else 'NO '} {step['tool']} [{RISK[step['tool']]}] -> {verdict}")
    return audit

print("FIXED audit:")
for line in run_fixed():
    print("  ", line)
BROKEN performed: ['get_pods', 'delete_pod']
FIXED audit:
   OK  get_pods [read_only] -> allow
   NO  delete_pod [irreversible] -> block
▶ How this works

The most dangerous failure, and the climax of the lesson. The scripted model, when confused, asks to delete_pod — an irreversible action. A loop with no gate simply runs it. The fix puts a decision table between the model's request and the tool call.

  1. run_broken executes every step in the script unconditionally. Its output ['get_pods', 'delete_pod'] means the irreversible delete actually ran — no prompt instruction would have stopped it.
  2. gate(tool, rung) is a pure lookup in MATRIX: given the tool's risk class and the agent's autonomy rung, it returns allow, ask, or block. It's outside the model, so nothing the model says can change the verdict.
  3. run_fixed checks the gate before running anything. At the observe rung read-only is allowed and the irreversible delete is blocked and recorded as NO in the audit trail — the action never happens.

What the output means: The broken run performs the delete; the fixed run's audit shows OK get_pods [read_only] and NO delete_pod [irreversible] → block. Same model, same request, but the gate refused the dangerous one.

Try this: Look down the irreversible column of MATRIX: it's block at every rung. Add an autonomous rung that keeps that column blocked — that single invariant is what makes the agent safe near real systems.

The broken loop has no gate, so when the scripted model asks to delete_pod it runs — an irreversible action, no questions asked. The fixed loop routes every call through gate(tool, rung), a pure lookup of (risk × rung) → allow / ask / block. At the OBSERVE rung read-only is allowed and the irreversible delete is blocked, recorded in the audit trail as NO. Note the blocked verdict is data the loop can hand back to the model, so it can adapt (propose a PR) instead of failing silently.

Bound the loop, gate the actionsThese two properties are what separate a demo agent from one you'd let near real systems. Bound the loop: a hard step cap plus a no-progress check guarantee it terminates and doesn't waste the budget. Gate the actions: a risk × rung table, checked in code outside the model, guarantees an irreversible action is refused no matter what the model asks. Everything else in this lesson is a wiring bug; these two are the invariants.

6 · Grade your agent debugging tech-lead

Here's the difference that gets people promoted, and it shows up in how they close a bug. A competent engineer fixes the agent in front of them: they add the step cap, sharpen the description, pair the id, and the ticket closes. A lead does that and asks a second question — "how do I make sure this class of bug can never come back?" — because they've learned that the same failure recurs in the next agent, and the one after that, unless the fix is turned into a constraint the codebase enforces.

That second move is what turns each section of this challenge from a one-off repair into a durable invariant. A step cap is a fix; a cap plus a no-progress detector, with a test asserting a stuck model stops within two steps, is an invariant. A rewritten description is a fix; making all descriptions disjoint and adding a test that asserts request→tool routing is an invariant. A corrected tool_use_id is a fix; routing every tool result through one code path so ids can't drift again is an invariant. The pattern is always the same — close the bug class, not just the ticket — and the deepest version of it is a regression test aimed at the deterministic scaffolding (the cap, the gate, the id pairing), because that logic runs with no API key and the model can't argue its way past a mechanical check. Score your own diagnosis against the bar below, and read the right-hand column as the habit to build toward.

Score your diagnosis of any broken agent against this bar. "Meets" is a correct fix; "above bar" is what a lead does — they fix the instance and close the class of bug so it can't recur.

fix the instance ticket closes add regression test no API key · hits the cap / gate / id pairing invariant class closed a mechanical check can't be argued out of by the model Close the bug class, not just the ticket. A fix removes one instance; a regression test aimed at the deterministic scaffolding — cap, gate, id pairing — makes the whole class un-repeatable, and it runs with no API key.
🗺️ How to read this diagram

Read left to right: it is the extra move that separates a competent fix from a lead's fix.

  • The left box is what everyone does — patch the instance in front of you and the ticket closes. Correct, but the same bug class returns in the next agent.
  • The amber middle box is the extra step: write the smallest regression test, aimed at the deterministic scaffolding (the step cap, the gate, the tool_use_id pairing). It needs no API key because it exercises the loop logic, not the model.
  • The green right box is the payoff: the fix becomes an invariant the codebase enforces, so the class of bug can't recur. The bottom line is why — a mechanical check is a hard constraint the model can't argue past.

In short: "meets the bar" fixes the instance; "above the bar" turns the fix into a test-enforced invariant that closes the class.

📋 Grade your agent debugging
DimensionMeets the barAbove the bar (tech-lead)
Loop terminationAdded a step cap so the loop always ends.Cap and a no-progress detector; the cap is an alarm, not the normal exit.
Tool routingRewrote the description so the right tool is chosen.Made all descriptions disjoint; added a test that asserts request→tool routing.
Result wiringFixed the missing append / mismatched tool_use_id.One code path builds every tool_result, so ids can't drift again.
Context budgetBounded the transcript so long runs don't overflow.Rolling window + summary, with a token-budget assertion in the loop.
Action safetyAdded an allow/ask/block gate before tools run.Gate is a pure table tested without the model; irreversible is block at every rung.
Diagnosis methodFound and fixed the reported symptom.Walked the decision tree, named the layer, and wrote a regression test for it.

Six dimensions. All at "meets" = you can fix a broken agent. Four+ at "above the bar", including Action safety and Diagnosis method, = you debug agents like a lead: you close the bug class, not just the ticket.

Exercise AC2.1 — Triage a mystery agent

Context: Real debugging starts before you know the cause: an agent that ‘sometimes hangs, sometimes deletes the wrong thing’ is triaged by walking the layers in order, not by guessing. This exercise rehearses the §0 decision tree.

Your task: Walk the §0 decision tree in order for a mystery agent, stating the one check per layer and which two layers explain ‘hangs’ and ‘deletes’.

Requirements:

  • For each layer name the single check (step cap present? descriptions disjoint? result id paired? transcript bounded? gate in place?)
  • For each, state what output would confirm or clear that layer
  • Attribute ‘hangs’ to the loop/termination layer and ‘deletes the wrong thing’ to the tool-selection / gate layer
  • Keep it a triage walk — one cheap check per layer, top to bottom

💡 Hint: Go in the tree's order and stop at the first layer that fails its check — ‘hangs’ and ‘deletes’ are two different layers failing, not one.

Exercise AC2.2 — Turn a fix into an invariant

Context: A fix you can't encode as a test will regress. The strongest version of every agent fix is the smallest CI check that would have caught the bug — and understanding why a test beats a stern prompt is the core lesson.

Your task: Pick one of the five bugs and write the smallest no-API-key test that would have caught it in CI, then argue why a test at that layer beats a system-prompt instruction.

Requirements:

  • Choose one bug and turn it into a runnable invariant (e.g. ‘irreversible is never allowed at any rung’ or ‘a stuck model stops within 2 steps’)
  • The test must run with no API key — exercise the loop/gate logic, not the model
  • Explain why a mechanical check is a hard constraint while a prompt instruction is a soft hint the model can overrule
  • Keep the test minimal — the smallest thing that fails on the old behavior

💡 Hint: Aim the test at the deterministic scaffolding (the cap, the gate, the id pairing), which needs no model to exercise and can't be argued out of by the LLM.

🪜 Practice ladder beginner → industry

Six graded exercises, easy to real-world. Try each before opening its solution.

Exercise 1 · It never stops: the first three questionsBeginner

Context: An agent that loops forever is the first failure everyone hits. Before reaching for a fix you need three fast questions that localize whether the cause is a missing cap, a missing stop condition, or a lack of progress.

Your task: List the three questions you ask first when an agent loops forever calling tools and never returns a final answer.

Requirements:

  • Q1: is there a hard step / max-iterations cap at all?
  • Q2: does the stop condition (emitting a final answer) ever fire — does the prompt even tell it how to finish?
  • Q3: is it making progress, or repeating an identical call with identical args?
  • Distinguish the cheapest immediate mitigation (a step cap) from the real fix (termination instruction + progress detection)

💡 Hint: A step cap stops the bleeding immediately; the durable fix is a clear ‘how to finish’ instruction plus detecting repeated no-progress calls.

Show solution
  1. Is there a hard step cap? If the loop has no max-iterations guard, any small mistake becomes an infinite loop. This is the first thing to add and the first thing to check.
  2. Does the stop condition ever fire? The agent stops when it emits a ‘final answer’ instead of a tool call. If the prompt never tells it how to finish, it never will.
  3. Is it making progress? Log each tool call. If it repeats the identical call with identical args, it is stuck, not working — the loop is not converging.

The cheapest immediate mitigation is a step cap; the real fix is a clear termination instruction plus progress detection.

Exercise 2 · Repeated identical tool callIntermediate

Context: Repeated identical tool calls are a specific, common symptom with a specific cause: the observation isn't getting back into the model's context in a form it recognizes, so it re-issues the same call on an unchanged view of the world.

Your task: Explain why an agent calls the same tool with the same args three times in a row, and fix it by combining loop-control and observation-handling.

Requirements:

  • Root cause: the tool result isn't fed back correctly (wrong role/format, or an empty result the model retries on)
  • Fix the observation plumbing: result returned as a proper tool/observation message tied to its call id
  • Add a same-call guard: break if the last N calls are identical and force the model to use what it has or give up
  • Explain why the guard prevents the loop while the plumbing fix removes the cause

💡 Hint: The guard treats the symptom and the plumbing treats the disease — you want both, not just the guard.

Show solution

Why: the tool result is not making it back into the model's context in a form it recognizes as an answer — so on the next turn the model sees the same state and re-issues the same call. Two common causes: the result is being appended in the wrong role/format, or the result is empty and the model retries instead of giving up.

  1. Verify the tool result is fed back as a proper tool/observation message tied to the call id — not dropped or stringified into the wrong slot.
  2. Add a same-call guard: if the last N calls are identical, break and force the model to either use what it has or say it cannot find it.

The guard prevents the loop; fixing the observation plumbing removes the cause.

Exercise 3 · It calls the wrong toolAdvanced

Context: ‘Wrong tool’ is almost never a reasoning failure — tool descriptions are the agent's only selection signal, and here they under-specify boundaries so a read intent routes to a destructive tool.

Your task: Given a read-only get_order_status(id) and a destructive cancel_order(id), diagnose why the agent cancels on a status request and fix it without adding a model.

Requirements:

  • Diagnose it as a description/schema problem, not a reasoning one
  • Rewrite descriptions to state when NOT to use each tool (e.g. never call cancel to check state)
  • Make destructive tools require an explicit justified argument (e.g. confirmed=True) so accidental calls are structurally harder
  • Add a couple of few-shot examples routing a status question to the read-only tool
  • Verify by replaying the transcript and confirming the read intent now routes correctly

💡 Hint: Encode the boundary in the tool contract (description + a required confirmation arg), not in a hope that the model reasons carefully.

Show solution

Root cause: tool descriptions are the agent's only signal for selection, and here they under-specify boundaries. ‘wrong tool’ is almost always a description/schema problem, not a reasoning problem.

  1. Rewrite descriptions to state when NOT to use each: cancel_order — ‘Only call after the user explicitly confirms cancellation. Never call to check state.’
  2. Make destructive tools require an explicit argument the model must justify (e.g. confirmed=True) so accidental calls are structurally harder.
  3. Add a couple of few-shot examples showing a status question resolved with the read-only tool.

Verify by replaying the transcript: the read-only intent should now route to get_order_status.

Exercise 4 · It ignores the tool resultExpert

Context: The mirror of RAG's ‘ignored context’ bug: a tool returns {"in_stock": false}, the result is in context, and the agent still says the item is available. Present-in-context is not used-in-answer.

Your task: Diagnose why an agent ignores a tool result that is demonstrably in its context and fix it, ranking the subtle causes.

Requirements:

  • Cover format opacity — a raw JSON blob buried among many messages
  • Cover a stale prior turn the model stays consistent with
  • Cover conflicting results from two tools
  • Give a fix per cause (summarize/pin the latest result, answer only from the newest observation, make the authoritative tool explicit)
  • Confirm with a contradiction test: a result opposing the obvious answer should now change the output

💡 Hint: Making the freshest observation impossible to overlook — summarized and pinned last — addresses the most common of the three causes.

Show solution

Present-in-context is not used-in-answer. Rank causes:

  1. Format opacity: a raw JSON blob buried among many messages is easy to overlook. Fix: summarize tool results into a short natural-language fact before the model reasons over them, or pin the latest result last.
  2. Stale prior turn: an earlier assistant turn already claimed ‘in stock’ and the model stays consistent with itself. Fix: after a tool call, instruct the model to answer only from the newest observation.
  3. Conflicting results: two tools returned contradictory data and the model picked the wrong one. Fix: make the authoritative tool explicit and de-duplicate observations.

Confirm with a contradiction test: a result that opposes the obvious answer should now change the output.

Exercise 5 · Runs out of context / budgetProfessional

Context: A long-running agent eventually hits the context window, truncates its own early steps, and starts making decisions that contradict what it did before. This is a resource-management problem, and the fixes are production discipline.

Your task: Give the production fixes for an agent that runs out of context/budget and starts contradicting itself.

Requirements:

  • Bound the loop with hard caps on both steps and total tokens; on cap, return the best partial answer flagged ‘incomplete’ rather than crashing
  • Compress history: keep system prompt + latest observations verbatim, summarize older steps into a running scratchpad
  • Externalize state to a store/scratchpad so the answer doesn't depend on everything fitting in the window
  • Add budget telemetry (tokens- and steps-per-task) and alert on the p95
  • State the principle: context is a scarce resource to manage explicitly, not assume infinite

💡 Hint: The unifying idea is that the transcript is finite; every fix either shrinks what must fit or moves state out of the window.

Show solution
  1. Bound the loop: hard caps on steps and total tokens; on cap, return the best partial answer with a clear ‘incomplete’ flag rather than crashing.
  2. Compress history: keep the system prompt + latest observations verbatim; summarize older steps into a running ‘scratchpad’ the agent maintains, so decisions survive truncation.
  3. Externalize state: write intermediate findings to a store (or the scratchpad) so the answer does not depend on everything fitting in the window.
  4. Budget telemetry: emit tokens-per-task and steps-per-task metrics; alert on the p95 so a regression in tool verbosity is caught before it blows the budget.

The principle: context is a scarce resource to be managed explicitly, not assumed infinite.

Exercise 6 · Agent took an unsafe action in productionIndustry scenario

Context: The highest-stakes agent failure: a send_email tool fanned a wrong discount to 400 customers off one ambiguous instruction. This is where autonomy must be tied to reversibility and blast radius by design.

Your task: Produce the incident analysis and the guardrails that make an unsafe bulk agent action not recur.

Requirements:

  • Trace the root-cause chain: ambiguity resolved by acting, a high-blast-radius tool with no gate, no dry-run and no rate limit
  • Give guardrails mapped to what each stops (human confirmation for irreversible/bulk; a blast-radius cap; a dry-run/simulate mode; a clarify-before-act policy)
  • Include the immediate containment steps (revoke token, correction, disable the flow)
  • State the design rule: autonomy should scale with reversibility — read-only tools can be autonomous, irreversible bulk actions default to human approval

💡 Hint: Classify every tool by reversibility and blast radius at design time; the gate belongs on the tool, not in a prompt asking the model to be careful.

Show solution

Root cause chain: (1) an ambiguous instruction the agent resolved by acting rather than asking; (2) a high-blast-radius tool with no confirmation gate; (3) no dry-run and no rate limit, so one bad decision fanned out instantly.

GuardrailStops
Human-in-the-loop confirmation for irreversible/bulk actionsSingle bad decision from auto-executing
Blast-radius limit (max recipients per call, per hour)Fan-out beyond a safe cap
Dry-run / simulate mode returning the plan, not the effectTesting high-risk flows against real side effects
Clarify-before-act policy for ambiguous inputGuessing intent on high-stakes tasks
  1. Contain: revoke the tool token, send a correction, disable the flow.
  2. Fix: gate send_email behind confirmation + a 50-recipient cap; require the agent to restate its plan and get approval for bulk sends.
  3. Prevent: classify every tool by reversibility & blast radius at design time; anything irreversible defaults to human approval.

Lesson: autonomy should scale with reversibility. Read-only tools can be fully autonomous; irreversible bulk actions should not be.

✓ Checkpoint — you can move on when you can…

  • Walk the loop→tool→result→budget→gate decision tree to name a broken layer.
  • Kill an infinite loop with a step cap and a no-progress detector.
  • Fix wrong-tool routing by making descriptions sharp and disjoint.
  • Diagnose an 'ignored' result as a missing append or mismatched tool_use_id.
  • Bound an unbounded transcript, and gate irreversible actions before they run.
© 2026 studybydoing.in · AI Engineering: Zero to Production · All rights reserved. · About · Privacy Policy · Terms · Contact
Educational content, provided as-is and without warranty. Code samples are examples — review, test, and adapt them before using in production. See the Terms of Use & Disclaimer. Use at your own risk.
© studybydoing.in