Failure Recovery, Context Discipline & Tracing
An agent's quality is defined on the unhappy path: tools fail, outputs balloon, and at 2 a.m. the only witness is your trace log. Error feedback loops, per-tool retry budgets, output truncation, and JSONL tracing turn a demo into a system.
In Module 1 you learned to return tool errors to the model as tool_result content instead of raising — because models usually self-correct when shown the error. Inside a loop, that mercy becomes a hazard: a model that self-corrects can also self-repeat, calling the same failing tool with the same arguments forever, burning budget on a file that will never exist. Recovery inside a loop needs escalating pressure, not infinite patience.
- Defense 1 — feed the error back, specifically. "FileNotFoundError: docs/setup.md does not exist. Sibling files: docs/setup-guide.md, docs/install.md" gives the model something to correct toward. Vague errors ("tool failed") invite identical retries.
- Defense 2 — per-tool failure budgets. Count failures per tool (or per tool+arguments pair). After N failures, stop executing and return "this tool is disabled for the rest of the run; try a different approach" — the model reroutes surprisingly well when told plainly.
- Defense 3 — detect repetition itself. Hash each (tool, arguments) call; on an exact repeat of a failed call, short-circuit with "you already tried this and it failed" without executing. Combined with the overall budget from lesson 4, the worst case is now bounded on three axes.
# Colab cell 1 — pure Python: runs with no key and no client.
import json
from collections import Counter
class SafeExecutor:
def __init__(self, impl: dict, max_failures_per_tool: int = 3):
self.impl = impl
self.max_failures = max_failures_per_tool
self.failures = Counter() # per tool name
self.failed_calls = set() # exact (tool, args) repeats
def execute(self, name: str, args: dict) -> tuple[str, bool]:
"""Returns (content, is_error). Never raises."""
key = (name, json.dumps(args, sort_keys=True))
if self.failures[name] >= self.max_failures:
return (f"Tool '{name}' is disabled after "
f"{self.failures[name]} failures this run. "
"Use a different tool or approach.", True)
if key in self.failed_calls:
return ("You already tried this exact call and it failed. "
"Do not repeat it; change the arguments or approach.",
True)
try:
return (self.impl[name](**args), False)
except Exception as e:
self.failures[name] += 1
self.failed_calls.add(key)
return (f"{type(e).__name__}: {e}", True)
# in the loop:
# content, is_error = executor.execute(block.name, block.input)
# results.append({"type": "tool_result", "tool_use_id": block.id,
# "content": content, "is_error": is_error})
# demo: all three defenses fire, no API key needed
flaky = SafeExecutor({"read_file": lambda path: open(path).read()})
for path in ["/no/a", "/no/a", "/no/b", "/no/c", "/no/d"]:
content, _ = flaky.execute("read_file", {"path": path})
print(f"{path}: {content[:58]}")is_error: true on the result matters on Anthropic's API: it flags the result so the model treats it as a failure to route around rather than data. Keep the failure state per-run (on the executor object), not global — yesterday's flaky tool shouldn't be banned today. The demo at the bottom walks the escalation ladder with no API involved: a specific error first, the exact-repeat short-circuit second, and after the third distinct failure the tool goes dark.Context discipline: the loop's silent tax
Every iteration appends an assistant turn and a tool-result turn — and Module 1 taught you that all of it is re-sent, re-processed, and re-billed on every subsequent call. Fifteen iterations with unbounded tool outputs is how a 'max 15 iterations' agent still blows a dollar budget. Three techniques keep it flat: (1) truncate tool outputs at the source, with a note telling the model how to get more; (2) compact old iterations — after the model has extracted what it needs from a big tool result, replace the old result with a stub; (3) keep the system prompt lean and cache it — stable prefix first, per Module 1's caching lesson.
# Colab cell 2 — pure Python: runs with no key and no client.
MAX_TOOL_OUTPUT_CHARS = 4000
def truncate(output: str, limit: int = MAX_TOOL_OUTPUT_CHARS) -> str:
if len(output) <= limit:
return output
dropped = len(output) - limit
return (output[:limit] +
f"\n\n[TRUNCATED: {dropped} more characters not shown. "
"Narrow your grep pattern, or call read_file with an offset "
"to view a specific region.]")
def compact_old_results(messages: list, keep_last: int = 2,
stub_over: int = 1000) -> list:
"""Replace big tool results from old iterations with short stubs.
The model already extracted what it needed; the bytes are just rent."""
compacted = []
cutoff = len(messages) - keep_last * 2 # each iteration = 2 messages
for idx, msg in enumerate(messages):
if idx >= cutoff or msg["role"] != "user" or isinstance(msg["content"], str):
compacted.append(msg)
continue
new_content = []
for part in msg["content"]:
if (isinstance(part, dict) and part.get("type") == "tool_result"
and len(str(part.get("content", ""))) > stub_over):
new_content.append({**part, "content":
"[old tool result elided to save context - "
"re-run the tool if you need it again]"})
else:
new_content.append(part)
compacted.append({**msg, "content": new_content})
return compacted
# demo: six fake iterations shrink; the two most recent stay intact
print(truncate("x" * 9000)[-90:])
history = []
for i in range(6):
history.append({"role": "assistant", "content": f"(tool call {i})"})
history.append({"role": "user", "content": [
{"type": "tool_result", "tool_use_id": f"t{i}", "content": "y" * 5000}]})
before = sum(len(str(m)) for m in history)
after = sum(len(str(m)) for m in compact_old_results(history))
print(f"history: {before:,} chars -> {after:,} chars")There's a second, sneakier cost to compaction that separates senior answers: it fights prompt caching. Module 1 taught that caching is an exact prefix match — and compaction rewrites early messages, so every compaction pass invalidates the cached prefix from the first edited byte onward. Compact every iteration and you pay full prefill on the entire history every call, which can cost more than the tokens you saved. The resolution: compact rarely and in batches (e.g. when context crosses a threshold, compact everything older than the last two iterations at once), eat the one-time cache re-write, then enjoy many cached calls on the new shorter prefix. It's a classic amortization trade — tokens saved per call × calls remaining vs. one full re-prefill — and being able to sketch that inequality on a whiteboard is exactly the bar.
compact_old_results to run before every API call ('keep context minimal at all times!'). Context length drops as expected — but the per-run cost rises 40%. Walk through why, using the usage fields that would prove it.The trace log: your only witness
| Field | Why it matters when debugging |
|---|---|
run_id, iteration, ts | Groups events into one run and orders them — the skeleton every other question hangs on |
Event type (llm_call / tool_call / terminate) | Lets you filter: "show me only the tool calls" or "how did runs end this week?" |
input_tokens, output_tokens, cumulative usd | Finds the iteration where cost spiked — usually a giant unretruncated tool output |
stop_reason | Distinguishes 'model answered' from 'model wanted tools' from 'hit max_tokens' (truncated mid-thought!) |
tool, args, result_chars, is_error | Reconstructs the model's search path; repeated identical args = the spiral from Defense 3 |
latency_ms per call | Splits the blame between slow model calls and slow tools when a run blows the deadline |
Termination reason + complete flag | The first field you check on a bad answer: did it finish, or run out of budget at step 14? |
# Colab cell 3 — run cells 1 and 2 first (SafeExecutor, truncate). This
# cell adds the key, the client, and a fake repo, then traces a full run.
!pip install -q anthropic
import os
try:
from google.colab import userdata
os.environ["ANTHROPIC_API_KEY"] = userdata.get("ANTHROPIC_API_KEY")
except Exception:
from getpass import getpass
os.environ.setdefault("ANTHROPIC_API_KEY", getpass("Anthropic API key: "))
import json, time, uuid
import anthropic
client = anthropic.Anthropic()
MODEL = "claude-sonnet-5"
class Tracer:
def __init__(self, path: str = "trace.jsonl"):
self.path = path
self.run_id = uuid.uuid4().hex[:8]
def log(self, event: str, **fields) -> None:
record = {"run_id": self.run_id, "ts": round(time.time(), 3),
"event": event, **fields}
with open(self.path, "a") as f:
f.write(json.dumps(record, default=str) + "\n")
# the same tiny "repo" as lesson 2, so the tools have something to find
REPO = {
"README.md": "Sample service. Retry policy is configured in config/app.yaml.",
"config/app.yaml": "retries: 5\nbackoff: exponential\ntimeout_s: 30\n",
"src/retry.py": "def backoff(attempt):\n return min(2 ** attempt, 60)\n",
}
def list_dir(path: str = "") -> str:
hits = [p for p in REPO if p.startswith(path)]
return "\n".join(sorted(hits)) if hits else f"Nothing under {path!r}."
def grep(pattern: str) -> str:
hits = [f"{p}: {line}" for p, body in REPO.items()
for line in body.splitlines() if pattern.lower() in line.lower()]
return "\n".join(hits) if hits else f"No lines match {pattern!r}."
def read_file(path: str) -> str:
return REPO.get(path, f"No file at {path!r}.")
IMPL = {"list_dir": list_dir, "grep": grep, "read_file": read_file}
TOOLS = [
{"name": "list_dir", "description": "List repo paths under a prefix.",
"input_schema": {"type": "object",
"properties": {"path": {"type": "string"}}, "required": []}},
{"name": "grep", "description": "Find lines matching a substring.",
"input_schema": {"type": "object",
"properties": {"pattern": {"type": "string"}},
"required": ["pattern"]}},
{"name": "read_file", "description": "Read one file in full, by path.",
"input_schema": {"type": "object",
"properties": {"path": {"type": "string"}},
"required": ["path"]}},
]
def run_traced(question: str, max_iterations: int = 10) -> str:
tracer = Tracer()
executor = SafeExecutor(IMPL) # cell 1
messages = [{"role": "user", "content": question}]
for i in range(max_iterations):
t0 = time.monotonic()
resp = client.messages.create(model=MODEL, max_tokens=1024,
tools=TOOLS, messages=messages)
tracer.log("llm_call", iteration=i,
input_tokens=resp.usage.input_tokens,
output_tokens=resp.usage.output_tokens,
stop_reason=resp.stop_reason,
latency_ms=round((time.monotonic() - t0) * 1000))
if resp.stop_reason != "tool_use":
tracer.log("terminate", reason="natural stop", complete=True,
iterations=i)
return next(b.text for b in resp.content if b.type == "text")
messages.append({"role": "assistant", "content": resp.content})
results = []
for block in resp.content:
if block.type == "tool_use":
content, is_error = executor.execute(block.name, block.input)
content = truncate(content) # cell 2
tracer.log("tool_call", iteration=i, tool=block.name,
args=block.input, result_chars=len(content),
is_error=is_error)
results.append({"type": "tool_result", "tool_use_id": block.id,
"content": content, "is_error": is_error})
messages.append({"role": "user", "content": results})
tracer.log("terminate", reason="max iterations", complete=False,
iterations=max_iterations)
return "(incomplete: max iterations exceeded)"
print(run_traced("How does this service configure retries?"))
print("--- trace.jsonl ---")
print(open("trace.jsonl").read())SafeExecutor guarding every execution, cell 2's truncate capping outputs, and the Tracer logging each step (a production loop would also record usd_so_far from lesson 4's Budget). JSONL (one JSON object per line) is the right format because it's append-only (crash-safe — you keep everything up to the crash), streamable (tail -f during a live run), and trivially queryable with jq or pandas. Log every LLM call and every tool call, not just failures: the question you'll actually ask is "what was the model seeing when it made this weird choice?", and that requires the whole path. The trace printed at the end is exactly the artifact you walk through in the Gate G1 practical.Traces are assets: regression fixtures and the road to evals
The trace log's second life is the one seniors bring up unprompted: every interesting failure becomes a test case. A run where the agent spiraled, hallucinated on empty results, or blew its budget gets its initial input checked into a regression suite; after any prompt, tool, or model change, replay those inputs and compare outcomes (did it finish? within budget? citing real files?). That's the embryo of the eval harness Module 7 builds properly — and it's how prompt changes stop being vibes-driven. Two production notes to mention: real deployments usually emit this same data as spans through their observability stack (the GenAI conventions in OpenTelemetry, or purpose-built tools like LangSmith/Langfuse) rather than a local file — the fields are what matter, not the sink; and traces contain user data and tool outputs, so retention and PII policy apply to them like any other log.
Whiteboard drills
- ▸Feed errors back with specifics (what failed, what exists instead) — vague errors cause identical retries.
- ▸Escalate: per-tool failure budgets disable a tool after N failures; hashing (tool, args) short-circuits exact repeats.
- ▸Context grows every iteration and is re-billed every call: truncate outputs at the source, compact old results, keep the prefix stable and cached.
- ▸Compaction fights caching — rewriting history invalidates the prefix. Compact rarely, in batches, on a threshold; watch cache_read_input_tokens to verify.
- ▸Truncation notes are affordances — tell the model how to get more, and it will.
- ▸Trace every LLM call, tool call, and termination to JSONL with tokens, cost, latency, and reason. If it's not in the trace, it didn't happen.
- ▸Traces are assets: failures become regression fixtures, fixtures become the eval suite that makes prompt changes safe (Module 7 industrializes this).