News

Agent Run Observability in Laravel AI SDK 0.11

Published
Agent Run Observability in Laravel AI SDK 0.11 image

Laravel AI v0.11.0 gives an agent run a single correlation ID and a set of lifecycle events that fire around every provider round-trip and every tool call. Until now, a run that took five round-trips to resolve its tool calls looked identical to one that took a single call, and a run that died in the gateway reported nothing. The release landed on August 19, 2026 with 36 merged pull requests and twelve first-time contributors.

  • One invocation id threaded through an entire run, including failover attempts
  • StartingStep, StepCompleted, StepFailed, ToolFailed, and AgentFailed events with wall timings
  • Hosted tool search on OpenAI and Anthropic through a ToolSearch wrapper
  • Failover now triggers on connection failures, more upstream error codes, and Anthropic usage caps
  • Web search and file search for xAI, transcription for Groq and OpenAI-compatible providers
  • Stream errors throw instead of quietly ending the run
  • An assertPromptedTimes() test assertion

What's New

Tracing a Full Agent Run

Seven pull requests from @pushpak1300 rework how a run reports on itself.

streamPrompt() already minted a run-level invocation id, but prompt() did not, so synchronous middleware saw $prompt->invocationId === null. In contrast, streaming middleware saw a real value, and a three-provider failover produced three unrelated IDs for one run. prompt() now mints the ID up front, and the provider reuses whatever the caller supplied (#871).

A RunContext now carries the run's identity and dispatches its events directly, replacing a pair of callbacks that each provider registered on the generation loop. The old arrangement tracked the current tool invocation ID in a single mutable property, which broke under nesting: an agent invoked as a tool overwrote the ID before the outer ToolInvoked fired, so the outer event carried the inner call's ID. The id is now minted inside executeTool(), and tools can read it with Request::toolInvocationId() (#872).

StartingStep, StepCompleted, and StepFailed fire around every provider round-trip on both the synchronous and the streaming path (#873). StartingStep carries the messages and resolved options the step is sent with, StepCompleted carries the whole step response, and each end event carries the step's wall time in milliseconds to match QueryExecuted::$time. StartingStep also carries the run's entire message history, so a ShouldQueue listener serialises it along with any attachments.

executeTool() previously had no catch, so a tool whose handler threw propagated straight out of the generation loop with no record of which tool caused it. ToolFailed now reports that, carrying the same tool invocation ID as the InvokingTool that opened it, and the exception is still rethrown (#874). At the run level, AgentFailed reports a terminal failure once per run, and only once the run is over, which means after failover has exhausted the provider chain (#876).

A tool call now publishes its run and tool invocation IDs for its own duration, and any agent prompted while it runs picks them up as parentInvocationId and parentToolInvocationId (#875). That links a sub-agent back to the run that delegated to it, and it covers a hand-written tool that prompts an agent, not just AgentTool. The link does not cross a queue boundary, so a prompt dispatched with promptOnQueue() from inside a tool still starts its own unparented run.

Hosted Tool Search

Every tool an agent exposes was previously shipped to the provider on every request. With a large catalogue, that costs tokens and gives the model a long menu to choose from. @behzadsp added a ToolSearch wrapper in #697 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: no interface to implement, no provider options. The wrapper maps to {"type":"tool_search"} on OpenAI and to the tool_search_tool_* types on Anthropic, and Anthropic's search strategy is a constructor argument validated against regex and bm25:

new ToolSearch(tools: [new SearchInvoices], strategy: 'bm25')

Providers that don't support hosted search throw a clear exception before the request is sent, rather than dropping the wrapper's tools, and the check runs even when the wrapper is empty so misconfigurations surface in development. Only one wrapper may be registered per request, and OpenAI hosted search requires stored responses, so a ToolSearch used with store=false throws. The same pull request adds stateless output replay for OpenAI, capturing the full ordered response output and replaying it verbatim on continuation when store=false.

More Failures Now Trigger Failover

Failover now triggers in three cases where it did not before. Each one came from a failure someone hit in production.

When a provider was unreachable because the host was down, the connection was refused, or a local Ollama instance was not running, Laravel's HTTP client threw a ConnectionException. That is a separate branch of the client exception hierarchy from RequestException, so it slipped past the failover handler entirely. It is now rethrown as a ProviderConnectionException implementing FailoverableException, in the shared trait every gateway uses (@JVillator0, #781).

The set of statuses treated as an overloaded provider grew from 503 alone to 502, 503, 504, 520, 522, and 524, adding gateway timeouts and the Cloudflare errors providers behind Cloudflare return during upstream incidents. A bare 500 is deliberately left out, since it can be a deterministic error that failover would mask (@sulimanbenhalim, #810). Anthropic gained the same treatment for its transient gateway and Cloudflare errors (#884).

Anthropic also rejects requests with an HTTP 400 when an organisation hits its configured spend cap, and the message wording didn't match any existing credit patterns. Hence, the raw RequestException never reached the failover loop. @oddvalue reported roughly 990 unhandled exceptions in a month from exactly that, with a second Anthropic key and an OpenAI provider sitting idle in the same chain. Adding usage limit to the pattern list fixes it (#864).

Provider Support

  • xAI gained web search (#857) and file search (@timmcleod, #894)
  • Transcription arrived for the OpenAI-compatible provider (#858) and for Groq (#868)
  • OpenRouter supports the web fetch server tool (@CamilleScholtz, #889)
  • Anthropic web fetch citations now appear on $response->meta->citations, which previously came back empty because only web_search_tool_result blocks were read. Non-streaming responses only (#892)
  • Gemini's default text model moved to gemini-3.7-flash (#887)

Testing Helpers

assertPromptedTimes() joins the fake assertions on the Promptable trait, working the way Bus::assertDispatchedTimes() and Notification::assertSentTimes() do (@F1nnG, #891):

SalesCoach::assertPromptedTimes(3);

Faked queued generation of transcriptions, images, audio, and embeddings now runs the then(...) callback, so a test can assert on what the callback did instead of stopping at the dispatch (@gdebrauwer, #797).

Other Fixes

  • Streamed usage is summed across tool-call steps in every provider gateway (#698), the OpenAI gateway no longer drops cache write tokens from usage (#846), and DeepSeek cache-hit tokens are excluded from the reported prompt token count (#896)
  • Partially-orphaned tool calls are filtered when replaying conversation history (#758), and unknown local tool calls are repaired rather than failing the run (#859)
  • Mistral responses that return content as a list of blocks are handled (#866), Anthropic refusal and model_context_window_exceeded stop reasons are mapped (#881), markdown code fences are stripped from OpenAI-compatible structured output (#879), and text part state resets after TextEnd in Responses API streams (#854)
  • S3 document attachments are reconstructed in File::fromArray (#878), text document sources are sent as text/plain on Anthropic (#886), and the MCP request stays bound while generator results are converted (#888)

Upgrade Notes

A provider error reported inside a stream body, an HTTP 200 whose SSE payload carries an error object, now throws a StreamErrorException instead of ending the step with a break. Consumers previously got a partial ->text, no finish reason, no terminal StreamEnd, and no signal that the run had failed. The exception carries the provider's own error event on ->error, and it is deliberately not failoverable, since these arrive as a 200 (#870).

Two events gained required constructor arguments: AgentFailedOver takes a string $invocationId as its last argument (#871), and ToolInvoked takes a float $time (#874). Listeners are unaffected; only code constructing these events by hand needs updating.

AgentFailedOver also no longer fires for the final provider in a chain. That attempt has nothing to fall back to, so it reports the run's failure through AgentFailed instead (#876).

Applications using Gemini's default text model will move to gemini-3.7-flash (#887). Set the model explicitly if you need the old one.

Upgrade with composer update laravel/ai, or install the package for the first time with composer require laravel/ai and follow the official documentation for configuration. For background on the package, see our coverage of the AI SDK's announcement and of human-in-the-loop tool approval. The source lives at laravel/ai on GitHub.

References

Yannick Lyn Fatt photo

Staff Writer at Laravel News and Full stack web developer.

Sponsored

laravelcloud logo
Laravel Cloud

Easily create and manage your servers and deploy your Laravel applications in seconds.

Visit Laravel Cloud

The latest

View all →
Debounced Queued Event Listeners in Laravel image

Debounced Queued Event Listeners in Laravel

Read article
Statamic Mailables Viewer Previews Laravel Emails in the Control Panel image

Statamic Mailables Viewer Previews Laravel Emails in the Control Panel

Read article
Laravel Tackle: Run an AI Coding Agent in Your Laravel App image

Laravel Tackle: Run an AI Coding Agent in Your Laravel App

Read article
Queue::forward(): Reroute Laravel Queues in One Place image

Queue::forward(): Reroute Laravel Queues in One Place

Read article
Laravel Read-Through Filesystem: Lazy Storage Migration image

Laravel Read-Through Filesystem: Lazy Storage Migration

Read article
Read-Through Disks and Debounced Listeners in Laravel 13.26 image

Read-Through Disks and Debounced Listeners in Laravel 13.26

Read article