Know why the model stopped#
A request ends when the model has nothing more to ask for. That is not the same as the model finishing. It may have refused, run into its output limit, filled the context window, or lost its connection halfway through a sentence. Providers report each of these differently, and a program that only looks for tool calls sees all of them as “the model answered”.
Zett Agent reads every provider’s report into one vocabulary and puts it where a host can see it, instead of dropping it.
Check how it ended#
An abnormal stop is on the message the run returns:
message = await agent.run("Summarize this repository")
if message.stop is not None:
# Not a normal ending: the text may be missing, partial, or a refusal.
print(message.stop.reason, message.stop.category, message.stop.message)
message.stop is None when the model finished or asked for tools, which includes
every message stored before this existed. When it is set it is a
StopInfo:
Field |
Meaning |
|---|---|
|
The normalized |
|
The provider’s own value, unchanged, such as |
|
What kind: the provider’s policy category ( |
|
A sentence for a person: the refusal text, or the provider’s explanation. Show it; do not parse it. |
|
A model the provider suggests retrying on, when it does. |
|
Provider-specific facts with no normalized home, such as an OpenRouter |
Scripts can ask to be told instead of checking:
message = (await agent.run("...")).raise_for_stop() # raises AgentStopError
The runtime never raises it for you. A refusal is a normal outcome, so a host that does not look still gets the conversation back.
Hear about it as it happens#
A streaming UI gets a MODEL_STOPPED event right after MODEL_COMPLETED. It is sent
only for an abnormal stop, so there is nothing to filter:
async for event in client.stream("Summarize this repository"):
if event.type == AgentEventType.MODEL_STOPPED:
show_notice(event.stop.describe()) # e.g. "refusal (cyber): ..."
With an AgentEventDispatcher, override
on_model_stopped_event. An extension sees the same value as response.stop in
after_model.
RUN_COMPLETED still follows. It means the request ran to its end, and a refusal
ends a request and completes it; MODEL_STOPPED is how you tell the two apart.
What each reason means#
Reason |
Meaning |
What the runtime does |
|---|---|---|
|
The model finished, or hit a stop sequence you set. |
Nothing; not reported. |
|
The model asked for tools. |
Runs them; not reported. |
|
The output limit was reached. Text, or a tool call’s arguments, is cut off. |
Reports it. A tool call with cut-off arguments is answered with an error, as before. |
|
The context window filled. The output is cut off. |
Reports it. |
|
The model, or a safety system in front of it, declined. |
Withholds tool calls, ends the request, reports it. |
|
Infrastructure ended the response: a provider error, a dropped stream, no capacity. |
Withholds tool calls, ends the request, reports it. |
|
The provider handed control back and wants the turn continued. |
Continues, up to |
|
A value this library does not know. |
Reports it and otherwise carries on. |
A new provider value never breaks a run: it becomes OTHER with the original kept.
Tool calls that come with a refusal are not run#
A refusal can arrive after a complete tool call was already streamed, and an interrupted
stream may end in the middle of one. Running that call would act on output the model or
the provider disowned. So for REFUSAL and INTERRUPTED the calls are removed from
the stored message, the tool is never started, and the names of what was withheld are in
stop.detail["withheld_tool_calls"]. The provider’s own record of the call goes too,
because a tool_use replayed without a result is rejected by the API.
Download stop_reasons.py for a complete,
offline program that streams a partial answer and a tool call, refuses, and shows that
the tool never ran.
async def main() -> None:
agent = await create_agent(RefusingModel(), config=AgentRunConfig("stop-reasons"), tools=[delete_file])
heard = []
async for event in agent.stream("Clean up the project"):
if event.type is AgentEventType.MODEL_STOPPED:
assert event.stop is not None
heard.append(event.stop.describe())
if event.type is AgentEventType.RUN_COMPLETED:
assert isinstance(event.message, AssistantMessage)
message = event.message
# The run completed, and the message says it was not an answer.
assert message.stop is not None and message.stop.reason is StopReason.REFUSAL
assert message.stop.category == "cyber" and not message.stop.ok
assert heard == ["refusal (cyber): This request was declined."]
# The tool call that came with the refusal was withheld: the tool never started.
assert RAN == [] and message.tool_calls == ()
assert message.stop.detail["withheld_tool_calls"] == ["delete_file"]
# A script that prefers an exception to checking can ask for one.
try:
message.raise_for_stop()
except Exception as error:
assert type(error).__name__ == "AgentStopError"
print("Refusal reported; the tool call was not run")
Where the reason comes from#
A provider can say it in four ways, and each is read:
A value in
finish_reasonorstop_reason. Every provider.A refusal in the content. OpenAI’s
refusalfield and content block. The text is kept and shown like any other answer, and it becomesstop.message. This is the one that used to be lost: such a response finishes withstopand no text.A refusal before any output, which arrives as an error rather than a stop: a blocked prompt on Google, a
content_filter400on Kimi, a moderation or guardrail403on OpenRouter, a policy failure on the OpenAI Responses API. These are answered as aREFUSALwith an empty message, so the conversation continues and the host is told why. Every other error status still raises as it did.An error inside a successful stream. OpenRouter sends one after the
200is committed. With output already written it ends the response asINTERRUPTEDand keeps the text; with none, it is raised as an error, so the usual retry applies.
What each provider sends#
These are the values each provider documents, and what they become. A value not listed is
OTHER.
Provider |
Values |
Refusals arrive as |
|---|---|---|
Anthropic |
|
|
OpenAI Chat |
|
|
OpenAI Responses |
|
A |
DeepSeek |
|
|
GLM (Z.ai) |
|
|
Kimi (Moonshot) |
|
|
OpenRouter |
|
|
OpenCode Zen |
A gateway: the values of the protocol you choose (Responses, Chat, Messages, or Gemini) |
As that protocol |
|
The finish reasons above, or |
|
Mistral |
|
No dedicated value |
Qwen, Groq, xAI, Ollama |
Qwen documents |
Not documented by the provider |
Gemini reports STOP whether or not it called a function, so a step that finished with
tool calls is TOOL_USE. finishMessage is dropped by the Gemini API path of the
google-genai SDK (only Vertex keeps it), so a Google refusal has a category but no
sentence.
Choose whether the model reads a refusal again#
By default a refused answer stays in the history and is sent again with every later
request, like any other assistant message. Anthropic advises treating output produced
before a refusal as incomplete and discarding it. Add
RefusalReplayExtension to choose:
from zett_agent.extensions.refusal_replay import RefusalReplay, RefusalReplayExtension
agent = await create_agent(model, extensions=[RefusalReplayExtension(replay=RefusalReplay.DROP)])
Only the request changes. The message stays in stored history and on screen, so changing the setting never rewrites a session. Dropping a message can leave two user messages in a row, and providers differ in whether they accept that, so test it against yours.
Subagents and goals#
A subagent that was refused still returns a message, and its text can be empty or partial.
The parent model is told: the report starts with a notice, and SubAgentResult.stop
carries the reason. A background task whose child stopped early is still completed,
because the child ended, and says so in stop, in its note, and in the completion notice.
A goal is not evaluated against a refused or interrupted answer. Asking the model to try again would only meet the same refusal, so the request ends and the host hears why.
What this does not do#
No retry on another model. A refusal is reported, not worked around. Anthropic documents server-side and client-side fallback to a different model; acting on
stop.recommended_modelis left to the host.No removal of a refused turn from the history unless you choose it above.
No continuing of a cut-off response by itself. A response that stopped as
INTERRUPTEDorMAX_OUTPUTis picked up again by callingresume(); see Resume an interrupted session. A refusal orCONTEXT_FULLcannot be resumed.No guarantee the provider says why. A refusal with no category and no text is reported as a refusal with nothing to show.
Next: Stream to a terminal or UI for the full event list, or Connect a model provider to connect one.
Related API: StopInfo, StopReason,
AgentStopError,
RefusalReplayExtension.