laravel/ai: Stop middleware: end agent runs early
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 withStop::when()andStop::unless()constructors that end the run when a condition holds or fails.StepResult::stop()— the underlying gateway primitive. Custom middleware can return it fromhandle()instead of passing the step down the pipeline, and the generation loop now checksStepResult::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 plainAgentResponserather than structured data in this case. - Stopped after one or more steps: the normal final response is built from the completed steps, so
$response->stepsand$response->toolResultscontain everything that actually ran, with their usage intact. - Streaming: the stream ends with a
StreamEndevent whose reason iscancelled, 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
matchonFinishReasonexhaustively (without adefaultarm), the newCancelledcase will throw anUnhandledMatchErrorat runtime. Add an arm for it. - If you forward stream finish reasons to a frontend,
'cancelled'is now a possible value forStreamEnd::$reason— make sure clients handle it.
Takeaways
Stop::when()andStop::unless()end agent runs before the next model call; custom middleware can returnStepResult::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
cancelledfinish reason. - Handle the new
FinishReason::Cancelledcase in exhaustivematchstatements when upgrading from v1.2.0.