if ($stopReason === 'max_tokens') {
report(new \RuntimeException('Response truncated on max_tokens'));
$state['messages'][] = [
'role' => 'bot',
'content' => __('messages.errors.ai_truncated'),
'is_error' => true,
];
$state['hasError'] = true;
break;
}
Chapter 4 covered the cause. What matters here is the shape of the fix: truncation gets its own branch, before the general exit. Left to fall through, a cut-off reply renders identically to a complete one — confident, well-formed, and missing its conclusion.
The report() matters as much as the message. A truncation is a signal that a cap is wrong or a prompt asks for too much, and if it is only ever shown to a user it is never fixed.
Refusal Is Not an Error
// Safety classifiers declined. Not an error — say so and stop.
if ($stopReason === 'refusal') {
$state['messages'][] = [
'role' => 'bot',
'content' => __('messages.errors.ai_refused'),
'is_error' => true,
];
break;
}
Nothing failed. Your code is fine, the provider is fine, the request was well-formed. The model declined, and the correct behaviour is to say so plainly and stop.
Do not retry it. Do not rephrase and resubmit. A retry loop around a refusal is a machine trying to talk its way past a safety system, which is both futile and exactly the pattern abuse detection is looking for.
The History Is the State
There is no session on the provider's side. The entire state of the conversation is the array you keep and re-send:
$history[] = [
'role' => 'assistant',
'content' => $content,
];
$content is the raw content array, appended verbatim — not the text you extracted, not a summary. It contains typed blocks: text, tool calls, tool results, and possibly reasoning. Flatten it to a string and you have thrown away the tool calls, which means the next request will not make sense.
The rule: append what you were given, exactly as you were given it. Extract for display; store the original.
And because the history is re-sent in full every iteration, it grows monotonically through the turn — the input-token consequence from Chapter 4.
Read the Blocks by Type
A response is a list of typed blocks, and the loop wants them separated:
$textBlocks = array_filter($content, fn ($b) => $b['type'] === 'text');
$toolUseBlocks = array_filter($content, fn ($b) => $b['type'] === 'mcp_tool_use');
$toolResultBlocks = array_filter($content, fn ($b) => $b['type'] === 'mcp_tool_result');
Three types, three purposes. Text is shown to the user. Tool uses tell you what the model reached for — which is what drives the waiting indicator. Tool results carry the structured payloads the interface renders, which is Chapter 9.
Two things follow from filtering by type rather than indexing by position.
A response can hold several text blocks, so they are joined rather than picked:
$fullText = implode("\n", array_map(fn ($b) => $b['text'], $textBlocks));
if (! empty(trim($fullText))) {
// … append as a message …
}
A response can hold no text at all. A turn that only calls tools is normal, and appending an empty message for it puts blank bubbles in the transcript. Hence the emptiness check — a small guard that prevents a scruffy interface.
Two Caps, Not One
$maxIterations = 10;
An iteration cap alone is not enough. Ten iterations of a fast tool is a few seconds; ten iterations of a slow one exceeds any job timeout. So there is a wall-clock budget beside it, and whichever binds first wins.
Both need to be observable. Hitting the iteration cap is reported, not silently swallowed:
if (now()->greaterThan($deadline)) {
report(new \RuntimeException('Hit its wall-clock budget after '.$iteration.' iterations'));
break;
}
A loop that quietly stops at ten produces a subtly incomplete answer that looks like a normal one. If you cannot tell the difference between "finished" and "ran out of budget" in your logs, you cannot tell whether the cap is right.
What Happens After the Loop
Cache::forget($this->checkpointKey());
Cache::forget(self::cancelKey($this->cacheKey));
// The turn is over, so there is no step in flight to report.
$state['activity'] = '';
$this->publish($state, $history, done: true);
$this->recordUsage($iteration);
Every exit path — normal, cancelled, budgeted, refused, truncated — lands here. That is deliberate: break rather than return throughout the loop, so cleanup, the final publish and the usage accounting cannot be skipped by whichever exit happens to fire.
Clearing activity matters more than it looks. It is the last step the model took, and leaving it set means a finished conversation displays a stale "checked the URL" caption forever.
What This Chapter Argued
stop_reasonis control flow, not diagnostics. A loop that ignores it works for the common case and fails silently for four others.- Handle
pause_turnbefore the no-tools exit. It arrives with no tool calls, so a naive exit condition treats a paused turn as a finished one. - Order branches most specific first, with every continue above every exit.
- Give truncation its own branch and report it. Otherwise a cut-off reply renders as a complete one.
- A refusal is not an error and must never be retried.
- Append the assistant's content array verbatim. Extract for display, store the original — flattening loses the tool calls.
- Filter blocks by type, not position. Expect several text blocks, or none at all.
- Cap iterations and wall-clock, and report when either binds — a silent cap is indistinguishable from a finished turn.
- Exit via
break, neverreturn, so cleanup, the final publish and usage accounting run on every path.