24. Асинхронные действия — generic wait-event
Статус: спека (проект). Проблема + предлагаемое решение для поддержки флоу вида «запрос → показать юзеру → дождаться внешнего коллбэка → вернуть результат».
Проблема
Инструменты Agent OS синхронны: вызов → результат. Но есть класс флоу, где результат приходит не сразу, а после внешнего действия, невидимого для агента:
- СБП / оплата: создать платёж → показать QR → вебхук
payment.succeeded; - OAuth (частный случай, уже решён точечно через
oauth_connect); - подтверждение по коду (SMS / e-mail / push);
- «подтвердить в мобильном приложении/банке»;
- любой «запрос → пользователь что-то делает вне диалога → коллбэк».
Сейчас обобщённого примитива для этого нет:
| Механизм | Что умеет | Чего не хватает |
|---|---|---|
rest_tool / site_tool |
исходящий запрос (GET/POST/…, oauth2, sign) | один запрос, не ждёт |
oauth_connect |
suspend → resume по коллбэку | захардкожен под OAuth-URL |
request_attachment |
suspend, ждёт файл/фото от юзера | специализирован на вложения |
inbound webhook (POST /v1/inbound/:template) |
запускает новый ход по вебхуку | не резолвит уже висящий ход; ждёт {message} |
В итоге любой асинхронный флоу либо пишут нативно под себя, либо костылят поллингом.
Цели
- Обобщённый
kind: await_event— декларативный флоуstart → prompt → suspend → callback → resume. - Один нативный движок + один общий коллбэк-роут; конкретные интеграции — YAML.
- Покрыть СБП, подтверждения по коду, «подтверди в приложении»;
oauth_connectвыразить как частный случай.
Не-цели (v1)
- Отрисовка QR картинкой (начинаем со ссылки/кнопки).
- Гарантии доставки/ретраи коллбэков (это ответственность внешней системы).
- Очереди/приоритеты ожидающих ходов.
- Долгоживущие коллбэки дольше жизни процесса (без персистентного хранения pending).
Существующие примитивы ядра
kernel.request_user_input(pid, kind, prompt, timeout)— эмитит input-request, ход приостанавливается.kernel.request_user_input_with_id(pid, kind, prompt, timeout, id)— то же, с явным id для привязки коллбэка.kernel.resolve_user_input(id, response)— резолвит приостановленный ход.
Уже используются: oauth_connect (URL авторизации) и request_attachment (файл/фото).
Это ровно тот «suspend → resume» механизм, который нужен; не хватает только
декларативной обёртки общего назначения и универсального коллбэк-роута.
Предложение: kind: await_event
YAML-схема
kind: await_event
name: sbp_payment
description: Принять оплату по СБП (QR) и дождаться подтверждения.
parameters: # JSON-Schema для LLM
type: object
properties:
amount: { type: string, description: "Сумма, напр. 100.00" }
required: [amount]
start: # 1. запрос-инициатор (поля как у rest_tool)
method: POST
url: "https://api.yookassa.ru/v3/payments"
json:
amount: { value: "{{ amount }}", currency: "RUB" }
capture: true
confirmation: { type: "redirect" }
payment_method_data: { type: "sbp" }
headers:
Authorization: "Basic {{ (env_var('YOOKASSA_SHOP_ID') ~ ':' ~ env_var('YOOKASSA_SECRET_KEY')) | base64 }}"
prompt: # 2. что показать юзеру
message: "Оплатите по QR:"
url_pointer: /confirmation/confirmation_url # JSON-pointer → URL для показа
resolve:
id_pointer: /id # 3. JSON-pointer → id, по которому сопоставлять коллбэк
timeout_secs: 600 # 4. таймаут ожидания (по истечении — «не дождались»)
Флоу выполнения
1. выполнить `start` (method/url/json/headers/oauth2/sign) → ответ JSON
2. извлечь `prompt.url_pointer` → URL, `resolve.id_pointer` → id
3. kernel.request_user_input_with_id(pid, "await_event", prompt, timeout, id)
→ эмитит input-request (показ URL) и ПРИОСТАНАВЛИВАЕТ ход
4. [ход висит; юзер платит/подтверждает вне диалога]
5. внешняя система шлёт коллбэк → POST /v1/events/callback { id, ... }
6. kernel.resolve_user_input(id, payload) → ход ВОЗОБНОВЛЯЕТСЯ
7. await_event возвращает `payload` (или результат сверки) агенту
Коллбэк-роут
POST /v1/events/callback — общий, не привязан к конкретной интеграции:
- принимает
{ "id": "...", ... }; - ищет pending-ход по
id, резолвит егоresolve_user_input; - auth — как у
inbound(token_env), опционально — проверка подписи на стороне коллбэка; - если pending по id нет — idempotent-noop (или логирует), чтобы «забывчивый» агент не терял событие (добираем сверкой статуса по
state_tool/cron).
Показ юзеру (QR)
Источник — всегда prompt.url_pointer (URL). Рендер — на стороне канала:
| Канал | v1 (ссылка/кнопка) | v2 (QR-картинка) |
|---|---|---|
| Виджет (web) | кнопка «Оплатить» | клиентский JS-QR из URL |
| Telegram / VK | сообщение со ссылкой | серверный PNG (qrcode-крейт) + фото |
| Android | кнопка-ссылка | Kotlin-QR |
В await_event ничего QR-специфичного — он отдаёт URL, QR рисует канал.
Альтернативы (почему не они)
| Вариант | Минус |
|---|---|
Поллинг (тул/агент крутит GET status) |
ест воркер и лимиты API; LLM ненадёжно «ждёт»; долгий ход |
Отдельный payment_tool |
узко: не переиспользуется для кодов/подтверждений |
Webhook → inbound |
inbound ждёт {message}, а провайдеры шлют свой формат → нужен шим; и это «новый ход», а не resume |
Открытые вопросы
- Имя kind:
await_event(рекомендую) vsasync_actionvscallback_tool. - Что возвращает тул по коллбэку: сырой
payloadили{status, payload}? - Персистентность pending между рестартами процесса (v2?) — сейчас pending в памяти.
- Проверка подписи коллбэка: общая (
sign-подобная) или на уровне конкретной интеграции.
План внедрения
- ✅ Спек (этот документ).
- Нативный движок
await_event+ роут/v1/events/callback(+ юнит/интеграционные тесты). - Примеры YAML: СБП (ЮKassa), подтверждение по коду; выразить
oauth_connectкак частный случай. - QR-рендер: ссылка → кнопка → клиентский QR → серверный PNG.
- Док-глава «REST-интеграции» с рабочими примерами (Трекер, ЮKassa СБП, await_event).