Skip to content

Laravel AI SDK 0.11: I Can Finally See What the Agent Did

Laravel AI SDK 0.11: I Can Finally See What the Agent Did

A client asked me last month why one particular agent request took eleven seconds when the same thing usually takes two. I opened the logs with real confidence, because I had instrumented this thing myself, and found exactly one line saying a prompt went out and one line saying a response came back. That’s it. Eleven seconds of nothing in between.

I knew the agent had made tool calls. I couldn’t tell you how many, which ones, how long each took, or whether the provider had quietly failed over to a backup halfway through. I ended up adding my own correlation ID by hand, which worked, and which I resented the entire time I was writing it.

Laravel AI SDK 0.11 landed on 19 August and deletes most of that work. It’s a release about being able to see what your agent did. Thirty-six merged pull requests, twelve first-time contributors, and the honest summary is that agent runs were basically opaque before this and now they aren’t.

What observability meant before this, which is: nothing

Here’s roughly what I had, and I suspect it’s close to what you have:

Event::listen(InvokingTool::class, function (InvokingTool $event) {
    Log::info('tool call', ['tool' => $event->tool->name()]);
});

Fine as far as it goes. Now try to answer any of these from the resulting log file. Did this tool call belong to the same run as that one? How long did the model round-trip take, as opposed to the tool execution? Did the tool throw? Did the whole run die, and if so, where?

You can’t. There’s no run identifier to group by, no timing, and crucially no failure event at all. A tool that threw an exception propagated straight out of the generation loop with nothing recorded about which tool caused it. A run that died in the gateway reported nothing whatsoever.

The gap that annoyed me most was subtler. streamPrompt() already minted a run-level invocation ID, but prompt() didn’t, so synchronous middleware saw $prompt->invocationId === null while streaming middleware saw a real value. Same library, two behaviours, depending on a choice you made for unrelated reasons. And if you had a three-provider failover chain, one logical run produced three unrelated IDs.

One ID for the whole run, which is the real headline

Everything else in this release hangs off this. prompt() now mints the invocation ID up front, the provider reuses whatever the caller supplied, and that single ID survives failover attempts.

A RunContext now carries the run’s identity and dispatches events directly, which replaced a pair of callbacks each provider registered on the generation loop. The old arrangement kept the current tool invocation ID in one mutable property, and that broke the moment you nested anything. An agent invoked as a tool overwrote the ID before the outer ToolInvoked event fired, so the outer event reported the inner call’s ID. If you had ever tried to build a trace tree from those events and given up because the parent-child links were nonsense, that’s why.

Nested runs now link properly too. A tool call publishes its run and tool invocation IDs for its duration, and any agent prompted while that tool runs picks them up as a parent run and parent tool call. That covers a hand-written tool that prompts an agent, not just the framework’s own agent-as-tool wrapper.

One limit worth knowing before you design around it: the link doesn’t cross a queue boundary. A prompt dispatched to the queue from inside a tool starts its own unparented run. If your architecture pushes the expensive half of every agent onto a worker, and mine does, you get two disconnected traces and you’ll have to stitch them yourself.

The events, and the one I’d be careful with

StartingStep, StepCompleted, and StepFailed now fire around every provider round-trip, on both the synchronous and the streaming path. StartingStep carries the messages and resolved options the step is sent with. StepCompleted carries the whole step response. Each end event carries the step’s wall time in milliseconds, deliberately matching the shape of QueryExecuted::$time, which is a nice touch. If you have written a listener for slow database queries, you already know how to write this one:

Event::listen(StepCompleted::class, function (StepCompleted $event) {
    if ($event->time > 3000) {
        Log::warning('slow agent step', [
            'run' => $event->invocationId,
            'ms'  => $event->time,
        ]);
    }
});

I put together a similar listener for query timing when I was chasing the N+1 problem in a Laravel app, and the mental model transfers directly. Steps are just a slower, more expensive kind of query.

Now the caution. StartingStep also carries the run’s entire message history. That’s the right call for tracing, because you want to know what the model was actually looking at. But if you make that listener a ShouldQueue, Laravel serialises the whole payload onto your queue, message history and attachments included. A long conversation with a couple of PDFs attached becomes a genuinely large queue job, dispatched once per provider round-trip, on every run. That’s not a bug, it’s the documented behaviour, and I’d still rather know than not. Just don’t wire it up to a queued listener on a chatty agent and then wonder why Redis memory is climbing.

ToolFailed finally reports a tool that threw, carrying the same tool invocation ID as the InvokingTool that opened it, and the exception is still rethrown afterwards. AgentFailed reports terminal failure once per run, and only after failover has exhausted the whole provider chain. That once-per-run guarantee matters more than it sounds like it does. It means you can alert on AgentFailed without writing dedupe logic.

The failover list reads like a log of other people’s outages

This is my favourite part of the release notes, and it’s the part nobody will quote.

Failover previously triggered on a RequestException, which sounds sufficient until you notice that a provider being unreachable throws a ConnectionException instead. Different branch of the exception hierarchy. So a dead host, a refused connection, or a local Ollama instance that wasn’t running slipped past the failover handler entirely. That’s now rethrown as a failoverable provider connection exception.

The set of statuses treated as an overloaded provider grew from a lone 503 to 502, 503, 504, 520, 522, and 524. Those last three are Cloudflare’s, which tells you exactly how this list was assembled: somebody’s provider sat behind Cloudflare during an incident and their failover chain didn’t fire. A bare 500 is deliberately left out, on the reasoning that it can be a deterministic error, and failing over would just mask it. I think that’s the right call and I’d have argued myself into the wrong one.

Then the detail that made me wince in sympathy. Anthropic rejects requests with an HTTP 400 when an organisation hits its spend cap, and the wording didn’t match any existing credit-related pattern, so the exception never reached the failover loop. One contributor reported roughly 990 unhandled exceptions in a single month from exactly that, with a second Anthropic key and an OpenAI provider sitting idle in the same chain the whole time. Nine hundred and ninety failed requests with a working fallback one line away. The fix was adding “usage limit” to a pattern list.

If you run failover in production and you have never tested it by actually revoking a key, that story is for you.

Hosted tool search, and what it costs you

Every tool an agent exposes was previously shipped to the provider on every single request. With a large catalogue that’s tokens you pay for on every round-trip, plus a long menu for the model to pick from, which doesn’t help accuracy either.

There’s now a wrapper that defers the tools inside it so OpenAI and Anthropic load them on demand through their own hosted search:

public function tools(): iterable
{
    return [
        new WeatherTool,
        new ToolSearch(tools: [new SearchInvoices, new RefundOrder]),
    ];
}

The tools themselves need no changes, which is the part I like. No interface to implement, no provider-specific options bolted onto each class. Anthropic’s search strategy is a constructor argument validated against regex and bm25.

The constraints are real though. Only one wrapper per request. Providers that don’t support hosted search throw a clear exception before the request goes out rather than silently dropping those tools, and the check runs even when the wrapper is empty so a misconfiguration surfaces in development. OpenAI’s hosted search requires stored responses, so using it with store=false throws.

That last one is a genuine tradeoff, not a footnote. Plenty of teams set store=false on purpose because they’d rather the provider didn’t retain conversation content. If that’s you, hosted tool search is off the table and trimming your tool catalogue by hand is still your job.

Two upgrade notes that will break your alerting quietly

AgentFailedOver no longer fires for the final provider in a chain. The reasoning is sound, since that attempt has nothing left to fall back to, and the run’s failure is reported through AgentFailed instead. But if your on-call alert is wired to AgentFailedOver, it now stops firing in exactly the case you care about most: total failure. Nothing errors. The alert just goes quiet, which is the worst possible failure mode for an alert.

Second, a provider error reported inside a stream body, meaning an HTTP 200 whose payload carries an error object, now throws instead of ending the step with a break. Consumers used to get partial text, no finish reason, no terminal stream-end event, and no indication the run had failed. The new exception carries the provider’s own error event, and it’s deliberately not failoverable, since these arrive as a 200. If you were catching the old silent truncation with your own heuristics, rip that out.

Two events also gained required constructor arguments, but only code that constructs them by hand is affected. Listeners are fine.

What I’d do this week

Upgrade with composer update laravel/ai, then spend twenty minutes on one thing: write a single listener that records the invocation ID, the step wall time, and the tool name for every step and tool call, and push it wherever you already send logs. Don’t build a dashboard. Just get the run ID into your log lines so you can group by it.

Then go find your failover configuration and check what your alerts listen for. If AgentFailedOver appears anywhere in your monitoring, move that alert to AgentFailed before this release makes it silent.

The full release notes and the official docs are worth a read before you upgrade, particularly if you’re on Gemini, whose default text model moved in this release. Most of the Laravel work I take on now has some model call sitting inside it somewhere, and you can see the shape of that in what I build. Being able to see what those calls did is the difference between debugging and guessing.