Запуск агента редко ограничивается одним запросом к API. Модель возвращает вызовы инструментов (tools), SDK их исполняет, отправляет результаты обратно и повторяет этот процесс до завершения работы модели. До Laravel AI v0.11.0 цикл генерации не имел событий для отдельных шагов, сообщая лишь об  PromptingAgent  в начале и  AgentPrompted  в самом конце выполнения. Запуск, потребовавший пять сетевых запросов к провайдеру, выглядел абсолютно так же, как и запуск с одним запросом, а если в середине цикла возникало исключение, то ничего не логировалось, так как событие  AgentPrompted  так и не вызывалось.

Серия из семи пулл-реквестов от @pushpak1300, замерженных в рамках PR от #870 до #876, меняет эту ситуацию. Теперь у каждого запуска есть единый ID, а каждый запрос к провайдеру и вызов инструмента отправляют события начала и завершения с точным хронометражем выполнения.

#Единый ID для всего запуска

Метод  streamPrompt()  и раньше генерировал ID вызова на уровне всего запуска, но  prompt()  этого не делал. Синхронные middleware видели  $prompt->invocationId === null , в то время как потоковые middleware получали реальное значение. А поскольку провайдер генерировал собственный ID при каждой попытке, запуск с переключением при сбое (failover) через три провайдера создавал три не связанных между собой ID для того, что с точки зрения вызывающего кода было единым запуском.

Теперь  prompt()  сразу генерирует ID, а провайдер повторно использует то, что было передано вызывающей стороной (#871). Каждое из описанных ниже событий принимает его в качестве первого аргумента конструктора, что позволяет группировать все события запуска по одной строке:

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

Событие  AgentFailedOver  также получило этот ID: теперь оно принимает обязательный аргумент  string $invocationId  первым параметром конструктора. Это не влияет на слушатели (listeners), однако любой код, создающий это событие вручную, требует обновления.

#События шагов (Step Events)

События  StartingStep ,  StepCompleted  и  StepFailed  срабатывают до и после каждого запроса к провайдеру как при синхронном выполнении, так и при потоковой передаче (#873).

 StartingStep  содержит сообщения, отправленные для текущего шага, включая результаты выполнения инструментов из предыдущих шагов, а также опции, разрешенные для этого шага (которые могут отличаться от собственных опций агента после того, как было удовлетворено требование принудительного выбора инструмента). Оно также содержит  stepNumber  и  isFinalStep :

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

 StepCompleted  содержит весь объект  StepResponse , поэтому слушателю больше не нужно собирать его заново, а также  float $time  — время, потраченное на запрос к провайдеру в миллисекундах. Единица измерения совпадает с  QueryExecuted::$time , поэтому код, обрабатывающий время выполнения база-данных запросов, может работать с этими значениями аналогично.

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,
    ]);
});
 

Ранее статистика использования по шагам была доступна через  $response->steps , но только в виде единой агрегированной структуры в итоговом событии и без информации о времени. Теперь расход токенов и длительность каждого запроса логируются непосредственно по ходу выполнения запуска.

 StepFailed  обрабатывает случаи, когда шаг завершается без получения ответа, передавая объект  Throwable  и время, прошедшее до выброса исключения.

#События инструментов (Tool Events)

События  InvokingTool  и  ToolInvoked  существовали и раньше. Однако они вызывались через пару callback-функций, которые каждый провайдер регистрировал в цикле генерации, а ID текущего вызова инструмента хранился в единственном изменяемом свойстве провайдера, что ломалось при вложенных вызовах. Поскольку менеджер возвращает один экземпляр провайдера на имя, агент, вызванный в качестве инструмента, перезаписывал ID до того, как срабатывал внешний  ToolInvoked , в результате чего внешнее событие содержало ID внутреннего вызова.

Теперь объект  RunContext  хранит контекст запуска и отправляет эти события напрямую, а ID вызова инструмента генерируется внутри  executeTool() , благодаря чему каждый вызов получает свой уникальный ID (#872). Инструменты могут самостоятельно прочитать его из запроса:

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

Новое событие —  ToolFailed  (#874). Ранее метод  executeTool()  не имел блока  catch , поэтому если обработчик инструмента выбрасывал исключение, оно пробрасывалось наверх из цикла генерации: вызов инструмента так и не фиксировал свое завершение, а запуск прерывался без сохранения информации о том, какой именно инструмент вызвал сбой.  ToolFailed  фиксирует эту ошибку, содержа тот же  toolInvocationId , что и открывавший вызов  InvokingTool . Исключение при этом всё равно пробрасывается повторно, поэтому поведение системы в остальном не изменилось. Защищен только вызов самого обработчика, поэтому если исключение выбросит слушатель событий, это не будет ошибочно зафиксировано как сбой инструмента.

Событие  ToolInvoked  также получилось обязательный аргумент  float $time  — время выполнения обработчика. Если вы создаете эти события вручную, обновите вызов конструктора, передав длительность.

Оба события теперь передают экземпляр класса  Tool , а не его имя, поэтому связывайте их по  toolInvocationId  и получайте наименование из объекта:

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)

Все события, завершающие интервал выполнения в пакете, ранее вызывались только при успешном сценарии. Если шлюз выбрасывал исключение,  AgentPrompted  никогда не вызывался, из-за чего слушатель, отслеживающий запуск, не мог узнать о его ошибке.

Событие  AgentFailed  сообщает об этом один раз за запуск и только после его фактического завершения (#876). Если настроен failover, то первый провайдер, выбросивший  FailoverableException , не приводит к критическому сбою, поэтому событие сработает только после того, как будет исчерпана вся цепочка провайдеров. Оно содержит ID вызова, промпт и само исключение.

Кроме того,  AgentFailedOver  больше не вызывается для последнего провайдера в цепочке. У этой попытки больше нет запасных вариантов, поэтому она фиксируется непосредственно как ошибка запуска. Ранее событие failover отправлялось безусловно в каждом блоке  catch , включая последний.

#Связывание под-агента с родителем

Агент, вызванный в качестве инструмента, ранее выглядел как отдельный запуск, никак не связанный с родительским. Теперь вызов инструмента отслеживает ID своего запуска и ID вызова инструмента на всём протяжении своей работы. Любой агент, вызванный во время выполнения этого инструмента, получает их в свойствах  parentInvocationId  и  parentToolInvocationId  своего промпта (#875).

Это работает и для кастомных инструментов, вызывающих агента, а не только для  AgentTool . Эти ID хранятся в статическом свойстве, а не в контексте, что предотвращает попадание делегирующего вызова в payload очередей задач (queued jobs).

Эта связь не пересекает границы очередей. Промпт, отправленный через  promptOnQueue()  изнутри инструмента, создаст собственный независимый запуск без родителя.

#Очередность слушателей и история сообщений

 StartingStep  передает всю историю сообщений запуска. Слушатель, реализующий интерфейс  ShouldQueue , будет сериализовать всю историю вместе со всеми вложениями. Это осознанный компромисс, так как слушателю, открывающему интервал отслеживания, нужен исходный запрос, с которым был отправлен шаг. Если вам нужны только тайминги и количество токенов, подписывайтесь на  StepCompleted , который содержит ответ конкретного шага, а не всю историю.

#Что еще почитать

Эти события были выпущены в v0.11.0 вместе с возможностью поиска инструментов на стороне сервиса и расширенным failover для провайдеров. Подробнее о чтении сырых HTTP-ответов провайдера во время запуска (включая заголовки rate limit и ID запросов) читайте в заметке про свойство raw HTTP response, добавленное в v0.10.3.

Для ознакомления с пакетом изучите анонс AI SDK и наш материал про подтверждение вызова инструментов человеком (human-in-the-loop). Исходный код доступен на GitHub в репозитории laravel/ai.