temporality
Изменить страницу↗
Инженерные документы

HITL: унифицированный контракт для каналов и встроенного чата

Версия: 2.0 Статус: Доработано под текущую реализацию (as-built + план). v1.0 — общее ТЗ; v2.0 привязывает его к яру human_request / ask_human, фактическому реестру каналов (docs/channels.md) и встроенному чату Workspace.

#1. Цель

Единый механизм взаимодействия агента с человеком (Human-in-the-Loop): агент запрашивает решение, данные или подтверждение, рана становится на паузу, вопрос доставляется человеку в его канал, ответ возобновляет ран. Контракт вопроса не зависит от канала: агент описывает запрос один раз, ядро выбирает способ доставки и отображения.

Принцип неизменен со времён v1.0: агент не знает каналов, каналы не знают продукта — ядро мэтчит ответ на вопрос по адресам и времени, а не по протоколу мессенджера.

#2. Что уже реализовано (as-built)

Ядро HITL работает в проде; канонический контракт v1.0 совпадает с ним практически один в один:

Концепция v1.0 Реализация Где
Создание HITL-запроса tool ask_human: question, context, options[], recipient, timeout_sec, timeout_policy kernel/agent/workflow.go (~L3470 — описание tool, ~L1715 — исполнение)
Пауза и возобновление Temporal signal Approval (awaitApproval), раунды ожидания для retry/escalate kernel/agent/workflow.go
Хранилище запросов сущность human_request: статусы, thread_ref, fan-out deliveries workspace/models.go
Канальная доставка activity → pickChannel → реестр ChannelDriver (matrix, telegram, slack, slackbot, mattermost, webhook) kernel/agent/notify.go, kernel/agent/channeldriver.go
Приём ответов из канала общий inbound-конвейер: дедуп → идентификация → матчинг «чат + старейший открытый вопрос» (тред — точный) → AnswerHumanRequest + Approval signal cmd/agent-kernel/channelinbound.go
Submission API POST /v1/agent/runs/{runID}/approval: операция, arguments_hash (версия), actor, response; закрывает строку human_request, аудит, вердикт в durable outbox (hitl.verdict.recorded) cmd/agent-kernel/main.go
Web UI (inbox) открытые вопросы: список onlyOpen, ленивое истечение; ответ — тайлы вопроса на странице Runs debugger/src/AgentRuns.tsx, debugger/src/Layout.tsx
Встроенный чат карточка вопроса в бабле рана, опции + свободный ответ + отмена; ответ через тот же approval endpoint debugger/src/Workspace.tsx (§5)
Таймауты и эскалация политики fallback / fail / retry / escalate / cancel, повторный ask (human.reasked), идемпотентное закрытие строки kernel/agent/workflow.go, workspace/models.go
Командные вопросы org:<unit> fan-out: копия каждому активному участнику, deliveries, один thread_ref на запрос kernel/agent/notify.go, docs/inbound-messages.md
Поиск адресата tool human_contacts (+ escalate_for) перед ask_human kernel/agent/contacts.go

Вывод: этапы 1–2 v1.0 (канонический контракт + универсальный web UI) уже выполнены, но в терминах этой системы — «human_request + approval endpoint», а не отдельный HITL-сервис. Дальше — интерактивность каналов и формы.

#3. Текущие каналы связи

Фактический реестр — channelDrivers (kernel/agent/channeldriver.go); возможности каждого канала объявляет Spec().Capabilities. Каналы вне реестра (Teams, Mini Apps) — не в ближнем скоупе (§9).

Канал Модель inbound TwoWay Threads Интерактивность сейчас План
Matrix pull /sync, mxid да — текст + ссылка «ответить здесь» кнопки через клиент-агностику не обещаем
Telegram pull getUpdates, chat id да (личка) — inline keyboard (Capabilities.Interactive, callback a:<id>:<idx> + cancel; answerCallbackQuery; editMessageText «решён») + schema-формы HTML-виджетом (Bot API 10.4, §7.1) + rich-ответы + черновики TON-типы виджет-запросов (sign_data/sent_transaction) — логируются
Slack (webhook) только outbound — — текст + answer_url остаётся outbound-каналом
Slack (bot) push Events API, member id да да (ts) текст в треде вопроса Block Kit buttons
Mattermost pull WebSocket, user id да да (root post) текст в треде вопроса interactive dialogs
Generic webhook только outbound (HMAC envelope) — — answer_url —
Web inbox (UI) роут приложения да — тайлы на странице Runs формы (§7)
Встроенный чат (Workspace) SSE-стрим рана да — карточка вопроса (§5) формы (§7)

Правила выбора канала неизменны (pickChannel): предпочитаемый канал получателя, иначе первый включённый и сконфигурированный, иначе web inbox. «Web» — безусловный фолбэк: недоставленный или упавший канал не теряет вопрос.

#3.1. Хостед-формы в каналах (один клик из мессенджера)

Нативные формы в чат-платформах слабы, поэтому schema-вопрос при доставке в канал минтит хостед-форму: workspace_form_link (токен 32 байта, один вопрос на токен, жизнь 7 дней или до решения) и кнопку-ссылку <UI_URL>/kernel-api/forms/<token> в сообщении. База ссылки — тот же UI URL из настроек доставки, что и у всех нотификационных ссылок; страница и ответ живут за /kernel-api на ядре (роуты форм токен-авторизованы, логин в харнес не нужен).

Матрикс: карточка с таблицей полей и кнопкой «fill the form». Остальные каналы: URL в тексте. Ответ проходит ту же валидацию и сигнал, что и из чата; аудит пишет actor form-link.

#4. Capability negotiation

Договор v1.0 (§5.4) отображается на существующий контракт драйвера без новой абстракции:

#5. Встроенный чат (Workspace) — реализовано

Встроенный чат — канал первого класса: ран, запущенный из чата, показывает вопрос прямо в бабле своего сообщения, отвечать можно не покидая диалог.

Механика (все части уже существовали, новая — только карточка):

  1. Доставка — отдельного канала нет: workflow и так эмитит human.requested в журнал рана, SSE-стрим /v1/workspace/runs/{id}/stream пересылает его чату. Карточка прикрепляется к сообщению рана (ChatQuestion: operation_id, arguments_hash, вопрос, контекст, опции).
  2. Отображение — ChatQuestionCard: опции кнопками (выбор = ответ), свободный ответ полем (Enter = отправить), «отменить вопрос».
  3. Ответ — POST /v1/agent/runs/{runID}/approval?project=… с телом {operation_id, arguments_hash, approved, actor_id, response} — тот же endpoint, что у страницы Runs: одна точка входа, одна модель авторизации и аудита. Отмена — approved=false, response="".
  4. Закрытие — авторитетное состояние карточки: журналированные human.answered / human.cancelled / human.timed_out / human.rejected; оптимистичный апдейт после 202 — только для отзывчивости UI. Терминальное событие закрывает карточку только своего operation_id — события старого вопроса не гасят новый.
  5. Восстановление — после перезагрузки карточка возвращается из localStorage (там живёт весь ChatMessage) и по tail-replay стрима (пауза рана = human.requested в последних 50 событиях). Восстановление на другом устройстве карточки не создаёт (транскрипт сервера не хранит run_id) — вопрос остаётся виден в inbox; это осознанное ограничение.

Активность вопроса также пишется в ленту статуса бабла (❓ …, ✓ Отвечено: …, ⏱ …) — по тем же formatStreamEvent, что и остальные события.

#6. Жизненный цикл

Состояния v1.0 мэтчатся на human_request так:

v1.0 human_request Кто ставит
created+delivered pending → delivered workflow + activity доставки
in_progress — (не различаем: клиентское понятие)
submitted+consumed answered approval endpoint / inbound-конвейер
rejected rejected (адресация запрещена идентичностью) workflow §30
cancelled cancelled человек или отмена рана
expired expired таймаут + ленивое истечение в list-эндпоинте
delivery_failed строка остаётся открытой, факт фиксирует notification.sent

Недопустимые переходы отклоняются хранилищем (AnswerHumanRequest на закрытой строке — ErrNotFound/no-op; гонка «ответ пришёл после таймаута» — ожидаемая, строка уже закрыта, ран пошёл дальше).

Версионирование v1.0 (§7.3) уже реализовано избирательно: повторный ответ принимается только под тем же arguments_hash операции; изменение схемы вопроса агентом = новый ask_human с новой операцией.

#7. Формы (v2 контракта — реализовано)

Простой вопрос (options[] + свободный текст) остаётся базовым случаем. Поверх human_request работает форма: агент прикладывает к ask_human необязательную schema, человек заполняет её в UI, сервер проверяет ответ до возобновления рана.

Контракт:

Поддерживаемое подмножество (workspace/forms.go — серверная авторитетная проверка, validateFormValues в HumanForm.tsx — клиентское удобство):

Что Ключи
Типы полей string, integer, number, boolean, array
Один выбор (radio/select) enum у string
Множественный выбор (чекбоксы) type: "array" + items.enum
Диапазон дат (два пикера) type: "array", minItems: 2, maxItems: 2, items: {type: "string", format: "date"}
Обязательность required (верхний уровень)
Строки minLength, maxLength, pattern (RE2, семантика JSON Schema — без якорей ищет в подстроке). Поле с pattern ОБЯЗАНО нести title/description по-человечески — регулярку человек не читает; если подписи нет, UI показывает расшифровку (^[A-Z]{2,4}-[0-9]{3,6}$ → «A–Z×2–4 + "-" + 0–9×3–6, строка целиком»), а ошибка сервера цитирует паттерн
Числа minimum, maximum
Форматы строк format: date (YYYY-MM-DD), date-time (RFC 3339), time (HH:MM:SS), email, url, password (секретный ввод). Датовые контролы следуют локали браузера (порядок дат, 12/24ч); клиент нормализует браузерные значения при отправке (секунды + смещение пояса), сервер принимает и наивные формы (2006-01-02T15:04, 15:04), трактуя их как UTC
Массивы minItems, maxItems (≤100 элементов)
Метаданные title, description (подпись и подсказка поля)

Паттерны компилируются как Go RE2 — тот же синтаксис валиден в браузере, поэтому клиентская и серверная проверки согласованы. Неизвестные ключи игнорируются, неизвестный тип поля отклоняет значение: нераспознанное ограничение не проходит молча.

Ответ доставляется модели целиком: эндпоинт сворачивает проверенные values в канонический JSON до сигнала (422 — до возобновления рана), workflow отдаёт его в tool result без 256-байтового предпросмотра-обрезания — форма не может «дойти пустой».

Пример (агент спрашивает параметры деплоя):

{"question": "Параметры релиза billing-service?",
 "schema": {"type": "object",
   "properties": {
     "channel": {"type": "string", "enum": ["canary", "stable"], "title": "Канал"},
     "version": {"type": "string", "minLength": 5, "title": "Версия"},
     "replicas": {"type": "integer", "minimum": 1, "maximum": 50},
     "confirm":  {"type": "boolean", "title": "Подтверждаю выкат"}},
   "required": ["channel", "version", "confirm"]}}

Ответ придёт агенту как Human response: {"channel":"canary","version":"v1.2.3","replicas":2,"confirm":true}.

Каталог примитивов, условные поля, многошаговость и карточки сущностей из v1.0 §3.2–3.4 остаются референсом для расширений подмножества (не блокируют каналы: новый тип поля = поле в рендерере + ветка в валидаторе).

#8. Безопасность (без изменений по существу)

#9. Вне ближнего скоупа

Перенесено из v1.0 в бэклог без сроков: Microsoft Teams, Mattermost Dialogs, Telegram Mini Apps, Matrix-кнопки, A2UI/MCP Apps как транспорт форм, групповое согласование и перенос запроса между каналами. Основание: текущие каналы и два UI (inbox, чат) покрывают сценарии; добавление канала — один драйвер (docs/channels.md §8), и формы поверх human_request (§7) не должны заставлять драйверы знать о HITL больше, чем про текст и тред.

#10. Этапы доработки

#7.1. Schema-формы как HTML-виджеты (Telegram, Bot API 10.4)

Schema-вопрос при доставке в telegram с Capabilities.Widgets рендерится HTML-виджетом прямо в чате (sendWidget): селекты/чекбоксы/числа вместо «текст + ссылка». Агенты не меняются: виджет — чистая функция над schema из ask_human, всё канало-специфичное живёт под контрактом.

Слои («ядро не знает каналов, драйверы не знают продукта»):

Деградации (решает ядро, агент не узнаёт): группа/старый сервер/модель >1КБ → текст + хостед-форма; сессия истекла → «форм уже закрыт»; невалидный submit → ошибки полей уезжают в виджет, строка остаётся открытой. Ограничения prod: виджеты только в личных чатах, protect_content неизбежен, TON-типы запросов (sign_data/sent_transaction) логируются и ack-аются без кошельковой логики.

Черновики генерации (sendMessageDraft): answer-watcher стримит накопленный model.text_delta как ephemeral-превью ответа (tail ≤1200 символов, стабильный draft_id на (чат, ран)); превью живёт ~30с и заменяется финальным sendRichMessage/sendMessage. can_stop выключен — остановка рана остаётся явным продуктовым действием.

#11. Критерии приёмки

  1. Агент создаёт вопрос одним контрактом (ask_human), не зная канала.
  2. Вопрос, доставленный в любой канал, отвечает в любом другом (матчинг по адресам/тредам, строка одна).
  3. Встроенный чат показывает вопрос в диалоге и позволяет ответить (опции, свободный текст, форма, отмена) без ухода со страницы.
  4. Ответ из чата идёт через тот же approval endpoint и тот же аудит, что и из Runs и из мессенджера.
  5. Перезагрузка страницы при paused-ране восстанавливает открытую карточку; ответ, данный в другом месте, закрывает карточку через журнальное событие.
  6. Таймаут закрывает вопрос по политике (fallback/fail/retry/escalate/cancel) и не оставляет открытую строку.
  7. Ответ без права/под неверным arguments_hash отклоняется сервером; форма с нарушенной схемой отклоняется с 422 до возобновления рана.
  8. Форма рендерится одним компонентом в чате и на Runs; канал без форм получает текстовую заглушку с перечнем полей и не теряет вопрос.
  9. Добавление нового канала не меняет контракт агента и не требует правок в ядре HITL (драйвер + возможности).