Skip to content

Stop a run

A loop stops in one of four ways. Pick the one that matches who decides.

Who decides How state.stopped
A check on the State, before every turn An until function StoppedByUntil(name), the function's name
Your code or a tool, at a specific moment state.finish(answer) StoppedByFinish(answer)
A fixed maximum number of turns limit StoppedByLimit(turns), the limit
A permission, when it refuses a call with stop=True Denied(reason, stop=True), for example DecideByHuman's no (Ask before a tool runs) StoppedByPermission(call, permission)

Stop on a check

from pathlib import Path

from alpineagents import Agent, Message, State, StoppedByLimit, loop, tool, waiting_for_user


@tool
def read_file(path: str) -> str:
    """Read a text file"""
    return Path(path).read_text()


def spent_too_much(state: State) -> bool:
    return state.usage.output_tokens > 20_000


@loop(until=[waiting_for_user, spent_too_much], limit=30)
def careful(agent: Agent, state: State):
    agent.think(state)
    if state.pending_calls:
        agent.use_tools(state)


agent = Agent(model="claude-sonnet-5", tools=[read_file], loop=careful)

state = State(messages=[Message.user("Summarize every file in docs/")])
agent.run(state)
print(state.stopped)
if isinstance(state.stopped, StoppedByLimit):
    print(f"Stopped after {state.stopped.turns} turns. The task may be unfinished.")
  1. waiting_for_user is true when the model has answered: no call is pending, and the last message is the model's and asks for no tool. The default loop stops on it too (Write your own loop).
  2. spent_too_much takes the State and returns True to stop.
  3. until takes a list. The loop stops when any function in it returns True.
  4. After the run, state.stopped is StoppedByUntil("waiting_for_user"), StoppedByUntil("spent_too_much") or StoppedByLimit(30). The terminal shows the same name, for example done: stopped by spent_too_much.
  5. Reaching limit does not raise. Check state.stopped when an unfinished run matters, as the last lines do.

Give stop conditions clear names. The name is the only record of why the run stopped.

Stop from a tool

Let the model decide when the work is done, and return a structured answer:

from alpineagents import Agent, State, tool


@tool
def submit(summary: str, files_changed: list[str], state: State) -> None:
    """Submit the finished work. Call it once, at the end."""
    state.finish({"summary": summary, "files_changed": files_changed})


agent = Agent(
    model="claude-sonnet-5",
    system="When the work is done, call submit.",
    tools=[submit],
)
print(agent.run("Rename the helper functions in utils.py"))
  1. The model calls submit when it thinks the work is done.
  2. state.finish(...) sets state.answer to the dict. The loop stops before the next turn.
  3. agent.run returns the dict.
  4. The State is now finished for good: see State and history.

The model fills the tool's typed parameters, so the answer has a known shape. It is JSON data: a dataclass or a Pydantic model comes back as a read-only dict, and Review(**state.answer) rebuilds it.

Stop when the person says no

A permission that refuses a call with stop=True stops the loop before its next turn. Unlike finish, the State is not finished: add the person's next message and run again. See Ask before a tool runs.

In a loop without @loop

state.finish() and a permission's stop=True set state.stopped right away, and run resets it to None when it starts. A loop written without @loop has to check it itself: see Loops without @loop.