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.
| Condition | Trigger | Who controls it | What it protects against |
|---|---|---|---|
| Natural stop | stop_reason is "end_turn" — model answered without tools | Model | Nothing — it IS the happy path (and sometimes a premature one) |
Explicit finish tool | Model calls finish(answer, citations) | Model, but on your schema | Ambiguous endings; forces a structured, complete final answer |
| Max iterations | Loop counter hits N (e.g. 15) | Your code | Infinite tool spirals |
| Cost budget | Accumulated dollars from usage exceed the cap | Your code | Expensive iterations — 15 cheap calls fine, 15 huge-context calls not |
| Wall-clock deadline | time.monotonic() passes the deadline | Your code | Slow 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.
# 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 proceedexhausted() 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
# 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()))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.
best_effort itself now fails with a 400. Why?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 ...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.Whiteboard drills
- ▸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.