Try this first

Look back at the loop from Lesson 1.1 and find the line that decides whether to keep going:

if not reply.wants_tool:
    break

One question. That is the entire control flow of an agent.

What is underneath it

wants_tool is true when the reply contains at least one tool call. Read that again,
because it is deliberately not the obvious implementation.

Each provider also reports a reason it stopped, and the adapter keeps that word in
reply.stop so you can see it:

reply.stop Provider What happened
tool_calls OpenAI, Ollama It wants a tool run
tool_use Anthropic The same thing
STOP Gemini Also the same thing — see below
stop / end_turn OpenAI / Anthropic It finished naturally
length / max_tokens OpenAI / Anthropic It hit your output limit mid-sentence
refusal Anthropic It declined the request

Why the loop does not check the stop word

Look at the Gemini row. When Gemini asks for a tool, it reports FinishReason.STOP — the
same value it uses for a perfectly ordinary finished answer. There is no special word.

So an agent written like this:

if reply.stop in ("tool_calls", "tool_use"):    # looks sensible, silently wrong
    run_the_tools()

works on two providers and quietly breaks on a third. It never runs a tool, never errors, and
the model appears to ignore the tools you gave it.

The reliable question is not “what word did it stop with?” but “did it ask for anything?”:

if reply.tool_calls:        # what wants_tool actually does
    run_the_tools()

This is worth more than the portability. Even on one provider, the stop word is a report
about the conversation
and the tool calls are the request itself. Check the request.

Two other rows deserve attention now, because ignoring them produces bugs that look like
something else.

The truncation trap

If the model runs out of output budget, you still get a normal-looking reply with
normal-looking text in it. The text is just cut off.

Code that only asks “does it want a tool?” treats that truncated text as a finished answer.
The learner then spends an hour wondering why the model “forgot” the end of its own sentence.
It did not forget. You cut it off.

if reply.stop in ("length", "max_tokens"):
    print("Warning: the reply was cut off. Raise max_tokens.")

The one that will bite you in Module 4

A reply that does not want a tool means the model stopped talking. It does not mean the job
is done.

Read that again, because it is the failure that Module 4 is built around. The model can write
“I have updated the file and everything is working” and stop, having written nothing to disk.
The stop signal is correct. The claim is wrong.

Stop signals tell you about the conversation. They tell you nothing about the world. Checking
the world is your job, and we build that check in Module 4.

The stop signal tells you why the model stopped talking. It never tells you whether the work
got done.

Try this before the next lesson

Set max_tokens=20 and ask something that needs a long answer.

Print reply.text and reply.stop together. Then look up your provider’s word for
truncation in the table above and confirm you got it. That is the value your loop has to
handle, and now you have seen it with your own model.