buttercup.sh lesson 3

Lesson 3: loops and goals - the agent that stops on purpose

Today your agent takes several steps in order and then ends, for a reason you chose. Lesson 2's loop had exactly one way out: the model stopped asking for tools. That's one exit, and it's the model's, not yours. You'll add the other three, a turn counter, a goal a program can check, and a deadline on every tool, and you'll see the arithmetic on why the counter alone is worth $2.18 a run. Forty lines, JavaScript and Python, against the same key you already have.

The one idea

One tool call is a transaction. A loop with an exit is an agent. Lesson 2 built the loop. This lesson is entirely about the exit, because that's where agents misbehave in public. An agent that can't tell it's finished runs up a bill. An agent that decides it's finished three steps early tells you the job is done and leaves the file half written. Same bug, two directions.

There are four ways out of the loop, and only one of them belongs to the model:

Only the first of those is in lesson 2's twenty lines. The other three are the forty in this one. And to write them you need to know every value the first one can hold.

stop_reason, and every value it can hold

Every reply carries one. if (stop_reason !== "tool_use") was fine for one lesson; it treats five different situations as “the model answered.” Two of them are not that at all.

ValueWhat happenedWhat you do
end_turn The model has nothing more to say Check your goal. If it's met, stop. If not, say what's missing and go round again
tool_use It wants one or more tools Run them, answer all of them in one message, go round
max_tokens The reply hit your ceiling and is cut off mid-thought Don't append it. The last block may be half a tool call. Raise max_tokens and re-run the same request
stop_sequence It wrote a string you listed in stop_sequences Nothing, unless you set that parameter. If you did, this is your own exit and you already know what it means
pause_turn A long server-side tool run (web search, web fetch, code execution) was paused part way Send the conversation back unchanged and the server resumes. Don't add a “continue” message
refusal A safety classifier declined the request. HTTP 200, not an error Read stop_details.category, stop the loop, tell the user. Retrying the same prompt is not a fix

Two details worth having in your head now rather than at 1am. stop_details is populated only when stop_reason is refusal; on every other value it is null, so guard before you read it. And pause_turn is the one people meet by accident: the SDK tool runners don't auto-resume it, so a paused turn ends the run and comes back as the final message with no error and a silently truncated answer.

Other endpoints spell it differently and mean the same three things. OpenAI-compatible servers, Ollama and vLLM included, send finish_reason with stop, length and tool_calls. Gemini sends finishReason. We normalise all of them to one stopReason in js/llm.js, four adapters wide (Anthropic, Google, OpenAI-style chat, and WebLLM in the tab itself), so the loop above them never has to care. Whatever your stack, find the field. It's the only thing in the response that tells you what to do next.

The turn cap, and what it saves

while (true) is a bug with a delay. Nothing about a model makes it notice a loop. Give it a tool description it has misread and it calls the tool again, reasonably, and again. Every one of those turns carries the whole conversation with it.

turns taken · a runaway, capped at 12
turn 1, the goal turn 12, your counter stops it
Twelve turns, then a wall. The hatched ticks past it are the calls a misread tool description would have gone on making, all the way to the model's own patience, which is unbounded. The counter is four lines. Log the fact that you hit it. A run that ends at the cap is not a model problem. It's a description problem, and the log line is the only evidence you'll get.

Do the arithmetic, because it's the argument. A stuck turn appends a tool_use block and its tool_result: call it 600 tokens, and the request that carries it is 300 tokens of prompt plus everything before it. Turn n costs 300 + 600n input tokens, so the whole run is the triangle, not the last row.

Let it run to 40 turns and you bill 480,000 input tokens for one request that produced nothing. At Claude Opus 5's $5 per million that's $2.40. Stop at 12 and the same runaway costs 43,200 tokens, or $0.22. Four lines of code, $2.18 back, on a single request. Run that agent a thousand times a day with one broken tool description and the uncapped version is $2,400 a day of turns that never had a chance of finishing. Ouch.

Pick a number, break at it, and log that you did. A capped runaway is a bug report. An uncapped one is an invoice.

What number? Count the steps the task honestly needs and roughly double it. Read-edit-verify is three tool calls and four turns, so twelve is generous. We ship max steps 40 in the harness settings panel, because the harness doesn't know what you're about to ask it for. A purpose-built agent does know, and should say so with a smaller number.

A goal the model can check itself against

Here is the exit everyone forgets. The model stopping is not the job finishing. end_turn means “I have nothing more to say,” which is a claim about the model, not about your filesystem. If you want the difference caught, something has to check. That something is four lines of your own code.

Which means the goal has to be checkable. Most goals people hand an agent are a feeling:

a vibe
"clean up the notes
 directory"

"make the weather
 file better"

"fix the tests"
a condition, in code
read("notes/paris.md")
  .split("\n")
  .length === 3

exitCode === 0

files.every(f =>
  f.endsWith("\n"))
Left is what the user says, and it's fine as a prompt. Right is what your loop needs: an expression that comes back true or false without a human reading anything. Write the right-hand side first. If you can't, you haven't decided what you want yet, and an agent can't decide it for you. It picks something plausible and stops there, which is exactly the failure people describe as “it gave up.”

Two rules make this work in practice. State the goal in the prompt in the same terms your check uses, so the model is aiming at the thing you're measuring: “three lines” in the prompt, and length === 3 in the code. And when the check fails after an end_turn, say what's missing in one sentence and let it continue. Don't restart the conversation. It has the whole array already.

Nudge once. If the model claims to be done and the check fails twice, the model is not the problem. Either your check is stricter than your prompt, or the tools can't express the job. Stop and read them both.

tip: run the check before the first turn

Call your goal function once before the loop starts. If it comes back true, you just learned the task was already done and you were about to pay a model to discover that. If it throws, you found the bug in your check for free, rather than at turn 12, when it silently returns undefined and your agent never stops.

The code, JavaScript

Complete and runnable. npm i @anthropic-ai/sdk, set ANTHROPIC_API_KEY, and run it with Node. Same three parts as lesson 2, the tools, the descriptions, the loop, with a goal and a counter around them.

import Anthropic from "@anthropic-ai/sdk";
import { readFile, writeFile } from "node:fs/promises";

const client = new Anthropic();

const MAX_TURNS = 12;             // your exit, not the model's
const TOOL_TIMEOUT_MS = 10_000;   // no tool gets to hang the run

// 1. the tools. Two plain functions, same as last week.
const TOOLS = {
  write_file: async ({ path, text }) => {
    await writeFile(path, text);
    return `wrote ${text.split("\n").length} line(s) to ${path}`;
  },
  read_file: ({ path }) => readFile(path, "utf8"),
};

const toolSchemas = [
  {
    name: "write_file",
    description: "Write text to a path, replacing whatever was there.",
    input_schema: {
      type: "object",
      properties: { path: { type: "string" }, text: { type: "string" } },
      required: ["path", "text"],
    },
  },
  {
    name: "read_file",
    description: "Read a file back and return its contents verbatim.",
    input_schema: {
      type: "object",
      properties: { path: { type: "string" } },
      required: ["path"],
    },
  },
];

// 2. the goal. Said once in prose for the model, once in code for the loop —
//    and the two say the same thing on purpose.
const GOAL = "Write notes/paris.md with exactly three lines about the weather, " +
             "then read it back and confirm all three lines are there.";

async function goalMet() {
  try {
    const text = await readFile("notes/paris.md", "utf8");
    return text.trimEnd().split("\n").length === 3;
  } catch {
    return false;              // no file is a perfectly good "not yet"
  }
}

// 3. the loop. Four exits, and three of them are yours.
const messages = [{ role: "user", content: GOAL }];
let turn = 0, nudges = 0, budget = 16000, done = false;

if (await goalMet()) throw new Error("already done — nothing to run");

while (turn < MAX_TURNS) {
  turn++;
  const reply = await client.messages.create({
    model: "claude-opus-5",
    max_tokens: budget,
    tools: toolSchemas,
    messages,
  });

  // EXIT 4a: truncated mid-thought. The last block may be half a tool call, so
  // do not append it — raise the ceiling and spend a turn on the same request.
  if (reply.stop_reason === "max_tokens") {
    budget = Math.min(budget * 2, 64000);
    console.warn(`truncated — retrying with max_tokens ${budget}`);
    continue;
  }

  messages.push({ role: "assistant", content: reply.content });

  // round we go: run every call, answer every call, one message back.
  if (reply.stop_reason === "tool_use") {
    const calls = reply.content.filter(b => b.type === "tool_use");
    messages.push({ role: "user", content: await Promise.all(calls.map(runOne)) });
    continue;
  }

  // EXIT 4b: refusal, pause_turn, or a value added after this was written.
  // Crash loudly rather than treating it as an answer.
  if (reply.stop_reason !== "end_turn") {
    throw new Error(`unhandled stop_reason: ${reply.stop_reason} ` +
                    `${JSON.stringify(reply.stop_details ?? null)}`);
  }

  // EXIT 1 + 3: the model says it is finished. Ask the filesystem.
  if (await goalMet()) { done = true; break; }

  if (++nudges > 1) {
    throw new Error("stopped early twice — the goal, the check or the tools disagree");
  }
  messages.push({
    role: "user",
    content: "notes/paris.md does not have three lines yet. Finish the job.",
  });
}

// EXIT 2: the counter. Say which exit you took, every time.
console.log(done
  ? `goal met in ${turn} turn(s)`
  : `stopped at the ${MAX_TURNS}-turn cap without meeting the goal`);

// one tool call: a deadline, and a failure that travels as information
async function runOne(block) {
  const base = { type: "tool_result", tool_use_id: block.id };
  try {
    const out = await withTimeout(TOOLS[block.name](block.input), TOOL_TIMEOUT_MS);
    return { ...base, content: String(out) };
  } catch (err) {
    return { ...base, is_error: true, content: String(err.message) };
  }
}

function withTimeout(work, ms) {
  return Promise.race([
    Promise.resolve(work),
    new Promise((_, reject) =>
      setTimeout(() => reject(new Error(`timed out after ${ms} ms`)), ms)),
  ]);
}

Read the four continue/break points and nothing else. That's the lesson. Every one of them names the exit it is, and the last line of the run says out loud which one you left by. That's the difference between an agent you can debug and an agent you can only watch.

The code, Python

The same program. pip install anthropic.

import anthropic
from pathlib import Path
from concurrent.futures import ThreadPoolExecutor, TimeoutError as Timeout

client = anthropic.Anthropic()

MAX_TURNS = 12
TOOL_TIMEOUT_S = 10
NOTES = Path("notes/paris.md")

# 1. the tools
def write_file(path, text):
    Path(path).write_text(text)
    return f"wrote {len(text.splitlines())} line(s) to {path}"

def read_file(path):
    return Path(path).read_text()

TOOLS = {"write_file": write_file, "read_file": read_file}

tool_schemas = [
    {
        "name": "write_file",
        "description": "Write text to a path, replacing whatever was there.",
        "input_schema": {
            "type": "object",
            "properties": {"path": {"type": "string"}, "text": {"type": "string"}},
            "required": ["path", "text"],
        },
    },
    {
        "name": "read_file",
        "description": "Read a file back and return its contents verbatim.",
        "input_schema": {
            "type": "object",
            "properties": {"path": {"type": "string"}},
            "required": ["path"],
        },
    },
]

# 2. the goal, in prose and in code
GOAL = ("Write notes/paris.md with exactly three lines about the weather, "
        "then read it back and confirm all three lines are there.")

def goal_met():
    try:
        return len(NOTES.read_text().rstrip().splitlines()) == 3
    except FileNotFoundError:
        return False

# 3. the loop
pool = ThreadPoolExecutor(max_workers=8)

def run_one(block):
    result = {"type": "tool_result", "tool_use_id": block.id}
    try:
        out = pool.submit(TOOLS[block.name], **block.input).result(TOOL_TIMEOUT_S)
        return {**result, "content": str(out)}
    except Timeout:
        return {**result, "is_error": True,
                "content": f"timed out after {TOOL_TIMEOUT_S}s"}
    except Exception as err:
        return {**result, "is_error": True, "content": str(err)}

messages = [{"role": "user", "content": GOAL}]
turn, nudges, budget, done = 0, 0, 16000, False

assert not goal_met(), "already done — nothing to run"

while turn < MAX_TURNS:
    turn += 1
    reply = client.messages.create(
        model="claude-opus-5",
        max_tokens=budget,
        tools=tool_schemas,
        messages=messages,
    )

    # truncated: do not append a half-written tool call
    if reply.stop_reason == "max_tokens":
        budget = min(budget * 2, 64000)
        print(f"truncated — retrying with max_tokens {budget}")
        continue

    messages.append({"role": "assistant", "content": reply.content})

    if reply.stop_reason == "tool_use":
        calls = [b for b in reply.content if b.type == "tool_use"]
        messages.append({"role": "user", "content": [run_one(b) for b in calls]})
        continue

    if reply.stop_reason != "end_turn":
        raise RuntimeError(f"unhandled stop_reason: {reply.stop_reason} "
                           f"{reply.stop_details}")

    if goal_met():
        done = True
        break

    nudges += 1
    if nudges > 1:
        raise RuntimeError("stopped early twice — the goal, the check or the tools disagree")
    messages.append({"role": "user",
                     "content": "notes/paris.md does not have three lines yet. Finish the job."})

print(f"goal met in {turn} turn(s)" if done
      else f"stopped at the {MAX_TURNS}-turn cap without meeting the goal")

The Python timeout runs the tool on a pool thread and gives up on the result, not on the work. result(10) stops waiting, and the function keeps going in the background. That's honest rather than tidy. If the tool touches the outside world, hand it a real deadline of its own, timeout= on the HTTP call and statement_timeout on the query, because a cancelled wait is not a cancelled write.

When a tool hangs

A model with no deadline waits forever, and so does your loop, because station 3 is your code and nothing above it is watching a clock. This is the failure that looks like nothing at all: no error, no output, no bill, no end.

So every tool gets a deadline, and a blown deadline is information, not an exception. It goes back the ordinary way, in the ordinary place:

station 3 never returns http_get https://slow.example — 10 seconds gone, the socket is open, and your await is still waiting
  • you keep waiting no result is ever appended, so the loop has no next turn to take — the run has neither finished nor failed, and nothing in the logs says so
  • you time out and say so a tool_result with is_error: true and "timed out after 10000 ms" as its content, then round the loop goes
The dead road is not a crash, which is what makes it expensive: a hung agent looks exactly like a slow one for the first hour. Give the timeout back to the model and it does the sensible thing: tries the other source, narrows the request, or tells the user the service is down. A tool that failed is a fact the model can use. A tool that never answered is a loop with no exit.

Then the retry, which is where good intentions do damage. Retry the transport, not the decision. A 429 or a dropped connection is worth trying again with a short backoff, twice, and the model never needs to know. A tool that ran and returned something you didn't like is not a retry. It's a result, and the model is the one who should choose what to do about it.

And before you retry anything with a side effect, ask whether running it twice is safe. read_file is free to repeat. send_email is not. If a tool writes, either make it idempotent, so the same call twice leaves the same state, or don't retry it at all and let the model decide with the error in front of it.

What the transcript looks like

Three tool calls, four trips round the loop, and one goal check the model never sees. Watch where the run actually ends:

  1. user, write notes/paris.md with exactly three lines about the weather, then read it back
  2. assistant, tool_use write_file · {"path": "notes/paris.md", …}
  3. user, tool_result wrote 2 line(s) to notes/paris.md
  4. assistant, tool_use read_file · two lines come back · writes the third
  5. user, tool_result the three lines, back verbatim
  6. assistant, stop_reason: end_turn — and goalMet() agrees, so turn 4 is the last one
Turn 3 is the interesting one: the write came back two lines, not three, and the agent noticed from its own tool result rather than from you. That's the whole difference between lesson 2 and lesson 3: a loop that reads what it did. The last row is two conditions agreeing, and if only the first had held, your nudge would go in there instead and the tape would keep running.

Let the model pace itself: task budgets

Your counter stops the loop from outside, and the model never sees it coming. It gets cut off mid-job with no chance to wrap up. Task budgets fix that half. You hand the model a token allowance for the whole run, it sees a countdown while it works, and it prioritises and finishes gracefully instead of being guillotined. This is nice to see. It's beta, and it takes a header:

const reply = await client.beta.messages.create({
  betas: ["task-budgets-2026-03-13"],
  model: "claude-opus-5",
  max_tokens: 64000,
  output_config: {
    effort: "high",
    task_budget: { type: "tokens", total: 64000 },
  },
  tools: toolSchemas,
  messages,
});

Know exactly what you bought. A task budget is a suggestion the model can see. max_tokens is an enforced ceiling it cannot. Your turn counter is a hard stop on the number of round trips. They do different jobs and the budget replaces neither: use all three, and keep the counter, because a budget the model chooses to blow through is not a cap.

Straight about what we've shipped: the harness doesn't use task budgets. It counts steps, max steps, 40 by default in the settings panel, and stops there with a line in the log telling you it was the setting and not the model: stopped after 40 steps (the max-steps setting). That's the cheap version. It's four lines, and it's the one to write first.

The trade-off: your counter vs. the SDK's

The tool runners from lesson 2 have this built in. In TypeScript, client.beta.messages.toolRunner({ ..., max_iterations: 12 }) is your MAX_TURNS, and the runner stops at it. In Python you count in the body of for message in runner: and break, which is the same four lines you just wrote.

What the runner doesn't have is your goal. Nothing in the SDK knows that three lines is the job, so the check stays yours either way: iterate the runner, and when a message comes back with stop_reason: "end_turn", run your check and push one more user message if it fails. Per-turn hooks are where an approval gate, a log line, or a budget read go.

Take the runner for the loop. Keep the counter and the goal check for yourself, because they're the two things a general-purpose helper can't guess. And check stop_reason on the final message even when the runner ended cleanly. pause_turn comes back looking like success.

Five rules for stopping

The exercise

Do this in the harness. It's the same loop, already running, with twenty-one tools on a virtual filesystem and the counter from this lesson wired to the max steps box. Open KEYS, paste a key or point it at Ollama, then ask for something that genuinely needs several steps in order:

read notes/paris.md, add a fourth line with today's date, then read it back and tell me the line count

Watch the transcript for the shape from the tape above: a read, an edit, a second read, then prose. Three tool calls that had to happen in that order, and an agent that verified its own edit before saying it was done.

Then break it on purpose, which is the half that teaches:

All four are safe to run. The harness writes to a virtual filesystem in localStorage in your own tab, so nothing here touches your disk, /wipe clears the workspace, and /undo rewinds the files and the conversation until you close the tab. The runaway you provoke on purpose costs cents, which is the cheapest way to meet it.

Next week

Your agent now runs several steps and ends on a condition you chose. Everything it touched so far was code you wrote. Lesson 4, agent communication, is where that stops: you publish a handler on Blocks.ai so other agents can hire yours, and you write a client that hands work to an agent you have never seen the inside of. Latency becomes real, the failure might be someone else's, and a task can be accepted and then abandoned.

Which is why this lesson came first. Every one of those is a stopping problem, and you now have a counter, a deadline and a goal check to bring with you. An agent that reaches the network without them doesn't fail loudly. It waits.

An agent that stops on purpose is one you can leave running on someone else's behalf. That's the whole reason anybody trusts one with real work.

Where this sits in the whole course, and what comes after: the syllabus. Last week's loop, if you're arriving here first: lesson 2, tool calling.

open the harness

One lesson a week