Laravel AI SDK 1.2.1: Stop middleware for agent runs

Laravel AI SDK 1.2.1 adds Stop::when, Stop::unless and StepResult::stop() to end agent runs early. Tested stop button, token budgets and a Vue 3 + Inertia 3 UI.

For your AI agent

Copies a prompt. Paste it into the AI agent you already have open.

Read https://laravel-vuejs.space/laravel-ai-sdk-1-2-1-stop-middleware.md
Laravel AI SDK 1.2.1 adds Stop::when, Stop::unless and StepResult::stop() to end agent runs early. Tested stop button, token budgets and a Vue 3 + Inertia 3 UI.
Show the diff. Do not commit.
On this page · 13 sections
  1. What’s in 1.2.1
  2. Why agents needed a Stop
  3. Stop::when and Stop::unless
  4. What the PendingStep tells you
  5. A custom middleware with StepResult::stop()
  6. What a stopped run returns
  7. Wire a Stop button with Vue and Inertia
  8. The other 1.2.1 fixes
  9. Upgrade notes
  10. Try it yourself
  11. Recap
  12. FAQ
  13. Next steps
  14. Sources

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\Stop with Stop::when() and Stop::unless(), plus StepResult::stop() for your own middleware.
  • The run ends before the next model call. Completed steps, tool results and usage are kept; text is ''.
  • A stopped stream ends with stream_end and reason: "cancelled" (a new FinishReason::Cancelled). The Vercel protocol reports it as other.
  • Also in 1.2.1: OpenRouter image models like Flux work, Ollama embeddings keep their native size, and a short Retry-After is honoured before failover. Upgrade with composer update laravel/ai.

What you'll learn

  • Stop::when and Stop::unless for stop buttons and token budgets
  • Custom middleware that returns StepResult::stop()
  • What a stopped prompt() and stream() return (hint: cancelled, not stop)
  • A Vue 3 + Inertia 3 Stop button, plus the other 1.2.1 fixes

Who it's for: Laravel 13 developers running tool-calling agents with laravel/ai, Vue and Inertia

Tested with: Laravel 13.35 · laravel/ai 1.2.1 · inertia-laravel 3.5.1 · @inertiajs/vue3 3.9.1 · Vue 3.5 · PHP 8.4

What’s in 1.2.1#

ChangePRWhy you care
Stop middleware (Stop::when, Stop::unless, StepResult::stop())#1072Stop buttons and token budgets without fake responses
OpenRouter image generation uses /images#1116Image-only models (Flux) stop failing with a 404
Ollama keeps native embedding dimensions#1115No more silent truncation to 768
OpenRouter reasoning replayed in tool loops#1110Reasoning models stop “starting over” every step
One retry after a short Retry-After#1112Brief 429s don’t fail over or fail the prompt
Terminal Responses API stream events handled#1113response.incomplete keeps usage and finish reason
Structured output with quotes in keys decoded#1111$response->structured no longer comes back []
CopyFile refuses directories#1109Same 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:

app/Ai/Middleware/StopWhenCancelled.phpphp
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),
    );
}
app/Ai/Middleware/StopWhenCancelled.phpphp
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:

app/Ai/Agents/ResearchAgent.phpphp
<?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:

app/Models/Chat.phpphp
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:

PropertyWhat it holds
$step->number / isFirstStep()Step index, 0 for the first call
$step->stepsThe steps completed so far in this run
$step->usageUsage accumulated by completed steps (inputTokens, outputTokens, …)
$step->tools, $step->messages, $step->modelWhat’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.

Before every step, the agent runs its middleware. If the Stop middleware's condition says keep going, the model is called; if it returns tool calls, the tools run and the loop goes back to the next step, otherwise the run is done with finish reason stop. If the condition says stop, the run ends immediately: completed steps are kept and a stream ends with reason cancelled.
Stop is checked before each model call, never in the middle of one.

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:

app/Ai/Middleware/EnforceDailyTokenBudget.phpphp
<?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() returnsstream() ends with
Before the first steptext: '', steps emptystream_end, reason: "cancelled"
After a tool steptext: '', completed steps and toolResults kept (last step’s finish reason tool_calls)stream_end, reason: "cancelled"
Not stoppedThe model’s answerstream_end, reason: "stop"

This is the tail of the SSE response when the user hit Stop during the first tool call:

text
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 AgentResponse with empty text, not a structured one. Guard your $response['field'] access. Update: since laravel/ai 1.3 it returns a StructuredAgentResponse with structured: [] 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.

routes/web.phpphp
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');
});
app/Http/Controllers/ChatController.phpphp
<?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:

resources/js/components/AgentChat.vuevue
<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#

bash
composer update laravel/ai
composer show laravel/ai | grep versions   # versions : * v1.2.1
  1. Nothing breaks. Stop middleware is opt-in, and agents without it behave exactly as in 1.2.0.
  2. Match on cancelled. If your front end switches on the stream’s finish reason, add a cancelled branch (or other with the Vercel protocol).
  3. Ollama users: check config/ai.php. If you never set embedding dimensions, new vectors come back at the model’s native size.
  4. Replace fake-response stops. Middleware that returned a hand-built StepResponse to end a run can switch to StepResult::stop() or Stop::when().
  5. 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

tests/Feature/StopTest.phpphp
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 in middleware().
  • Use a cache flag for Stop buttons and $step->usage for budgets (a soft ceiling: the crossing step still runs).
  • Custom middleware returns StepResult::stop() and can track spend with then().
  • A stopped run keeps its completed steps; streams end with reason: "cancelled".
  • composer update laravel/ai and you also get OpenRouter Flux images, native Ollama dimensions and a smarter Retry-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#

Sources#

Found this useful?

Ask questions, share what you built.

Join thousands of Laravel and Vue developers in our Facebook group, or get new posts in your Telegram feed. Sharing this article helps too.

Keep reading

Newsletter

Get the best of Laravel & Vue, once a month.

One short email with new guides, release notes worth reading and hand-picked community links. No spam, unsubscribe any time.