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.
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
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:
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_idmatch 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
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:
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)
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.
run_brokenis a barewhile True. It has no way to stop on its own — the only reason it returns at all is thebudget=20safety net baked into the test harness. In a real agent thatwhile Truepins a CPU and never returns.run_fixedadds 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.- It adds guard (2): a
seenset 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.
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
seen set. That converts a silent 8-lap waste into an early, explainable stop.2 · Symptom: it calls the wrong tool advanced
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:
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
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.
routeturns 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.- In
VAGUEthe descriptions are mushy andget_podseven contains the word logs. A request for error logs therefore overlaps the pods tool more — so it routes to the wrong tool. - In
SHARPnothing 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 onget_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.
3 · Symptom: it ignores the tool result expert
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:
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)
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.
- The
FakeModelrequestsget_logswith idcall_1, then on later turns scansmessagesfor atool_resultcarrying that id. If it finds it, it answers; if not, it re-asks. - The broken run appends the result with
tool_use_id="wrong_id". The model never finds a result taggedcall_1, so it loops and finally gives up — looking for all the world like it "ignored" a tool it actually ran. - 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.
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
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
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:
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
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.
est_tokensis a crude token estimate (~1 token per 4 characters).run_brokenappends a chunky log result every step and checks the running total againstWINDOW.- 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.
trimkeeps the first (task) message and the lastkeep_recentturns 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.
5 · Symptom: it takes an unsafe action tech-lead
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:
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
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.
run_brokenexecutes 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.gate(tool, rung)is a pure lookup inMATRIX: given the tool's risk class and the agent's autonomy rung, it returnsallow,ask, orblock. It's outside the model, so nothing the model says can change the verdict.run_fixedchecks the gate before running anything. At theobserverung read-only is allowed and the irreversible delete is blocked and recorded asNOin 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.
6 · Grade your agent debugging tech-lead
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.
| Dimension | Meets the bar | Above the bar (tech-lead) |
|---|---|---|
| Loop termination | Added a step cap so the loop always ends. | Cap and a no-progress detector; the cap is an alarm, not the normal exit. |
| Tool routing | Rewrote the description so the right tool is chosen. | Made all descriptions disjoint; added a test that asserts request→tool routing. |
| Result wiring | Fixed the missing append / mismatched tool_use_id. | One code path builds every tool_result, so ids can't drift again. |
| Context budget | Bounded the transcript so long runs don't overflow. | Rolling window + summary, with a token-budget assertion in the loop. |
| Action safety | Added an allow/ask/block gate before tools run. | Gate is a pure table tested without the model; irreversible is block at every rung. |
| Diagnosis method | Found 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.
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
- 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.
- 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.
- 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.
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.
- 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.
- 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.
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.
- Rewrite descriptions to state when NOT to use each:
cancel_order— ‘Only call after the user explicitly confirms cancellation. Never call to check state.’ - Make destructive tools require an explicit argument the model must justify (e.g.
confirmed=True) so accidental calls are structurally harder. - 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.
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:
- 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.
- 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.
- 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.
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
- 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.
- Compress history: keep the system prompt + latest observations verbatim; summarize older steps into a running ‘scratchpad’ the agent maintains, so decisions survive truncation.
- Externalize state: write intermediate findings to a store (or the scratchpad) so the answer does not depend on everything fitting in the window.
- 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.
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.
| Guardrail | Stops |
|---|---|
| Human-in-the-loop confirmation for irreversible/bulk actions | Single 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 effect | Testing high-risk flows against real side effects |
| Clarify-before-act policy for ambiguous input | Guessing intent on high-stakes tasks |
- Contain: revoke the tool token, send a correction, disable the flow.
- Fix: gate
send_emailbehind confirmation + a 50-recipient cap; require the agent to restate its plan and get approval for bulk sends. - 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.