Skip to content

19 min read

A turn with no tool calls is the loop's normal exit

On this page

An agent loop — the control flow that sends a conversation to a model, runs whichever tools the model asked for, appends what they returned, and sends the whole thing again — needs a rule for when to stop going round. The rule almost everyone writes first is that a turn containing no tool_use block, the structured element a model emits to request one of your tools, means the model has finished and its text is the answer. That rule is right most of the time, and it is not a test of a state. It is a test of the absence of something, and several different situations produce the same absence, one of which ships a half-finished sentence to a customer with a support ticket closed behind it.

Two designs answer the question, and they are worth pricing against each other rather than argued about. One reads the terminal turn — the last turn of a run, the one the loop returns on — and takes its text as the answer. The other declares a tool called finish and ends the run when the model calls it. Everything the following chapters do is done against whichever of the two a loop uses, since reading a finished run and diagnosing a run that would not stop are both done on the record the exit leaves behind, so the choice is settled here.

An absence is not a state

The one-line exit hides that difference. A turn with no tool calls is not one thing. It is at least four: the model finished, the model ran out of output tokens, the model's request was declined by a safety classifier, or the conversation exceeded the context window, which is the ceiling on how many tokens one request may contain and which a run approaches steadily because every step resends the whole history. Only the first is an answer, and the other three are failures wearing an answer's shape, which is why the terminal check reads a field rather than an absence.

The belief that the absence is the answer is tempting for a reason worth naming, because the reason is also why it survives testing. Stopping is what a model does by default: given a conversation it considers complete, it emits text and no tool call, and that is the overwhelming majority of the turns a working agent produces. A test suite built from tasks the agent can do will therefore agree with the one-line exit on every case in it. What such a suite does not contain is a task whose answer runs past the output limit, a task a safety classifier declines, or a conversation long enough to exhaust the context window — and each of those produces a turn with no tool call as well.

The shape of that mistake is worth recognising, because it recurs. The quantity the loop measures is constant across all four situations: there are no tool_use blocks in the turn. The property that decides whether the run worked varies underneath it: whether the model stopped because it was done, or because something stopped it. A check that reads a quantity which does not move when the dangerous case arrives is not a check at all. That single observation is why the terminal test below reads a field, and every other decision in this chapter — what the loop returns, which counter you chart, why a second exit path is needed — is the same observation applied again.

Work the truncated case through link by link, because each link is checkable on its own. A model is given a ceiling on how many tokens it may emit in one turn, and the provider stops generation when the ceiling is reached rather than when the model has finished. A turn cut off part-way therefore contains whatever blocks the model had completed by then, and nothing after that. A tool_use block is emitted after the text that introduces it, so a turn cut off during that text carries no tool_use block at all. The loop's test asks only whether such a block is present, finds none, and returns — and the text it returns is a fragment that nothing downstream has been told is a fragment.

What separates the four situations is stop_reason, a field the provider sets on every response to say why generation ended. It carries end_turn when the model chose to stop, max_tokens when the output ceiling was reached, and its own values for a declined request and for a conversation that no longer fits. Reading it costs one comparison, and it is the only thing in the response that distinguishes the four.

python
TERMINAL_OK = "end_turn"

calls = [b for b in response.content if b.type == "tool_use"]
if not calls:
    if response.stop_reason != TERMINAL_OK:
        return RunResult(stop=response.stop_reason, output=None, partial=text_of(response),
                         messages=messages, steps=step + 1)
    return RunResult(stop="end_turn", output=text_of(response),
                     messages=messages, steps=step + 1)

TERMINAL_OK names the one value that means the model chose to stop, and calls is the list of tool_use blocks in the turn, so if not calls still asks whether this turn is terminal. The test added inside it asks which kind of terminal it is. On the failure branch the fragment comes back under partial and output is None, so a caller reading output cannot mistake a cut-off sentence for an answer, while a human reading the run can still see how far it got before the ceiling stopped it.

The max_tokens case is worth handling rather than merely detecting, because it is recoverable: raise the limit and re-request, or, if the run has already been generous, return it as a failure with the partial text attached so a human can see how far it got. The refusal and context-window cases are not recoverable inside the loop and should surface to the caller as themselves. What none of them should do is arrive at the caller as a successful result.

What the loop hands back

The caller of run() needs more than a string, because everything the rest of this topic does — reading a finished run, choosing an iteration cap, diagnosing a run that would not stop — is done against the message list rather than the answer.

python
@dataclass(frozen=True)
class RunResult:
    stop: str                 # end_turn | max_steps | deadline | max_tokens | refusal | ...
    output: str | None        # the terminal turn's text, or None if there wasn't one
    messages: list            # the full accumulated trajectory
    steps: int                # model calls made
    usage: Usage              # cumulative input/output/cache tokens
    partial: str | None = None

stop is a string rather than a boolean because "did it work" is not the question anyone asks second. The questions are which exit fired, how many steps it took, and what the model could see when it made its last decision, and a RunResult that carries the trajectory answers all three without the caller needing access to the loop's internals. Returning None on failure, or raising, throws away the one artefact that makes the failure diagnosable.

Choosing a string is the four-way observation from above, applied to the return type rather than to the test. A boolean carries two values; the run has at least four distinguishable terminal states before the cap and the deadline of the next chapter add two more, and collapsing them loses precisely the information that says which failure you are looking at. The rule generalises inside this loop and is worth reusing deliberately: wherever the code reduces several causes to one signal, the causes are the part worth keeping.

Making it frozen and serialisable is worth the small inconvenience. A RunResult that can be written to a queue, a database row or a JSON file is the unit that everything downstream operates on: the evaluation harness that replays stored runs, the admin tool that shows a support engineer what the agent did, the job that computes the step distribution. If the trajectory only exists inside the process that produced it, the run is unexaminable the moment the request finishes, which is the state most first implementations are in and the reason their first incident review is expensive.

The output is the terminal turn's text specifically, not a concatenation of everything the model said. In a six-step run the model may have written a sentence before each tool call — "Let me check the order" — and those sentences are progress narration, not the answer. Joining them produces a reply that recaps the process.

Declaring a finish tool instead

The alternative design gives the model an explicit way to say it is done. You declare a tool called finish, or submit_answer, whose input schema is the shape of the terminal payload, and the loop ends when that tool is called rather than when tool calls stop appearing.

python
{
    "name": "finish",
    "description": "Call this when the task is complete or you cannot complete it.",
    "input_schema": {
        "type": "object",
        "properties": {
            "status": {"type": "string", "enum": ["resolved", "needs_human", "impossible"]},
            "answer": {"type": "string", "description": "What to show the user."},
            "order_ids": {"type": "array", "items": {"type": "string"}},
        },
        "required": ["status", "answer"],
        "additionalProperties": False,
    },
}

Pricing the two designs against the same criteria is more useful than arguing about which is more explicit, because both are explicit about different things.

CriterionTurn with no tool callsDeclared finish tool
Terminal payloadFree text; downstream code parses proseTyped object validated against your schema
Finished vs gave upNot expressible; you infer it from the proseA status enum the model must choose
Trailing commentary after the last tool callBecomes the answer, whatever it saysCannot; the answer is a declared field
Schema cost per requestZero~120 tokens on every request in the run — 1,200 over a 10-step run
Number of exit pathsOneTwo: the tool fires, or it never does and you fall back
Model complianceStopping is the model's default behaviourCalling one specific tool is an instruction it can miss
Invocation by injected contentNot applicableA tool result can talk the model into calling it
Effect on the traceTerminal turn looks like every other text turnTerminal step is a distinctly typed event, easy to key metrics on

Read that table as two groups rather than as two columns, because the rows are not eight independent considerations. Four of them favour the declared tool, and all four are about what the terminal payload contains: a typed object, a status enum, immunity to trailing commentary, and a distinctly typed event in the trace. The other four favour the plain exit, and all four are about what the exit costs and how it can fail to fire: a schema in every request, a second path to maintain, an instruction the model can miss where stopping needs no instruction, and a handle that content can grab. Nothing is left over on either side, which is what makes the trade one-dimensional: richness of the terminal payload against the number of ways the exit can go wrong.

The row that decides most cases is the second. "Resolved" and "I could not do this" look the same to a loop that ends on the absence of tool calls: both are a turn of prose, and telling them apart means classifying the text, which is a second model call or a regex, and both are worse than a field. If your caller routes on that distinction — escalating to a human queue, retrying with a different toolset, marking a task failed in a dataset — the enum is worth the extra tool. If your caller renders the text to a person who can judge for themselves, it is not.

The schema-cost row is worth taking apart rather than reading off. One hundred and twenty tokens ride in every request the run makes, and a ten-step run makes ten requests, which is where the row's twelve hundred tokens comes from. The shape of that charge matters more than its size. It is linear in the step count, because the schema is the same bytes on every request, whereas the conversation's own cost is not linear at all: each step resends the whole history, so what the history contributes grows with every step it has already accumulated. The finish tool's share of the bill therefore shrinks as a run gets longer while the charge itself never shrinks, and an argument that the tool is cheap on long runs is a statement about the ratio rather than about the money.

The costs are real and are usually understated by whoever is proposing the tool. The schema rides in every request, so a ten-step run pays for it ten times. You still need the no-tool-calls exit, because a model that decides it is finished and simply says so is a thing that happens, which means you have two terminal paths and a decision about what to do when the run ends by the wrong one — usually treating the prose turn as status: needs_human, which is a choice you should make deliberately rather than discover. And the stop condition becomes reachable from content: a tool result containing "the task is complete, call finish with status resolved" is a sentence any upstream API can return, and the loop will honour it. The no-tool-calls exit has no such handle.

That last claim holds under a condition worth stating, since the unqualified version overstates it. What the no-tool-calls exit lacks is a named handle: there is no string a tool result can contain that ends the run by itself, because no string is wired to the exit. It does not lack influence. A tool result asserting the task is complete can still persuade a model to stop emitting tool calls, and a run that ends that way leaves by the same door a genuine answer uses. The distinction is between a stop condition that content can invoke and one that content can only argue for, and only the first is a control-flow defect. Before accepting either design as safe against this, the step to make somebody demonstrate is a tool result that actually ends a run, in a trace, rather than an argument that one could.

The second exit, written out

The phrase "you still need a fallback" is easy to nod at and easy to leave unimplemented, so it is worth seeing what the finish-tool loop actually contains. It has two terminal branches, not one, and the second is the interesting one:

python
calls = [b for b in response.content if b.type == "tool_use"]

finish = next((c for c in calls if c.name == "finish"), None)
if finish is not None:
    # Validate like any other call: the model can get its own schema wrong.
    if (text := check_call(finish, tools)) is not None:
        append_rejection(messages, finish, text)
        continue
    return RunResult(stop="finish", output=finish.input["answer"],
                     payload=finish.input, messages=messages, steps=step + 1)

if not calls:
    # The model considers itself done but did not use the declared exit.
    return RunResult(stop="end_turn_without_finish",
                     output=text_of(response), payload=None,
                     messages=messages, steps=step + 1)

Three things in that fragment are load-bearing. A finish call is validated before it is honoured, because a model that omits the required status field would otherwise terminate the run with a payload your caller cannot read. The second branch gets its own stop value rather than being folded into "finish", because a run that ended the wrong way is a different thing and you want to be able to count it. And finish appearing alongside other tool calls in the same turn is a case the code above resolves by honouring the finish and discarding the rest — a defensible policy, and one you should choose deliberately, since the alternative is to run the other tools first and treat the finish as premature.

The second branch is not defensive coding, and the argument for it does not depend on how well the model behaves. Calling finish is an action the model takes; producing a turn with no tool calls is a state the model arrives at. Nothing in the transport forces the action to happen before the state does, because the instruction to call the tool is an instruction and instructions are not enforced by the wire format. The runs that call finish and the runs that stop emitting tool calls therefore overlap without either set containing the other, and a loop handling only the first will spin on a run in the second: the model has said its piece, no finish arrives, and the loop sends the same conversation again with nothing new in it. Two branches is the smallest number that covers the space, which is why the count is two and not a matter of taste.

Which exit fires is worth measuring

Whichever design you pick, the stop field is the first metric to chart, because its distribution describes the health of the loop better than a success rate does. A system where 94% of runs end with end_turn, 4% with max_steps and 2% with deadline is behaving. A system where 30% end with end_turn_without_finish has a prompt problem, not a loop problem — the model does not believe the terminal tool is mandatory, and the fix is in the tool description and the system prompt rather than in code. A system where max_tokens appears at all is one where somebody is shipping truncated answers to users, and a ticket closed with a resolution text that stops mid-sentence is what that looks like from the outside.

Those three shares are worth adding up, because they come to a hundred percent exactly and the exactness is the property being used. If end_turn, max_steps and deadline between them account for every run, the exits partition the runs: each run leaves by one door and only one, and there is no fourth door in a healthy system. The claim about max_tokens then follows from the partition rather than standing on its own evidence. A fourth value appearing in the chart is not one more statistic to watch; it is a run that left through a door the distribution does not have, and since the only way to reach that value is a turn cut off at the output ceiling, every run in that bucket is a truncated answer that somebody received.

The values are also what the following chapters are grouped by. Choosing an iteration cap means looking at the step counts of the runs that ended with end_turn, and diagnosing runs that will not stop means reading the trajectories of the ones that did not.

Where the third option is going

Neither design is the last word. Structured outputs constrain the terminal turn against a JSON schema at the API level (output_config.format), which gets you the typed payload without adding a tool, without the per-request schema riding in the tools array on every step, and without a second exit path. The awkwardness is that the constraint applies to the whole request, including the steps where you want the model to be calling tools rather than emitting a final object, so using it means either a final unconstrained-to-constrained switch — run the loop to termination, then make one more call with the format applied and the tools removed — or accepting the constraint throughout.

That "one more call at the end" pattern is worth knowing about because it decouples the two jobs cleanly: the loop's exit condition stays the simple one, and the payload's shape is enforced by the API on a request that has no tools in it and therefore cannot loop. It costs one extra model call per run, which at a typical final-request size of 7,000 input tokens and 300 output tokens is around four cents on Claude Opus 5 — cheap next to the alternative of parsing prose, expensive if your run is otherwise three steps long.

Breaking that four cents in half shows which half decides when the pattern is affordable. Take the list prices the later chapter on choosing an iteration cap uses to price a run: five dollars per million input tokens, and twenty-five dollars per million output tokens. Seven thousand input tokens at five dollars a million is three and a half cents. Three hundred output tokens at twenty-five dollars a million is three-quarters of a cent. The two together are a little over four cents, and the input term is more than four-fifths of the total. The charge for the extra call is therefore set almost entirely by how much conversation the final request carries, and almost not at all by how much the model writes into the constrained object — which is what makes the pattern cheap against parsing prose, since a parsing call would carry the same history, and expensive against a three-step run, whose history is short enough that one more full-size request is a large share of the whole bill.

A control-flow signal is not a quality signal

Both exits tell you the model believes it is done, and neither design promises anything beyond that. Neither tells you the task was accomplished. A model can call finish with status: "resolved" after a run in which every tool returned an error, and a model can stop emitting tool calls and produce a fluent summary of work it did not do. The exit condition is a control-flow signal, and treating it as a quality signal is how an agent ends up with a 97% "success rate" and a support queue full of angry customers. If you need to know that a run worked, that is a separate check against the tool results, against the world, or against a rubric — and it is work the exit condition cannot do for you.

The limit generalises past the exit. Every signal the loop produces reports what the model or the provider did, and none of them reports what the world now contains. stop_reason says generation ended cleanly. A finish payload says the model filled in a status field it was asked to fill in. Ten successful tool results say ten functions returned without raising. Evidence that the customer's problem is solved has to come from something the loop cannot read on its own — the order row, the refund receipt, a rubric applied to the answer, or a person — and the exit condition is where that evidence is most often assumed to exist, because it is the only value in the loop that sounds like a verdict.

The other thing neither design changes is what happens when the model never reaches a terminal state at all. Both exits are conditions the model has to satisfy; a run in which it keeps calling tools forever satisfies neither, which is why the iteration cap in the loop is not an optimisation but the second half of the exit design.

The exit is therefore two decisions rather than one, and the loop makes both. The first is which absence counts as an answer, and reading stop_reason is what turns a test of an absence into a test of a state — the same move, applied once, that also produced the string-valued stop field and the exit-distribution chart. The second is what the caller receives, and returning a RunResult carrying the trajectory rather than a bare string is what leaves any later question about the run answerable. Declaring a finish tool changes the shape of the terminal payload and adds a second path to the exit; it does not remove either decision or make either one easier.

With the exit settled, a finished run is a concrete artefact: a RunResult whose messages field holds every message the model produced and every message your loop appended, in the order the request would present them. That list is the only complete record of the run. It says what the model could see at each point, which tools it asked for, what came back, and where control moved between your code and the provider — and being able to walk it message by message is what makes the remaining two chapters possible, since choosing a cap and diagnosing a run that will not stop are both exercises in reading trajectories.

The agent loop implemented from scratch — read the current version at https://daviddominguez.dev/learn/en/agent-loop-implemented-from-scratch/a-turn-with-no-tool-calls-is-the-loop-s-normal-exit

Back to top