Stop Reasons and Tool Results
Explore how to extend an agent loop in the Claude Client SDK to handle all stop reasons like max tokens and stop sequences, and to process both successful and error tool results effectively. Learn to manage message state accurately to avoid failure modes in AI system responses.
In the previous lesson, we built a loop that handles the happy path: Claude calls a tool, we return the result, and Claude answers. That loop handled two stop reasons correctly but left two unhandled: "max_tokens" and "stop_sequence". It also assumed the tool would always succeed.
In this lesson, we extend the loop to handle all four stop reasons, introduce a tool that can fail, and trace what actually changes in the messages array when errors are returned. Then we break the loop on purpose to see what inaccurate state management looks like. By the end of this lesson, we will have:
A loop that branches correctly on all four stop reasons
A mock tool that returns both success and structured error responses
A comparison of the messages array when tool results carry errors vs. successes
A concrete demonstration of what breaks when tool results are appended with the wrong role
The two cases we left unhandled
The loop from the previous lesson only acted on "tool_use" and "end_turn". The other two stop reasons were silently ignored, meaning the loop would fall off the end of the while block without returning anything. That is a silent failure: the function returns None, and the caller gets no signal about what went wrong.
Before adding error-returning tools, we need to close these two branches. Here’s the full four-branch skeleton that replaces the loop body:
while True:response = client.messages.create(model="claude-opus-4-8",max_tokens=1024,system=SYSTEM,tools=TOOLS,messages=messages)if response.stop_reason == "end_turn":for block in response.content:if block.type == "text":return block.textreturn ""if response.stop_reason == "tool_use":messages.append({"role": "assistant", "content": response.content})tool_results = []for block in response.content:if block.type == "tool_use":result = dispatch_tool(block.name, block.input)tool_results.append({"type": "tool_result","tool_use_id": block.id,"content": result})messages.append({"role": "user", "content": tool_results})continueif response.stop_reason == "max_tokens":raise RuntimeError(f"Response truncated after {response.usage.output_tokens} tokens. ""Increase max_tokens or shorten the task.")if response.stop_reason == "stop_sequence":for block in response.content:if block.type == "text":return block.textreturn ""
Lines 1–8: The API call is unchanged from the previous lesson. The model, token limit, system prompt, tools list, and messages array are all sent on every iteration.
Lines 10–14:
"end_turn"finds the text block and returns it. If no text block is present (uncommon but possible when Claude ends a turn after only a tool call), we return an empty string rather thanNone.Lines 16–27:
"tool_use"appends the assistant turn, dispatches each tool, collects results, appends them as auserturn, and callscontinueto start the next iteration. Thecontinueis explicit here to make the control flow readable.Lines 29–32: ...