Laravel AI SDK v1.4.0, covered by Laravel News on 13 November 2026, changes what happens when a tool throws an exception inside an agent run. Before 1.4, one failing tool ended the whole run. Now the exception message goes back to the model as a failed tool result, and the agent can explain the problem or try another approach. This tutorial builds a small support agent with a tool that can fail, shows what the model sees, explains how to keep the old behaviour if you need it, and covers the other upgrade notes in the release.
What changed in 1.4
Here is the release summary:
- A tool that throws no longer ends the agent run.
- Agent fakes now run approved tools and store their results, so you can test approval flows end to end.
- A
rawproperty (the provider’s HTTP response) on classification, embeddings, transcription, image and reranking responses. - OpenAI quota and credit errors throw
InsufficientCreditsExceptioninstead ofRateLimitedException. - Provider stream errors are broadcast under a fixed
errorevent name. - Conversation title generation now fires the usual agent events.
- Fixes for Anthropic and OpenAI-compatible tool calls.
Upgrade with:
composer update laravel/ai
A quick refresher: agents and tools
The Laravel AI SDK docs put tools in app/Ai/Tools. You generate one with Artisan:
php artisan make:tool RefundOrder
A tool has a description, a JSON schema for its arguments and a handle method. Here is a refund tool that refuses old orders by throwing an exception:
<?php
namespace App\Ai\Tools;
use App\Models\Order;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Ai\Contracts\Tool;
use Laravel\Ai\Tools\Request;
use RuntimeException;
use Stringable;
class RefundOrder implements Tool
{
public function description(): Stringable|string
{
return 'Refund a customer order by its order number.';
}
public function handle(Request $request): Stringable|string
{
$order = Order::where('number', $request['order_number'])->firstOrFail();
if ($order->created_at->lt(now()->subDays(30))) {
throw new RuntimeException(
"Order {$order->number} can no longer be refunded."
);
}
$order->refund();
return "Order {$order->number} has been refunded.";
}
public function schema(JsonSchema $schema): array
{
return [
'order_number' => $schema->string()->required(),
];
}
}
Then return the tool from an agent that implements HasTools:
<?php
namespace App\Ai\Agents;
use App\Ai\Tools\RefundOrder;
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Contracts\HasTools;
use Laravel\Ai\Promptable;
use Stringable;
class SupportAgent implements Agent, HasTools
{
use Promptable;
public function instructions(): Stringable|string
{
return 'You are a polite support agent. If a tool fails, explain why '
. 'in plain language and suggest what the customer can do next.';
}
public function tools(): iterable
{
return [new RefundOrder];
}
}
What happens now when the tool throws
Say a customer asks for a refund on an order from three months ago:
$response = (new SupportAgent)->prompt('Please refund order 1234.');
echo $response->text;
In 1.3 and earlier, the RuntimeException ended the run and fired AgentFailed. Your controller got an exception, and you had to build the error message yourself.
In 1.4, the SDK catches the exception and sends the message back to the model as a failed tool result:
The tool call failed: Order 1234 can no longer be refunded.
The model can now reply with something like “I’m sorry, order 1234 is outside the 30-day refund window. I can help you request store credit instead.” It can also call another tool if you’ve given it one.
A few details from the release notes:
- The exception still reaches your exception handler through
report(), so it still shows up in your error tracker. - The
ToolFailedevent still fires. - In a streamed run, the failure is emitted as a
ToolResultevent withsuccessfulset tofalse. - Only the tool’s own exception is recovered. An exception thrown by an event listener still fails the run.
Write exception messages for the model
Your exception message is now part of the conversation, so treat it as user-facing text:
- Say what went wrong in one clear sentence (“Order 1234 can no longer be refunded”).
- Don’t put secrets, SQL, stack traces or internal IDs in the message. The model may repeat it to the user.
- Throw a dedicated exception class for expected business failures so your error tracker can group or filter them.
Keeping the old “fail the run” behaviour
Some flows should stop as soon as a tool fails, for example a payment tool where a silent retry could be dangerous. The upgrade notes give a simple pattern: rethrow from a ToolFailed listener, because a listener exception still ends the run.
use Illuminate\Support\Facades\Event;
use Laravel\Ai\Events\ToolFailed;
Event::listen(ToolFailed::class, fn (ToolFailed $event) => throw $event->exception);
Put this in a service provider’s boot() method. If you only want strict behaviour for some tools, check the exception type inside the listener and rethrow only the ones that matter.
Testing approval flows with fakes
The SDK supports human-in-the-loop approval, where a run pauses until someone approves or rejects a tool call. Before 1.4, resuming a paused run under Agent::fake() skipped the tool entirely. Now an approved tool runs and its result is stored, and a rejected tool gets a denied result. This example is from the release coverage (imports omitted for brevity):
SupportAgent::fake([
new ToolCall('call-1', 'SendEmail', ['to' => '[email protected]']),
'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()]));
$response->toolResults[0]->result; // "Email sent to [email protected]."
$response->text; // "The email is on its way."
Note one upgrade gotcha: a rejection without a reason now ends the turn, as it does with a real provider. If your test expects a reply after rejecting, pass a reason, such as Decision::reject('Not now.').
Other changes worth knowing
Raw provider responses on more types
Text responses already had raw. Classification, embeddings, transcription, image and reranking responses now have it too, so you can read fields the SDK doesn’t map, such as a cost value from OpenRouter:
$cost = $response->raw->json('usage.cost');
raw is null after serialization, and on embeddings it’s also null when every vector came from the cache.
Billing errors are no longer “rate limits”
OpenAI returns HTTP 429 for both rate limits and billing problems. Version 1.4 throws InsufficientCreditsException for OpenAI’s billing codes and does not retry them. If you caught RateLimitedException to handle quota problems, catch InsufficientCreditsException as well.
Frontend stream listeners
Provider errors used to be broadcast under the provider’s own error code. They are now broadcast as error. If your JavaScript listened for codes like 403, listen for error instead.
Title generation events
Conversation title generation is now an agent run, so PromptingAgent, AgentPrompted, AgentFailed and step events also fire for it. Use parentInvocationId to tell title runs apart from user turns, for example when you log token usage.
Upgrade checklist
- Run
composer update laravel/ai. - Search for code that relied on
AgentFailedfiring when a tool throws. Add aToolFailedlistener that rethrows if you still need that. - Review tool exception messages. They are now shown to the model.
- Update
catch (RateLimitedException)blocks to also handleInsufficientCreditsException. - Update frontend stream listeners to use the
errorevent name. - Re-run tests that reject tool approvals and add reasons where needed.
Key takeaways
- Laravel AI SDK 1.4 turns tool exceptions into failed tool results, so agents can recover instead of crashing.
- Exceptions are still reported, and
ToolFailedstill fires. Rethrow from a listener to keep the old behaviour. - Agent fakes can now test full approval flows.
- Check the billing-error and stream-event changes before you deploy.
Source: Laravel AI SDK 1.4 Lets Agents Recover From Tool Failures (Laravel News) and the Laravel AI SDK documentation.
