An agent run is rarely a single API call. The model returns tool calls, the SDK executes them, sends the results back, and repeats until the model finishes. Until Laravel AI v0.11.0, the generation loop had no per-step events, reporting only PromptingAgent at the start and AgentPrompted at the end of the entire run. A run that took five provider round-trips looked identical to a run that took one, and a run that threw an exception midway reported nothing because AgentPrompted was never reached.
A stack of seven pull requests from @pushpak1300, merged as #870 through #876, changes that. Every run now has one ID, and every round-trip and tool call reports a start and an end with wall timings.
One ID for the Whole Run
streamPrompt() already generated a run-level invocation ID, but prompt() did not. Synchronous middleware saw $prompt->invocationId === null while streaming middleware saw a real value, and because the provider generated its own ID per attempt, a run that failed over across three providers produced three unrelated IDs for what was, from the caller's point of view, one run.
prompt() now generates the ID up front and the provider reuses whatever the caller supplied (#871). Every event below carries it as its first constructor argument so that you can group a run's events on one string:
public function __construct( public string $invocationId, public int $stepNumber, // ...) {}
AgentFailedOver also gained the ID: it now takes a required string $invocationId as its first constructor argument. Listeners are unaffected, but any code constructing the event manually needs updating.
Step Events
StartingStep, StepCompleted, and StepFailed fire around every provider round-trip on both the synchronous and streaming paths (#873).
StartingStep carries the messages sent for the step, including the tool results of previous steps, and the options resolved for that step, which 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 reconstruct it, plus float $time, the wall time spent in the provider call in milliseconds. The unit matches QueryExecuted::$time, so code that reads query timings can handle 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 was available before through $response->steps, but only as a bulk payload on the terminal event, with no timing attached. Now the cost and the duration of each round-trip are reported as the run happens.
StepFailed covers cases where a step ends without producing a response, carrying the Throwable and the time spent before it threw.
Tool Events
InvokingTool and ToolInvoked already existed. However, they were reported through a pair of callbacks that each provider registered on the generation loop, and the current tool invocation ID was stored in a single mutable property on the provider, which 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, causing the outer event to report the inner call's ID.
A RunContext now carries the run's identity and dispatches these events directly, and the tool invocation ID is generated inside executeTool(), so each invocation receives its own unique ID (#872). Tools can read it themselves through the request:
public function handle(Request $request): string{ $request->toolInvocationId(); // string|null}
The new event is ToolFailed (#874). executeTool() previously had no catch, so if a tool's handler threw an exception, it propagated straight out of the generation loop: the invocation never reported an end, and the run aborted without recording which tool caused it. ToolFailed reports that failure, carrying the same toolInvocationId as the InvokingTool that opened the invocation. The exception is still rethrown, so behavior is otherwise unchanged. Only the handler call is guarded, so a listener that throws is not misreported as a tool failure.
ToolInvoked also gained a required float $time, the wall time spent in the handler. If you construct these events manually, update the constructor call to include 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, so 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 (#876). With failover configured, the first provider that throws a FailoverableException is not terminal, so the event fires after the chain is exhausted. It carries the invocation ID, the prompt, and the exception.
AgentFailedOver also 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 unconditionally in every catch, including the last one.
Linking a Sub-Agent to Its Parent
An agent invoked as a tool looked like a separate run, with nothing correlating 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 (#875).
That covers a hand-written tool that prompts an agent, not just AgentTool. The IDs are held 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.
Queued Listeners and Message History
StartingStep carries the run's entire message history. A listener implementing ShouldQueue will serialize all of it, along with any attachments. That is the intended trade-off, since a listener that opens a span needs the request the step was sent with. If you only need timings and token counts, listen for StepCompleted instead, which carries the step's own response rather than the full history.
Further Reading
The events shipped in v0.11.0, alongside hosted tool search and wider provider failover. For reading the provider's own HTTP response during a run, including rate limit headers and request IDs, see the raw HTTP response property added in v0.10.3.
For background on the package, see the AI SDK's announcement and our coverage of human-in-the-loop tool approval. The source lives at laravel/ai on GitHub.