Your research agent is on tool call number nine, your user has already found the answer on Stack Overflow, and they’re mashing a Stop button that does nothing. Meanwhile the token meter keeps spinning. Laravel AI SDK 1.2.1 is a patch release with a real new feature: Stop middleware, a clean way to end an agent run before the next model call.
TL;DR
- 1.2.1 (Oct 12, 2026) adds
Laravel\Ai\Middleware\StopwithStop::when()andStop::unless(), plusStepResult::stop()for your own middleware. - The run ends before the next model call. Completed steps, tool results and usage are kept;
textis''. - A stopped stream ends with
stream_endandreason: "cancelled"(a newFinishReason::Cancelled). The Vercel protocol reports it asother. - Also in 1.2.1: OpenRouter image models like Flux work, Ollama embeddings keep their native size, and a short
Retry-Afteris honoured before failover. Upgrade withcomposer update laravel/ai.
What’s in 1.2.1#
| Change | PR | Why you care |
|---|---|---|
Stop middleware (Stop::when, Stop::unless, StepResult::stop()) | #1072 | Stop buttons and token budgets without fake responses |
OpenRouter image generation uses /images | #1116 | Image-only models (Flux) stop failing with a 404 |
| Ollama keeps native embedding dimensions | #1115 | No more silent truncation to 768 |
| OpenRouter reasoning replayed in tool loops | #1110 | Reasoning models stop “starting over” every step |
One retry after a short Retry-After | #1112 | Brief 429s don’t fail over or fail the prompt |
| Terminal Responses API stream events handled | #1113 | response.incomplete keeps usage and finish reason |
| Structured output with quotes in keys decoded | #1111 | $response->structured no longer comes back [] |
CopyFile refuses directories | #1109 | Same guard as MoveFile and DeleteFile |
Everything below ran in a Laravel 13.35 app with laravel/ai 1.2.1. Agents were faked with ResearchAgent::fake(), and the token budget test used Http::fake() with real Cohere response shapes, so no API key was charged.
Why agents needed a Stop#
Agent middleware runs once per step, before each model call. Since 1.0 it could change the model, trim tools or summarise history. What it couldn’t do was say “that’s enough, end the run”.
The only escape hatch was to answer the step yourself with a hand-built StepResponse. The SDK then treated your fake as a real model reply.
1.2.0, then 1.2.1:
use Laravel\Ai\Gateway\StepResponse;
use Laravel\Ai\Responses\Data\FinishReason;
use Laravel\Ai\Responses\Data\Meta;
use Laravel\Ai\Responses\Data\TextUsage;
public function handle(PendingStep $step, Closure $next)
{
if (! $this->cancelled($step)) {
return $next($step);
}
// Pretend the model answered with nothing...
return new StepResponse(
text: '',
toolCalls: [],
finishReason: FinishReason::Stop,
usage: new TextUsage,
meta: new Meta($step->provider, $step->model),
);
}use Laravel\Ai\Gateway\StepResult;
public function handle(PendingStep $step, Closure $next)
{
return $this->cancelled($step) ? StepResult::stop() : $next($step);
}StepResult::stop() tells the loop “no model call, no fake step, we’re done”. And for the two most common cases you don’t even need a class.
Stop::when and Stop::unless#
Laravel\Ai\Middleware\Stop takes a closure (or a plain bool) that receives the PendingStep. Stop::when() stops when it returns true; Stop::unless() stops when it returns false.
Here is a research agent with a Stop button and a per-run token budget, straight from the PR’s example:
<?php
namespace App\Ai\Agents;
use App\Ai\Middleware\EnforceDailyTokenBudget;
use App\Ai\Tools\SearchDocs;
use App\Models\Chat;
use Illuminate\Support\Facades\Cache;
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Contracts\HasMiddleware;
use Laravel\Ai\Contracts\HasTools;
use Laravel\Ai\Middleware\Stop;
use Laravel\Ai\PendingStep;
use Laravel\Ai\Promptable;
use Stringable;
class ResearchAgent implements Agent, HasMiddleware, HasTools
{
use Promptable;
public function __construct(public Chat $chat) {}
public function instructions(): Stringable|string
{
return 'Research the question with the docs tool, then answer briefly.';
}
public function tools(): iterable
{
return [new SearchDocs];
}
public function middleware(): array
{
return [
// The user clicked "Stop" in the UI
Stop::when(fn (PendingStep $step) => Cache::has($this->chat->stopKey())),
// Keep going only while this run is under 50k input tokens
Stop::unless(fn (PendingStep $step) => $step->usage->inputTokens < 50_000),
new EnforceDailyTokenBudget($this->chat->user),
];
}
}stopKey() is a one-liner on the model, so the controller and the agent agree on the cache key:
public function stopKey(): string
{
return "chat:{$this->id}:stopped";
}What the PendingStep tells you#
The closure gets the step that’s about to run, so you can decide with the full picture:
| Property | What it holds |
|---|---|
$step->number / isFirstStep() | Step index, 0 for the first call |
$step->steps | The steps completed so far in this run |
$step->usage | Usage accumulated by completed steps (inputTokens, outputTokens, …) |
$step->tools, $step->messages, $step->model | What’s about to be sent |
Gotcha: a budget check is a soft ceiling
$step->usage only counts steps that already finished, so the step that crosses the line still runs. In our test each Cohere step used 30,000 input tokens: steps 1 and 2 ran (60,000 total), and the third model call was never made. Set the threshold a step below your hard limit.
A custom middleware with StepResult::stop()#
Stop::when() covers one-liners. When you need dependencies or bookkeeping, write a class with php artisan make:agent-middleware and return StepResult::stop() yourself.
This one enforces a daily budget per user, and uses then() to record what each step actually cost:
<?php
namespace App\Ai\Middleware;
use App\Models\User;
use Closure;
use Illuminate\Support\Facades\Cache;
use Laravel\Ai\Gateway\StepResponse;
use Laravel\Ai\Gateway\StepResult;
use Laravel\Ai\PendingStep;
class EnforceDailyTokenBudget
{
public function __construct(
protected User $user,
protected int $limit = 200_000,
) {}
public function handle(PendingStep $step, Closure $next)
{
$key = "ai-tokens:{$this->user->id}:".today()->toDateString();
if (Cache::get($key, 0) >= $this->limit) {
return StepResult::stop();
}
return $next($step)->then(function (StepResponse $response) use ($key) {
Cache::add($key, 0, now()->endOfDay());
Cache::increment($key, $response->usage->inputTokens + $response->usage->outputTokens);
});
}
}After the two-step Cohere run above, the counter read 60400 (60,000 input + 400 output). With the counter pre-set to 250,000, the next prompt returned an empty response without calling the provider at all.
What a stopped run returns#
We stopped runs at different points and checked what came back:
| Stopped… | prompt() returns | stream() ends with |
|---|---|---|
| Before the first step | text: '', steps empty | stream_end, reason: "cancelled" |
| After a tool step | text: '', completed steps and toolResults kept (last step’s finish reason tool_calls) | stream_end, reason: "cancelled" |
| Not stopped | The model’s answer | stream_end, reason: "stop" |
This is the tail of the SSE response when the user hit Stop during the first tool call:
data: {"type":"tool_result","tool_name":"SearchDocs","result":"Top result for [queues]: ...","successful":true, ...}
data: {"type":"stream_end","reason":"cancelled","usage":{"input_tokens":0,"output_tokens":0, ...}, ...}
data: [DONE]Heads up: it's cancelled, not stop
The PR description says a stopped stream finishes with a stop reason, but the merged code adds a new FinishReason::Cancelled case and the tests assert cancelled. That’s what we saw too. With ->usingVercelDataProtocol() the finish part reads "finishReason":"other", because the Vercel mapping has no cancelled.
Two more details from the tests worth knowing:
- A structured agent stopped before its first step returns a plain
AgentResponsewith empty text, not a structured one. Guard your$response['field']access. Update: since laravel/ai 1.3 it returns aStructuredAgentResponsewithstructured: []instead. - With
RemembersConversations, a stopped turn is saved up to its last tool result. Stopped before step one? Only the user message is stored.
Wire a Stop button with Vue and Inertia#
The backend needs two routes: one that streams the run, one that raises the flag.
use App\Http\Controllers\ChatController;
Route::middleware('auth')->group(function () {
Route::post('/chats/{chat}/stream', [ChatController::class, 'stream'])->name('chats.stream');
Route::post('/chats/{chat}/stop', [ChatController::class, 'stop'])->name('chats.stop');
});<?php
namespace App\Http\Controllers;
use App\Ai\Agents\ResearchAgent;
use App\Models\Chat;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
class ChatController
{
public function stream(Request $request, Chat $chat)
{
abort_unless($chat->user->is($request->user()), 403);
Cache::forget($chat->stopKey()); // a fresh run starts un-stopped
return (new ResearchAgent($chat))->stream($request->string('prompt'));
}
public function stop(Request $request, Chat $chat)
{
abort_unless($chat->user->is($request->user()), 403);
Cache::put($chat->stopKey(), true, now()->addMinutes(10));
return response()->noContent();
}
}On the front end, the stream is read with fetch (it’s a POST, so no EventSource), and the Stop button uses Inertia 3’s useHttp, which sends a plain request with CSRF handled for you:
<script setup lang="ts">
import { useHttp } from '@inertiajs/vue3'
import { ref } from 'vue'
const props = defineProps<{ chatId: number }>()
const prompt = ref('')
const answer = ref('')
const status = ref<'idle' | 'streaming' | 'stopped' | 'done'>('idle')
const stopRequest = useHttp('post', `/chats/${props.chatId}/stop`, {})
function xsrfToken(): string {
const match = document.cookie.match(/(?:^|; )XSRF-TOKEN=([^;]+)/)
return match ? decodeURIComponent(match[1]) : ''
}
async function ask() {
answer.value = ''
status.value = 'streaming'
const response = await fetch(`/chats/${props.chatId}/stream`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-XSRF-TOKEN': xsrfToken() },
body: JSON.stringify({ prompt: prompt.value }),
})
const reader = response.body!.pipeThrough(new TextDecoderStream()).getReader()
let buffer = ''
while (true) {
const { value, done } = await reader.read()
if (done) break
buffer += value
const chunks = buffer.split('\n\n')
buffer = chunks.pop() ?? ''
for (const chunk of chunks) {
const data = chunk.replace(/^data: /, '')
if (data === '[DONE]') continue
const event = JSON.parse(data)
if (event.type === 'text_delta') answer.value += event.delta
if (event.type === 'stream_end') {
status.value = event.reason === 'cancelled' ? 'stopped' : 'done'
}
}
}
}
</script>
<template>
<form @submit.prevent="ask">
<textarea v-model="prompt" />
<button type="submit" :disabled="status === 'streaming'">Ask</button>
<button
v-if="status === 'streaming'"
type="button"
:disabled="stopRequest.processing"
@click="stopRequest.submit()"
>
Stop
</button>
</form>
<p>{{ answer }}</p>
<p v-if="status === 'stopped'">Stopped. Here's what the agent found so far.</p>
</template>The component passed vue-tsc with @inertiajs/vue3 3.9.1. In a Pest or PHPUnit test, $this->post(route('chats.stop', $chat))->assertNoContent() raised the flag, and another user’s chat got a 403.
Pro tip: the current step always finishes
Stop runs between steps. If the model is mid-answer when the user clicks, that answer streams to the end, then the run stops before the next call. For a “kill it now” button, also abort the fetch on the client with an AbortController. Stop middleware makes sure the server doesn’t start another (paid) step either way.
The other 1.2.1 fixes#
OpenRouter images (#1116). Image generation used to go through chat/completions, which only works for chat models that can output images, so Flux returned a 404. It now uses OpenRouter’s /images endpoint. quality() still maps to 1K, 2K and 4K, so existing code keeps working.
Ollama embeddings (#1115). The SDK used to send dimensions: 768 by default, which truncated larger models (qwen3-embedding, bge-m3) and broke smaller ones (all-minilm). With no dimensions configured, Ollama’s native size is now used. If you stored truncated vectors, re-embed before you mix old and new rows in a similarity search.
Retry-After (#1112). On a 429 or overloaded status with a numeric Retry-After of 10 seconds or less, the SDK waits and retries once before failing over. Longer delays, HTTP-date values or no header fail over immediately, as before.
Streams and structured output (#1113, #1111, #1110). OpenAI and Azure response.incomplete events now keep usage and the finish reason and block tool execution. Gemini output with unescaped quotes in object keys decodes again. OpenRouter replays reasoning_details in tool loops, so reasoning models keep their train of thought.
Upgrade notes#
composer update laravel/ai
composer show laravel/ai | grep versions # versions : * v1.2.1- Nothing breaks. Stop middleware is opt-in, and agents without it behave exactly as in 1.2.0.
- Match on
cancelled. If your front end switches on the stream’s finish reason, add acancelledbranch (orotherwith the Vercel protocol). - Ollama users: check
config/ai.php. If you never set embedding dimensions, new vectors come back at the model’s native size. - Replace fake-response stops. Middleware that returned a hand-built
StepResponseto end a run can switch toStepResult::stop()orStop::when(). - Coming from 1.0? Read our 1.1 and 1.2 post first: default models changed there.
Try it yourself#
Stop an agent in one line
use App\Ai\Agents\ResearchAgent;
use App\Models\Chat;
use App\Models\User;
use Illuminate\Support\Facades\Cache;
uses(Illuminate\Foundation\Testing\RefreshDatabase::class);
test('a raised stop flag skips the model call', function () {
$chat = Chat::create(['user_id' => User::factory()->create()->id]);
Cache::put($chat->stopKey(), true, 600);
ResearchAgent::fake(['This should never be returned']);
$response = (new ResearchAgent($chat))->prompt('How do queues work?');
expect($response->text)->toBe('')
->and($response->steps)->toBeEmpty();
});Remove the Cache::put() line and the fake answer comes back. That’s your Stop button, tested in under a second.
Recap#
Stop::when()/Stop::unless()end an agent run before the next model call. Add them inmiddleware().- Use a cache flag for Stop buttons and
$step->usagefor budgets (a soft ceiling: the crossing step still runs). - Custom middleware returns
StepResult::stop()and can track spend withthen(). - A stopped run keeps its completed steps; streams end with
reason: "cancelled". composer update laravel/aiand you also get OpenRouter Flux images, native Ollama dimensions and a smarterRetry-After.
FAQ#
How do I stop a Laravel AI SDK agent run early?
Since laravel/ai 1.2.1, add Stop::when(fn (PendingStep $step) => ...) to your agent's middleware() array, or return StepResult::stop() from a custom middleware. The run ends before the next model call.
Does Stop middleware interrupt a response that is already streaming?
No. It is checked before each step, so the current model call finishes first. Abort the request on the client too if you want the text to stop immediately.
What finish reason does a stopped stream have?
The stream_end event has reason: "cancelled" (the new FinishReason::Cancelled). With the Vercel data protocol the finish part reports other.
Do I lose the tool results when I stop a run?
No. Completed steps, tool results and usage stay on the response, and RemembersConversations stores the turn up to its last tool result. Only text is empty.
Is laravel/ai 1.2.1 a breaking upgrade?
No. It is a patch release: Stop middleware is opt-in. The one behaviour change to check is Ollama embeddings, which now use the model's native dimensions when none are configured.
Next steps#
- Laravel AI SDK 1.1 and 1.2: Agent Skills, tool approval, Cohere
- Approve an AI SDK tool before it runs
- Fake an AI agent in a Pest test
- Laravel AI SDK docs: Middleware
Sources#
- laravel/ai v1.2.1 release notes and full changelog v1.2.0…v1.2.1
- PR #1072: Add Stop middleware to end agent runs early (
Stop.php,StepResult.php) - PR #1116: OpenRouter images endpoint and PR #1115: Ollama native embedding dimensions
- PR #1112: Retry-After before failover, PR #1113: Responses API stream handling, PR #1111: structured output decoding, PR #1110: OpenRouter reasoning replay, PR #1109: CopyFile guard
- Laravel docs: Agent middleware, Streaming
- Inertia docs: HTTP requests with useHttp



