Try this first

Give Rover a task with no clear finish:

“Improve the code in agent.py. Keep improving it until it is perfect.”

Watch it. It reads, edits, reads again, edits again. It is not stuck — each turn does
something. It just has no way to decide that it is finished, because you gave it a goal with
no end state.

Stop it with Ctrl-C before your credit does something you regret.

What you just did

The loop from Module 1 has no exit except the model’s own choice:

while True:
    reply = llm.send(messages, TOOLS)
    if not reply.wants_tool:
        break

If the model keeps asking for tools, that loop keeps running. Forever. Every iteration costs a
full API call with the entire transcript resent.

This is the first thing to add to any agent that runs unattended, and it is one line.

The fix

MAX_TURNS = 25

for turn in range(MAX_TURNS):
    reply = llm.send(messages, TOOLS)
    ...
    if not reply.wants_tool:
        break
else:
    print(f"[stopped after {MAX_TURNS} turns without finishing]")

The for ... else runs the else only if the loop finished without break — which is
exactly the runaway case, and reads better than a counter and a flag.

Choosing the number

There is no correct value, but there is a way to pick one.

Run your real tasks and record how many turns each takes. Set the limit to roughly double the
worst honest case. If ordinary work takes four to eight turns, twenty-five gives room for a
hard task and still catches a loop within a minute.

Then treat hitting the limit as a signal, not just a stop. A task that suddenly needs thirty
turns when it used to need six has told you something — usually that a tool started failing,
or a description changed and the model is now flailing.

Not every long run is a loop

Two different situations look identical from outside:

What you see What it might be How to tell
Turn 15, still going A genuinely hard task Each turn does something new
Turn 15, still going A loop The same tool call, with the same arguments, repeating

The second is worth detecting on its own, because it is common and it is cheap to catch:

recent = []          # last few (name, arguments) pairs

signature = (call.name, json.dumps(call.arguments, sort_keys=True))
if recent.count(signature) >= 3:
    output, failed = (
        "You have already called this tool with these exact arguments three times "
        "and got the same result. Try something different, or stop and explain "
        "what is blocking you."
    ), True
recent.append(signature)
recent = recent[-10:]

Note the message. It does not just refuse — it tells the model what pattern it is stuck in and
gives it two ways out. Often it takes the second one, and explains the blocker, which is
usually more useful than the task succeeding.

A turn limit stops the bleeding. A repeat detector tells you why it was bleeding.

Try this before the next lesson

Add both. Then run the “keep improving it until it is perfect” task again.

Watch which one fires. On most runs it is the turn limit, because the model varies its edits
enough to dodge the repeat check. That tells you the two guards are catching different things,
and you want both.