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) отображается на существующий контракт драйвера без новой абстракции:
- возможности канала =
ChannelSpec.Capabilities(Text,TwoWay,Threads,FilesOut,FilesIn) +MaxTextLenиInteractiveиз docs/channels.md §3 (при появлении нативных кнопок); - алгоритм выбора отображения: вся форма нативно? → канал; нет → web inbox /
встроенный чат; взаимодействие невозможно → вопрос всё равно создаётся и
ждёт в inbox, доставка фиксируется в
notification.sent(status, detail); - ядро не ветвится по типу канала: всё, что канало-специфично — внутри драйвера (docs/channels.md §3).
#5. Встроенный чат (Workspace) — реализовано
Встроенный чат — канал первого класса: ран, запущенный из чата, показывает вопрос прямо в бабле своего сообщения, отвечать можно не покидая диалог.
Механика (все части уже существовали, новая — только карточка):
- Доставка — отдельного канала нет: workflow и так эмитит
human.requestedв журнал рана, SSE-стрим/v1/workspace/runs/{id}/streamпересылает его чату. Карточка прикрепляется к сообщению рана (ChatQuestion:operation_id,arguments_hash, вопрос, контекст, опции). - Отображение —
ChatQuestionCard: опции кнопками (выбор = ответ), свободный ответ полем (Enter = отправить), «отменить вопрос». - Ответ —
POST /v1/agent/runs/{runID}/approval?project=…с телом{operation_id, arguments_hash, approved, actor_id, response}— тот же endpoint, что у страницы Runs: одна точка входа, одна модель авторизации и аудита. Отмена —approved=false, response="". - Закрытие — авторитетное состояние карточки: журналированные
human.answered / human.cancelled / human.timed_out / human.rejected; оптимистичный апдейт после 202 — только для отзывчивости UI. Терминальное событие закрывает карточку только своегоoperation_id— события старого вопроса не гасят новый. - Восстановление — после перезагрузки карточка возвращается из
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, сервер проверяет ответ
до возобновления рана.
Контракт:
- tool
ask_human, параметрschema— JSON Schema-объект{type:"object", properties:{…}, required:[…]}; ограничен 8KB и 20 полями, нерабочая схема деградирует в обычный вопрос (не роняет tool call); - схема хранится в
human_request.schema(JSONB, миграция 0009), едет в карточку через журнальное событиеhuman.requested; - рендерер общий: карточка чата и тайлы Runs используют
HumanForm.tsx; каналы без форм получают текстовую заглушку с перечнем полей иanswer_url; - ответ: approval endpoint принимает
values(map); серверная валидация —workspace.ValidateFormValuesДО сигнала воркфлоу (422 при нарушении, строка остаётся открытой); канонический JSON значений становитсяResponse— модель получает данные как JSON в tool result, контракт сигнала не менялся; - текстовый ответ из мессенджера принимается как прежде — валидация схемы
применяется только к структурированной отправке
values.
Поддерживаемое подмножество (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. Безопасность (без изменений по существу)
- авторизация:
RoleOperator+ проект на approval endpoint; identity-лесенка может запретить адресацию (human.rejected, §30); - actor: аутентифицированный principal перекрывает присланный
actor_id; - ответы человека — недоверенные данные: текст ответа никогда не исполняется как инструкция (уходит в модель как tool result, не как prompt верхнего уровня);
- идемпотентность: одна строка
human_requestна операцию; повторная доставка события канала отсекается дедупом, повторный ответ — закрытой строкой; - аудит:
RecordAccessAuditна ответ/отмену, вердикт — через durable outbox; - ссылка
answer_urlведёт в UI под логином, постоянных секретов в URL нет.
#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. Этапы доработки
- Этап A (готово): контракт v1 (
ask_human+human_request+ approval endpoint + политики таймаутов) — работает во всех каналах. - Этап B (готово): встроенный чат — карточка вопроса, ответ/отмена в диалоге, восстановление после перезагрузки.
- Этап C (готово): формы —
schemaвask_human, хранение вhuman_request, серверная валидация значений, общий рендерерHumanFormв чате и на Runs, текстовая заглушка для каналов (§7). - Этап D (готово): нативные кнопки там, где канал умеет:
Capabilities.Interactive; telegram inline keyboard — Options кнопками (токеныa:<requestID>:<idx>, cancela:<requestID>:x≤64Б, иначе деградация в текст), press → тот же approval-путь, что текстовый ответ (kernel/agent/notify.go askButtons, cmd/agent-kernel handleCallback); answerCallbackQuery подтверждает нажатие, editMessageText гасит кнопки. - Этап E (готово, частично): schema-формы как HTML-виджеты в Telegram (§7.1 ниже) — расширение подмножества §3.2–3.4 по мере надобности.
- Этап F: бэклог §9 по мере появления каналов и требований.
#7.1. Schema-формы как HTML-виджеты (Telegram, Bot API 10.4)
Schema-вопрос при доставке в telegram с Capabilities.Widgets рендерится
HTML-виджетом прямо в чате (sendWidget): селекты/чекбоксы/числа вместо
«текст + ссылка». Агенты не меняются: виджет — чистая функция над schema из
ask_human, всё канало-специфичное живёт под контрактом.
Слои («ядро не знает каналов, драйверы не знают продукта»):
- ядро (
kernel/agent/widgetform.go) — нейтральнаяWidgetModel(поля из подмножества §7, состояние, ошибки) и op-протокол:set/submit/reset, значения по индексу поля (ops ≤64Б), валидация — зеркалоworkspace.ValidateFormValues; ответ резолва — канонический JSON значений, тот же tool result, что у веб-формы; - драйвер (
kernel/agent/widgetrender.go) — модель → HTML-виджет (≤32КБ raw / ≤4096Б zlib), кнопка хостед-формы рядом; - конвейер: доставка пинит сессию в
workspace_channel_widget(миграция 0029, внутренний роутPOST /v1/workspace/channel-widgets); входной луп обрабатываетwidget_query: throttle 5/5с на отправителя → применить op к авторитетному состоянию →answerWidgetQuery(ack ≤15с) →editMessageWidgetData(перерисовка) → на submit — signal+закрытие+аудит через общийresolveAsk(cmd/agent-kernel/channelinbound.go).
Деградации (решает ядро, агент не узнаёт): группа/старый сервер/модель >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. Критерии приёмки
- Агент создаёт вопрос одним контрактом (
ask_human), не зная канала. - Вопрос, доставленный в любой канал, отвечает в любом другом (матчинг по адресам/тредам, строка одна).
- Встроенный чат показывает вопрос в диалоге и позволяет ответить (опции, свободный текст, форма, отмена) без ухода со страницы.
- Ответ из чата идёт через тот же approval endpoint и тот же аудит, что и из Runs и из мессенджера.
- Перезагрузка страницы при paused-ране восстанавливает открытую карточку; ответ, данный в другом месте, закрывает карточку через журнальное событие.
- Таймаут закрывает вопрос по политике (
fallback/fail/retry/escalate/cancel) и не оставляет открытую строку. - Ответ без права/под неверным
arguments_hashотклоняется сервером; форма с нарушенной схемой отклоняется с 422 до возобновления рана. - Форма рендерится одним компонентом в чате и на Runs; канал без форм получает текстовую заглушку с перечнем полей и не теряет вопрос.
- Добавление нового канала не меняет контракт агента и не требует правок в ядре HITL (драйвер + возможности).