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

Termination, Budgets & Graceful Degradation

Never trust the model alone to stop. Production agents layer termination conditions — an explicit finish tool, iteration caps, dollar budgets, wall-clock deadlines — and when a budget trips, they degrade gracefully instead of raising.

The loop from lesson 1 has a dirty secret: it terminates when stop_reason != "tool_use" — i.e., whenever the model feels done. Models sometimes stop early with a half-answer, and sometimes never feel done: re-grepping the same pattern, re-reading the same file, chasing a lead in circles. A model deciding 'one more tool call' 30 times in a row is not a hypothetical; it's a Tuesday. Termination must be layered: the model's own signal, plus hard limits the model cannot override.

Natural stopstop_reason is end_turn — the happy path (model controls it)Finish toolmodel calls finish(answer, citations) — on your schemaMax iterationsloop counter hits N — stops infinite tool spiralsCost budgetaccumulated $ from usage exceeds the capWall-clock deadlinetime.monotonic() passes the deadlineevery guard is checked before the next LLM call — first one to trip wins
Five layered guards, checked in order before every LLM call — the first one to trip ends the loop.
1/5
ConditionTriggerWho controls itWhat it protects against
Natural stopstop_reason is "end_turn" — model answered without toolsModelNothing — it IS the happy path (and sometimes a premature one)
Explicit finish toolModel calls finish(answer, citations)Model, but on your schemaAmbiguous endings; forces a structured, complete final answer
Max iterationsLoop counter hits N (e.g. 15)Your codeInfinite tool spirals
Cost budgetAccumulated dollars from usage exceed the capYour codeExpensive iterations — 15 cheap calls fine, 15 huge-context calls not
Wall-clock deadlinetime.monotonic() passes the deadlineYour codeSlow tools and long generations; the user is still waiting

Why isn't max-iterations enough on its own? Because iterations are not the resource — tokens, dollars, and seconds are. One iteration that stuffs a 200KB file into context can cost more than fourteen normal ones; a tool that hangs for 40 seconds burns your latency budget in two iterations. Bound each real resource separately: count of calls, cumulative cost, and elapsed time — and check them before each LLM call, not after, so you never pay for a call whose result you'd discard.

a Budget object the loop consults before every call
# Colab cell 1 — pure Python: runs with no key and no client.
import time

class Budget:
    # Pull current per-MTok prices from your provider's pricing page.
    # Never hardcode from memory; keep them in one place so tests can pin them.
    PRICE_IN_PER_MTOK = 0.0   # TODO: fill from pricing page
    PRICE_OUT_PER_MTOK = 0.0  # TODO: fill from pricing page

    def __init__(self, max_iterations: int = 15,
                 max_usd: float = 0.50, max_seconds: float = 60.0):
        self.max_iterations = max_iterations
        self.max_usd = max_usd
        self.deadline = time.monotonic() + max_seconds
        self.iterations = 0
        self.usd = 0.0

    def add_call(self, usage) -> None:
        self.iterations += 1
        self.usd += (usage.input_tokens * self.PRICE_IN_PER_MTOK +
                     usage.output_tokens * self.PRICE_OUT_PER_MTOK) / 1_000_000

    def exhausted(self) -> str | None:
        """Return a human-readable reason, or None if we may continue."""
        if self.iterations >= self.max_iterations:
            return f"iteration cap ({self.max_iterations}) reached"
        if self.usd >= self.max_usd:
            return f"cost budget exceeded ({self.usd:.3f} USD)"
        if time.monotonic() >= self.deadline:
            return "wall-clock deadline passed"
        return None

budget = Budget(max_iterations=3, max_usd=0.25, max_seconds=30)
print(budget.exhausted())   # None -> all guards green, the loop may proceed
Small but deliberate: exhausted() returns a reason string rather than a boolean, because that reason goes into the trace log and into the degraded answer's metadata ("incomplete: cost budget exceeded"). time.monotonic() instead of time.time() because wall-clock time can jump (NTP adjustments); monotonic never goes backward. Prices live in named constants so a test can assert they're non-zero before you ship.

The finish tool and graceful degradation

loop with finish tool + best-effort fallback — never raises
# Colab cell 2 — run cell 1 first (it defines Budget). This cell adds the
# key, the client, and a fake repo, then runs the fully guarded loop.
!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 anthropic

client = anthropic.Anthropic()
MODEL = "claude-sonnet-5"

# The same tiny in-memory "repo" as lesson 2, so the tools do real work.
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 execute_all(content) -> list:
    # naive executor: run every requested tool, pair every result.
    # Lesson 5 hardens this into SafeExecutor (errors, budgets, repeats).
    return [{"type": "tool_result", "tool_use_id": block.id,
             "content": IMPL[block.name](**block.input)}
            for block in content if block.type == "tool_use"]

FINISH_TOOL = {
    "name": "finish",
    "description": (
        "Submit your final answer. Call exactly once, when you have enough "
        "evidence. Every claim must cite a file path you actually read."
    ),
    "input_schema": {
        "type": "object",
        "properties": {
            "answer": {"type": "string"},
            "citations": {"type": "array", "items": {"type": "string"},
                          "description": "file paths supporting the answer"},
        },
        "required": ["answer", "citations"],
    },
}

def run(question: str, budget: Budget) -> dict:
    messages = [{"role": "user", "content": question}]
    while True:
        reason = budget.exhausted()
        if reason is not None:                    # check BEFORE paying
            return best_effort(messages, reason)

        resp = client.messages.create(
            model=MODEL, max_tokens=2048,
            tools=TOOLS + [FINISH_TOOL], messages=messages,
        )
        budget.add_call(resp.usage)

        finish = next((b for b in resp.content
                       if b.type == "tool_use" and b.name == "finish"), None)
        if finish is not None:
            return {"answer": finish.input["answer"],
                    "citations": finish.input["citations"],
                    "complete": True}

        if resp.stop_reason != "tool_use":
            # model stopped talking without calling finish — nudge it.
            # Not a one-shot: if it keeps stalling this fires every turn;
            # the budget guard above is what actually bounds the retries.
            messages.append({"role": "assistant", "content": resp.content})
            messages.append({"role": "user", "content":
                "Call the finish tool with your answer and citations."})
            continue

        messages.append({"role": "assistant", "content": resp.content})
        messages.append({"role": "user",
                         "content": execute_all(resp.content)})

def best_effort(messages, reason: str) -> dict:
    """Budget is gone. One last cheap call, NO tools, to salvage an answer."""
    wrap_up = messages + [{"role": "user", "content":
        "Budget exhausted (" + reason + "). Using only what you have "
        "already found, give your best answer and state explicitly what "
        "you could not verify."}]
    resp = client.messages.create(model=MODEL, max_tokens=1024,
                                  messages=wrap_up)
    answer = next(b.text for b in resp.content if b.type == "text")
    return {"answer": answer, "citations": [],
            "complete": False, "stop_reason": reason}

print(run("How does this service configure retries?", Budget()))
Three design points. (1) The finish tool turns 'the model went quiet' into a structured, citation-bearing artifact — and lets you reject endings that lack citations. (2) The budget check sits at the top of the loop, so exhaustion is detected before spending. (3) best_effort makes one final tool-free call — a caller gets {complete: false, stop_reason: ...} instead of a stack trace. One subtlety: the message array must end in an API-legal state (every tool_use answered) before the wrap-up call, which the loop guarantees since results are appended in the same iteration. The repo tools repeat lesson 2's fixture so this cell stands alone next to cell 1, and execute_all is deliberately naive — run everything, pair every result; lesson 5 hardens it into SafeExecutor.

Tell the model about the budget

Hard enforcement and model awareness are complementary, not alternatives — and interviewers probe whether you know the difference. Everything above is enforcement: the model can't override it, but it also can't see it coming, so exhaustion always lands as a surprise mid-investigation. The refinement: inject the remaining budget into context as it shrinks ("You have ~4 tool calls left; prioritize and start converging") — the model paces itself, wraps up threads instead of opening new ones, and best-effort answers get dramatically better because the model chose what to sacrifice. Newer frontier models formalize exactly this as a native task-budget parameter: the server shows the model a countdown it self-moderates against. Either way, the invariant stands: the model's awareness is advisory; your harness's enforcement is the guarantee. An agent told to wrap up may still try one more call — the top-of-loop check is what makes the budget real.

Spot the bug
A teammate 'optimizes' the loop by moving the budget check to right after the API call — 'why loop around again just to check?' On budget-exhausted runs, best_effort itself now fails with a 400. Why?
python
while True:
    resp = client.messages.create(
        model=MODEL, max_tokens=2048,
        tools=TOOLS + [FINISH_TOOL], messages=messages,
    )
    budget.add_call(resp.usage)

    reason = budget.exhausted()
    if reason is not None:
        messages.append({"role": "assistant", "content": resp.content})
        return best_effort(messages, reason)     # 400 in here. why?

    # ... finish check, tool execution, append results ...
Budget the tools too
The LLM call isn't the only thing that burns time — a grep over a huge repo or a slow network tool can eat the deadline while the budget object sleeps. Give each tool execution its own timeout (a few seconds), and return "tool timed out" as an error result so the model can adapt. Latency budget = LLM time + tool time; meter both.
What separates senior candidates
Anyone can demo the loop; termination and budget discipline is what separates senior candidates in agent system-design interviews. When an interviewer sketches an agent and asks "what stops it?" or "what does a run cost?", volunteering the layered guards unprompted — finish tool, iteration cap, dollar budget, wall-clock deadline, all checked before each call — and then defending the actual numbers from trace percentiles is the difference between a mid-level and a senior read. The drills below are rehearsal for exactly that exchange.

Whiteboard drills

Check yourself
Drill: "How do you pick the actual numbers — 15 iterations, $0.50, 60 seconds? Defend them."
Check yourself
Drill: Same agent core, two products: (a) an interactive assistant a user watches, (b) an overnight batch analyst processing 500 jobs. Design the termination envelope for each and name what changes.
Key takeaways
  • Layer termination: model's natural stop plus finish tool plus iteration cap plus cost budget plus wall-clock deadline. Never trust the model alone.
  • Iterations aren't the resource — tokens, dollars, seconds are. Bound each separately.
  • Check the budget before the LLM call; return a reason string, not a boolean.
  • An explicit finish(answer, citations) tool forces structured, verifiable endings.
  • Tell the model its remaining budget so it self-paces — but awareness is advisory; harness enforcement is the guarantee.
  • Any early exit must leave the message array API-legal: answer or stub every pending tool_use before the wrap-up call.
  • Pick budget numbers from trace percentiles of successful runs (p95–p99 + margin), per task shape; alert on exhaustion-rate shifts.
  • On exhaustion: one final tool-free wrap-up call → best-effort answer flagged complete: false. Exceptions are for bugs, not budgets.