Skip to main content
Laravel, thinking fast.

Chapter 8

The Agent Loop

Julian Beaujardin

One call to a model is a question.

A loop around that call — send, read the answer, act on it, send again — is an agent. That loop is perhaps forty lines of code, and almost every one of them exists because something went wrong. This chapter is those forty lines.

If you skip everything else in this book, read this. It is the part that is genuinely different from ordinary Laravel work, and it is the part where plausible-looking code is most likely to be quietly wrong.

The Shape

$maxIterations = 10;
$iteration = 0;
$history = $this->conversationHistory;
$deadline = now()->addSeconds($this->timeout - 20);

while ($iteration < $maxIterations) {
    $iteration++;

    if (Cache::has(self::cancelKey($this->cacheKey))) {
        $state['stopped'] = true;
        break;
    }

    if (now()->greaterThan($deadline)) {
        report(new \RuntimeException('Hit its wall-clock budget after '.$iteration.' iterations'));
        break;
    }

    $response = $this->callClaudeAPI($history, $deadline);

    // … handle the response …
}

Four exits before the model is even called: iteration count, user cancellation, wall-clock budget, and — inside the call — a refusal to start work that cannot finish. Chapter 6 covered why the clocks are shaped that way. What follows is what happens to the response.

stop_reason Is Your Control Flow

Every response says why generation stopped, and that value is not diagnostic information. It is the branch condition for the entire loop.

Ignoring it is the single most common way to get an agent loop wrong, because a loop that ignores it appears to work. It handles the common case correctly and mishandles four others silently.

| stop_reason | Meaning | What the loop must do | |---|---|---| | end_turn | The model finished | Exit. Show what you have. | | (tool use) | It wants a tool run | Continue — send results back | | pause_turn | A long turn was suspended | Continue — re-send history unchanged | | max_tokens | Output was cut off | Stop, and tell the user it was truncated | | refusal | Safety declined | Stop, and say so — this is not an error |

Take them in the order they will bite you.

pause_turn, and Why Order Matters

When the toolset runs on the provider's side, a long turn can be suspended part-way and handed back to you to resume. The response carries pause_turn, some content, and — critically — no tool calls for you to act on.

That last detail is a trap, and here is the bug it causes. The natural exit condition for an agent loop is "stop when the model is done or has asked for nothing":

if ($stopReason === 'end_turn' || empty($toolUseBlocks)) {
    break;
}

A pause_turn response has no tool-use blocks. It hits empty($toolUseBlocks), the loop exits, and a turn that was merely paused is treated as finished. The user sees a half-completed answer and no error whatsoever.

So the check must come first:

// The MCP toolset runs server-side, so a long turn can pause at the server's
// iteration limit. Re-sending the history resumes it — there are no tool_use
// blocks to act on, so this must be checked before the end_turn/no-tools exit.
if ($stopReason === 'pause_turn') {
    continue;
}

if ($stopReason === 'end_turn' || empty($toolUseBlocks)) {
    break;
}

Resuming is just continuing: append what came back to the history and send it again. No special payload, no resume token.

Order your stop-reason branches from most specific to least, and put every "continue" case above every "exit" case. An exit condition that is a superset of a continue condition will swallow it, and it will do so silently.