Skip to content
LaravelBlog

laravel/ai: Stop middleware: end agent runs early

LaravelBlogBot
LaravelBlogBot

Contributed by pushpak1300

Part of the ai v1.2.1 release

Agent runs in laravel/ai are loops: the model is called, any tool calls are executed, and their results are fed back until the model produces a final answer. Middleware could inspect and reshape every step, but it had no clean way to end that loop — the only workaround was to return a fake step, which muddied the run's history. Pull request #1072 fixes this with a first-class stop mechanism. Middleware can now end a run before the next model call, and the finished response still contains every step that completed, along with their usage. Streaming runs stop with a cancelled finish reason.

What changed

The pull request adds three pieces:

  • Laravel\Ai\Middleware\Stop — a ready-made middleware with Stop::when() and Stop::unless() constructors that end the run when a condition holds or fails.
  • StepResult::stop() — the underlying gateway primitive. Custom middleware can return it from handle() instead of passing the step down the pipeline, and the generation loop now checks StepResult::stopped() between steps.
  • FinishReason::Cancelled — a new enum case ('cancelled') used as the final reason when a stream is stopped mid-run.

Whenever a step result reports stopped, the loop breaks out before the next model call, so the aborted step costs no tokens and produces no events.

Stopping a run with Stop::when() and Stop::unless()

Stop accepts either a plain boolean or a closure that receives the PendingStep and returns a boolean. Stop::when($condition) stops before any step where the condition is true; Stop::unless($condition) stops before any step where it is false. Because middleware runs before each model call, the condition is re-evaluated on every turn of the loop, which makes it a natural fit for a stop button or a token budget:

use Laravel\Ai\Middleware\Stop;

public function middleware(): array
{
    return [
        // A "stop" button sets this cache key; the next step never runs.
        Stop::when(fn (PendingStep $step) => Cache::has("chat:{$this->chat->id}:stopped")),

        // Halt the agent once input tokens reach 50,000.
        Stop::unless(fn (PendingStep $step) => $step->usage->inputTokens < 50_000),
    ];
}

The first condition may never fire; the second only fires once the budget is exhausted. Both are checked before each model call, never mid-step.

Stopping from custom middleware

If you already write your own middleware, you don't need the Stop class. Returning StepResult::stop() from handle() ends the run in exactly the same way:

use Laravel\Ai\Gateway\StepResult;

public function handle(PendingStep $step, Closure $next)
{
    return $this->cancelled($step) ? StepResult::stop() : $next($step);
}

This replaces the old "return a fake step" workaround. If you had one of those in your codebase, it's worth deleting during the upgrade.

What a stopped run returns

The behaviour depends on where the stop lands:

  • Stopped before the first step: the run returns an empty response — text is '', there are no steps, usage is zeroed, and the meta still identifies the provider and model. Structured agents return a plain AgentResponse rather than structured data in this case.
  • Stopped after one or more steps: the normal final response is built from the completed steps, so $response->steps and $response->toolResults contain everything that actually ran, with their usage intact.
  • Streaming: the stream ends with a StreamEnd event whose reason is cancelled, and the completed steps remain available in that event.

Conversation stores are updated too: a stopped turn is remembered up to its last tool result, so tool calls stay paired with their results in history, while a turn stopped before its first step leaves no assistant message behind.

Upgrade notes for v1.2.0 users

Nothing breaks. Stop is purely additive, and existing middleware keep working untouched. Two things are worth checking before you deploy:

  • If you match on FinishReason exhaustively (without a default arm), the new Cancelled case will throw an UnhandledMatchError at runtime. Add an arm for it.
  • If you forward stream finish reasons to a frontend, 'cancelled' is now a possible value for StreamEnd::$reason — make sure clients handle it.

Takeaways

  • Stop::when() and Stop::unless() end agent runs before the next model call; custom middleware can return StepResult::stop() directly.
  • Completed steps and their usage are preserved; a run stopped before its first step returns an empty response with no steps.
  • Stopped streams finish with a cancelled finish reason.
  • Handle the new FinishReason::Cancelled case in exhaustive match statements when upgrading from v1.2.0.

Sources

More from this release

Related Articles