Module 2: The Agent Loop · Lesson 5 of 5 · 35 min

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.
Defense 1 — specific errors"docs/setup.md not found. Sibling files: setup-guide.md, install.md"Defense 2 — per-tool budget3rd failure on this tool → disabled for the rest of the runDefense 3 — repeat detectionexact (tool, args) repeat of a failed call → short-circuit, no executionescalating pressure — each defense assumes the last one wasn't enough
Each defense assumes the one before it wasn't enough — specific errors, then a per-tool budget, then repeat detection.
1/3
a tool executor that never raises and applies escalating pressure
# 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]}")
The two escalation paths are checked before execution, so a disabled tool costs nothing. Setting 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.

truncate at the source + compact old results
# 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")
The truncation note is not politeness — it's an affordance: the model reads it and issues a narrower grep or an offset read, which is exactly the behavior you want. Compaction trades a risk (the model might need that data again) for a guarantee (context stays bounded); the stub tells it recovery is one tool call away. Warning: compaction rewrites history, so run it on a copy used for the API call if your trace log needs the original. The demo compacts a fake twelve-message history and prints the before/after sizes — the two most recent iterations survive untouched.

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.

Predict the output
A teammate wires 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

FieldWhy it matters when debugging
run_id, iteration, tsGroups 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 usdFinds the iteration where cost spiked — usually a giant unretruncated tool output
stop_reasonDistinguishes 'model answered' from 'model wanted tools' from 'hit max_tokens' (truncated mid-thought!)
tool, args, result_chars, is_errorReconstructs the model's search path; repeated identical args = the spiral from Defense 3
latency_ms per callSplits the blame between slow model calls and slow tools when a run blows the deadline
Termination reason + complete flagThe first field you check on a bad answer: did it finish, or run out of budget at step 14?
the 20-line JSONL tracer, wired into a complete traced run
# 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())
This cell assembles the whole module: lesson 1's loop, cell 1's 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.

Traces are a portfolio artifact
LLM observability tooling — Langfuse, Arize Phoenix, LangSmith — sits on the most-requested skills list in 2026 agentic postings, and hiring managers routinely look at your GitHub before your résumé. A repo whose README walks through a real trace of the loop — per-step tool choices, tokens, cost, and the termination reason — proves you've operated an agent rather than demoed one; the JSONL tracer you just built produces exactly that artifact, and Lab 02 asks you to ship it.

Whiteboard drills

Check yourself
Drill: 2 a.m. page: your agent told a VIP customer their enterprise plan includes a feature it doesn't. You have the JSONL trace. Walk the postmortem, step by step, out loud.
Check yourself
Drill: "Your agent works. Now make changing it safe." — the interviewer wants your path from trace logs to a regression/eval loop.
Key takeaways
  • 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).