Laravel AI SDK 1.4: Agents Recover From Tool Failures
Table of contents
Until last week, a Laravel AI agent that hit a broken tool just stopped. A failing API call, a refund that couldn't be processed, a lookup that threw an exception — any of it ended the run and fired AgentFailed, leaving the conversation dead and the user staring at an error. Laravel AI SDK 1.4.0, released on GitHub on November 10, 2026, changes that: agents can now recover from tool failures instead of crashing the whole turn. Here is what changed, how it behaves in practice, and what else shipped in the same release.
What changed: tool exceptions no longer kill the run
Before 1.4, an exception thrown inside a tool's handle() method propagated straight up and ended the agent run. Now the SDK catches it, reports it through your normal exception handler, and sends the error back to the model as a failed tool result, formatted along the lines of The tool call failed: .... The model sees the failure like any other tool output and decides what to do next: apologise, explain the problem, or try a different tool.
Picture a RefundOrder tool that throws because the order is already refunded:
class RefundOrder
{
public function handle(Order $order): string
{
if ($order->refunded_at !== null) {
throw new RuntimeException('This order was already refunded.');
}
// ... process the refund ...
return "Refunded order #{$order->id}.";
}
}
Under 1.3, that exception ended the conversation with an AgentFailed event and nothing useful to show the customer. Under 1.4, the model receives the failure as the tool's result and can say something like "that order was already refunded, is there anything else I can help with?" — a materially better experience for anything built on the SDK's agent and tool system.
Events still fire, and you can opt back out
Nothing about observability is lost in the change:
- The exception is still passed to
report(), so it lands in your logs and error tracker exactly as before. ToolFailedstill fires, so existing listeners keep working.- Streamed runs emit a
ToolResultevent withsuccessfulset tofalse, so a frontend can render the failure distinctly from a normal result. - If an event listener throws (rather than the tool itself), the run still fails — only tool-level exceptions get the new recovery behaviour.
If you relied on a tool exception ending the run — for a payment tool where you never want the model improvising after a failure, say — you can restore the old behaviour by rethrowing from a ToolFailed listener:
Event::listen(function (ToolFailed $event) {
if ($event->tool instanceof ChargeCard) {
throw $event->exception;
}
});
Agent fakes now run approved tools properly
The release also fixes a gap in Agent::fake() for human-in-the-loop approval flows. Previously, resuming a paused tool approval under a fake skipped the tool entirely. Now fakes run approved tools and store their results, and return a proper denied result for rejected calls:
SupportAgent::fake([
new ToolCall('call-1', 'SendEmail', ['to' => 'john@example.com']),
'The email is on its way.',
]);
$paused = (new SupportAgent)->forUser($user)->prompt('Email John the release notes');
$response = (new SupportAgent)
->continue($paused->conversationId, as: $user)
->prompt(Decisions::from(['call-1' => Decision::approve()]));
One behaviour change to note in tests: rejecting an approval without a reason now ends the turn, where it previously didn't. If your test suite rejects approvals, pass a reason explicitly: Decision::reject('Not now.').
Other changes worth knowing
- Raw HTTP responses everywhere. Classification, embeddings, transcription, image and reranking responses now expose a
rawproperty, matching what text-generation responses already offered.$response->raw->json('usage.cost')reads provider-specific fields such as OpenRouter's cost, or$response->raw->header('X-RateLimit-Remaining-Requests')for rate-limit headers. - Proper billing errors for OpenAI. A 429 response carrying a billing code such as
insufficient_quotanow throws a dedicatedInsufficientCreditsExceptioninstead of the genericRateLimitedException, and it is not retried — retrying a request you can't pay for just burns time. Other 429s still throwRateLimitedExceptionas before. - Stream errors use a fixed event name. Provider errors on a streamed run now broadcast under the name
error, with the provider's own error code in the payload'stype. If your frontend listens for provider-specific event names, switch it to listen forerror. - Title generation is now a tracked run. Automatic conversation title generation fires agent events and is linked back to the triggering turn through
parentInvocationId, so it shows up in your observability tooling instead of happening invisibly. - Bug fixes: Anthropic tool-call handling around
pause_turncontinuations, OpenAI batch embedding order, OpenAI-compatible tool calls without an index, custom voice formatting, and updated Mistral model identifiers.
Upgrading to 1.4
The update is a normal Composer bump:
composer update laravel/ai
Before you ship it, check three things against the full diff between 1.3.0 and 1.4.0:
- Any tool where you depend on an exception ending the run (payments, destructive actions) — add a
ToolFailedlistener that rethrows for that tool specifically. - Tests that reject an approval and expect the old no-op behaviour — they need an explicit rejection reason now.
- Code catching
RateLimitedExceptionaround OpenAI calls to handle billing problems — switch that branch toInsufficientCreditsException.
Conclusion
Letting a model see its own tool failures and talk through them is a small API change with a big effect on how an agent feels to use: instead of a hard stop, the user gets an explanation and a next step. Combined with working fakes for approval flows and better billing-error handling on OpenAI, Laravel AI SDK 1.4 is a solid, low-risk upgrade for anything you've already built on the SDK — just audit the three upgrade notes above before you deploy.