Laravel AI: Trace Agent Runs With Lifecycle Events

Jamie Freeman

Laravel AI 0.11 gives every agent run one invocation ID plus step and tool events with wall timings, so you can trace, log and time a run end to end — including failures.

Share LinkedIn X Facebook Email

An agent run is rarely one API call. The model asks for tools, your app executes them, results go back, and the loop repeats until the model is done. Until Laravel AI 0.11, that loop was a black box: you got PromptingAgent at the start and AgentPrompted at the end. Five provider round-trips looked the same as one. If the run threw halfway, AgentPrompted never fired, so a listener watching the happy path never learned the job had failed.

That is a production problem, not a nice-to-have. If you are building a client portal that calls tools, summarises documents, or fans work out to sub-agents, you need one ID for the whole run, timings per step, and a failure event when the chain dies. This is a practical rewrite of the Laravel AI 0.11 lifecycle work — what shipped, why it matters on real PHP apps, and how to listen without serialising a novel onto the queue.

The change landed as a stack of pull requests from @pushpak1300, merged as #870 through #876. Coverage on Laravel News is the source for the event list; the examples below are written for people shipping Laravel, not reading release notes.

Abstract cyan and violet glass steps linked by glowing traces, representing an AI agent run timeline
One invocation ID, many steps. Trace the whole run the way you already trace SQL.

One ID for the whole run

streamPrompt() already minted a run-level invocation ID. prompt() did not. Synchronous middleware saw $prompt->invocationId === null; streaming middleware saw a real value. Worse, the provider generated its own ID per attempt, so a run that failed over across three providers produced three unrelated IDs for what the caller treats as one job.

prompt() now generates the ID up front and the provider reuses whatever the caller supplied. Every event below takes that ID as its first constructor argument, so you can group a run on one string:

public function __construct(
    public string $invocationId,
    public int $stepNumber,
    // ...
) {}

AgentFailedOver also gained the ID as a required first argument. Listeners are fine. Anything that constructs the event by hand needs updating — the same class of break as adding a required constructor argument anywhere else in Laravel.

Step events

StartingStep, StepCompleted and StepFailed fire around every provider round-trip on both the synchronous and streaming paths.

StartingStep carries the messages sent for that step (including tool results from previous steps) and the options resolved for the step. Those options can differ from the agent’s own once a forced tool choice has been satisfied. It also carries stepNumber and isFinalStep:

use Laravel\Ai\Events\StartingStep;

Event::listen(StartingStep::class, function (StartingStep $event) {
    // $event->stepNumber, $event->model, $event->isFinalStep
    // $event->messages, $event->options
});

StepCompleted carries the whole StepResponse, so a listener never has to rebuild it, plus float $time — wall time in the provider call, in milliseconds. The unit matches QueryExecuted::$time, so code that already logs query timings can treat these the same way.

use Laravel\Ai\Events\StepCompleted;

Event::listen(StepCompleted::class, function (StepCompleted $event) {
    Log::info('AI step completed', [
        'invocation'    => $event->invocationId,
        'step'          => $event->stepNumber,
        'ms'            => $event->time,
        'prompt_tokens' => $event->response->usage->promptTokens,
        'finish'        => $event->response->finishReason->value,
    ]);
});

Per-step usage used to arrive as a bulk payload on the terminal event, with no timing attached. Now cost and duration of each round-trip are reported as the run happens. StepFailed covers a step that ends without a response: the Throwable plus the time spent before it threw.

If you only need timings and token counts, listen for StepCompleted. If you open a tracing span, you probably want StartingStep — and you need to know what that does to queued listeners (see below).

Nested glass cubes representing parent and child AI tool invocations connected by luminous traces
A nested tool call is still part of the same story. Parent and child IDs now travel together.

Tool events

InvokingTool and ToolInvoked already existed, but they were wired through callbacks each provider registered on the generation loop. The current tool invocation ID lived in a single mutable property on the provider. That failed under nesting: because the manager returns one provider instance per name, an agent invoked as a tool overwrote the ID before the outer ToolInvoked fired. The outer event reported the inner call’s ID. That is the sort of bug that only shows up when you actually nest agents — which is exactly when you need the IDs to be right.

A RunContext now carries the run’s identity and dispatches these events directly. The tool invocation ID is generated inside executeTool(), so each invocation gets its own unique ID. Tools can read it from the request:

public function handle(Request $request): string
{
    $request->toolInvocationId(); // string|null
}

The new event is ToolFailed. Previously executeTool() had no catch. If a handler threw, the exception left the generation loop: no end event, and the run aborted without recording which tool caused it. ToolFailed reports that failure with the same toolInvocationId as the opening InvokingTool. The exception is still rethrown, so behaviour is otherwise unchanged. Only the handler call is guarded — a listener that throws is not misreported as a tool failure.

ToolInvoked also gained a required float $time (wall time in the handler). Manual constructors need the duration. Both events carry the Tool instance rather than its name, so pair them on toolInvocationId and derive a label from the object:

use Laravel\Ai\Events\ToolFailed;

Event::listen(ToolFailed::class, function (ToolFailed $event) {
    Log::error('AI tool failed', [
        'invocation'      => $event->invocationId,
        'tool_invocation' => $event->toolInvocationId,
        'tool'            => class_basename($event->tool),
        'arguments'       => $event->arguments,
        'ms'              => $event->time,
        'exception'       => $event->exception->getMessage(),
    ]);
});

Run failure events

Every span-closing event in the package previously sat only on the happy path. If the gateway threw, AgentPrompted was never dispatched. A listener watching the run had no way to know it had failed.

AgentFailed reports that once per run, and only once the run is over. With failover configured, the first provider that throws a FailoverableException is not terminal — the event fires after the chain is exhausted. It carries the invocation ID, the prompt, and the exception.

AgentFailedOver no longer fires for the final provider in a chain. That attempt has nothing to fall back to, so it is reported as the run’s failure instead. Previously the failover event was dispatched in every catch, including the last one. If you alerted on failover, you were also alerting on the actual death of the run. Split those: failover is “we tried the next provider”; AgentFailed is “we are done, and it did not work”.

Linking a sub-agent to its parent

An agent invoked as a tool used to look like a separate run. Nothing correlated it to its parent. A tool call now tracks its run and tool invocation IDs for its duration. Any agent prompted during that tool execution receives them as parentInvocationId and parentToolInvocationId on its prompt.

That covers a hand-written tool that prompts an agent, not just AgentTool. The IDs live in a static property rather than context, which keeps the delegating invocation out of queued job payloads.

The link does not cross a queue boundary. A prompt dispatched with promptOnQueue() from inside a tool starts its own unparented run. If you queue work from a tool, log the parent IDs yourself onto the job — the package will not do it for you.

Queued listeners and message history

StartingStep carries the run’s entire message history. A listener implementing ShouldQueue will serialise all of it, along with any attachments. That is the intended trade-off: a listener that opens a span needs the request the step was sent with. It is also an easy way to blow a Redis payload or a failed-job row.

Practical rule for production:

  • Synchronous listener on StartingStep if you need the prompt body for tracing.
  • Queued listener on StepCompleted / ToolInvoked / ToolFailed / AgentFailed if you only need IDs, timings and token counts.
  • Do not queue StartingStep unless you have already measured the payload size with real attachments.

A listener set I would actually ship

Keep it boring. One log channel, structured fields, no clever spans on day one:

use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Log;
use Laravel\Ai\Events\AgentFailed;
use Laravel\Ai\Events\StepCompleted;
use Laravel\Ai\Events\ToolFailed;

Event::listen(StepCompleted::class, function (StepCompleted $event) {
    Log::channel('ai')->info('step', [
        'invocation' => $event->invocationId,
        'step'       => $event->stepNumber,
        'ms'         => $event->time,
        'tokens'     => $event->response->usage->promptTokens,
    ]);
});

Event::listen(ToolFailed::class, function (ToolFailed $event) {
    Log::channel('ai')->error('tool_failed', [
        'invocation' => $event->invocationId,
        'tool'       => class_basename($event->tool),
        'ms'         => $event->time,
        'error'      => $event->exception->getMessage(),
    ]);
});

Event::listen(AgentFailed::class, function (AgentFailed $event) {
    Log::channel('ai')->error('agent_failed', [
        'invocation' => $event->invocationId,
        'error'      => $event->exception->getMessage(),
    ]);
});

Once that is in production for a week, you will know whether you need OpenTelemetry spans, a dashboard, or just better log alerts. Do not start with a tracing product because the events exist.

Breaking changes to watch

  • Anything constructing AgentFailedOver or ToolInvoked by hand must pass the new required arguments (invocationId / $time).
  • Listeners are unaffected as long as they type-hint the event and read public properties.
  • Tests that asserted “no AgentFailedOver on the last provider” will now match reality; tests that expected a failover event on the final catch will fail and should be updated.

Treat agent runs like database work: one ID for the transaction, timings on each statement, and a failure event when the commit never happens.

Further reading

The events shipped in Laravel AI v0.11.0, alongside hosted tool search and wider provider failover. For reading the provider’s own HTTP response during a run — rate-limit headers, request IDs — see the raw HTTP response property added in v0.10.3.

Background: the official AI SDK announcement and coverage of human-in-the-loop tool approval. Source lives at laravel/ai on GitHub.

FAQ

Which Laravel AI version added these events?
v0.11.0. Upgrade the laravel/ai package and treat the new required constructor arguments as a minor breaking change if you build events in tests.

Should I listen to StartingStep or StepCompleted?
StepCompleted for timings and tokens. StartingStep only if you need the outbound messages to open a span — and prefer a synchronous listener so you do not serialise the full history onto a queue.

Do nested agents share an invocation ID?
No. The child run has its own ID, plus parentInvocationId and parentToolInvocationId so you can join the tree. Queued prompts from inside a tool are unparented unless you copy the IDs onto the job yourself.

What happens if a tool throws?
ToolFailed fires with the same toolInvocationId as InvokingTool, then the exception is rethrown. The run still aborts; you just get an end event this time.

Does failover still fire on the last provider?
No. The last provider has nowhere to fail over to, so that attempt is AgentFailed, not AgentFailedOver.

Need a Laravel or custom PHP build that you can actually observe?

I’m Jamie Freeman — a UK web designer and developer. I build Laravel and plain-PHP tools for small businesses: portals, integrations, and the unglamorous logging that tells you why a job died at 2am.

If you are adding AI agents to a real app and want traces, failure handling and a deploy you can reverse: get a free quote →

FAQs

Which Laravel AI version added these events?

v0.11.0. Upgrade laravel/ai and treat new required constructor arguments as a minor breaking change if you construct events in tests.

Should I listen to StartingStep or StepCompleted?

Use StepCompleted for timings and token counts. Use StartingStep only if you need the outbound messages to open a tracing span, and prefer a synchronous listener so the full history is not serialised onto a queue.

Do nested agents share an invocation ID?

No. The child run has its own ID plus parentInvocationId and parentToolInvocationId so you can join the tree. Queued prompts from inside a tool are unparented unless you copy the IDs onto the job yourself.

What happens if a tool throws?

ToolFailed fires with the same toolInvocationId as InvokingTool, then the exception is rethrown. The run still aborts; you now get an end event for the failed tool.

Does failover still fire on the last provider?

No. The last provider has nowhere to fail over to, so that attempt is reported as AgentFailed rather than AgentFailedOver.

Share this guide LinkedIn X Email
Need a site built? Free call · UK studio Book a free call