Перейти к содержанию

Протокол v1 — справочник

Транспорт — WebSocket, каждое сообщение — один JSON-кадр в UTF-8. Точка входа по умолчанию: ws://<хост>:8787/ws; наружу — wss://<домен>/ws через reverse-proxy (см. Владельцу агента).

Источник истины — adapter/hermes_mobile/protocol.py (константы и валидация) и adapter/hermes_mobile/adapter.py (поведение). Клиентские реализации: sdk/typescript/, sdk/python/hermes_client.py.

Общие правила кадра

  • t — тип кадра, обязателен, регистр не важен (приводится к нижнему).
  • v — версия протокола; сервер объявляет её в кадре ready. Текущая — 1.
  • id — идентификатор, который задаёт клиент; нужен для идемпотентности и ответов (обрезается до 128 символов).
  • Неизвестный тип кадра клиент обязан игнорировать, а не рвать соединение. Сервер ведёт себя так же: неизвестный кадр от клиента игнорируется (пишется debug-логи).
  • Кадр-объект, не JSON или не UTF-8 → ошибка bad_frame. Сверху по размеру — too_big.

Ограничения и лимиты

Константа Значение Что ограничивает
PROTOCOL_VERSION 1 версия протокола (в ready.v)
MAX_FRAME_BYTES 16 МиБ (16777216) один кадр WebSocket (голос в base64)
MAX_TEXT_CHARS 12000 текст сообщения (text, caption)
MAX_AUDIO_BYTES 8 МиБ (8388608) кадр audio (запись голоса)
MAX_MEDIA_BYTES 8 МиБ (8388608) один медиафайл в обе стороны
MEDIA_CHUNK_BYTES 3 МиБ (3145728) размер куска при передаче больших файлов
MIN_MEDIA_CHUNK_BYTES 4 КиБ (4096) минимальный кусок при сборке медиа — от него считается потолок parts
MAX_MEDIA_PARTS 2048 потолок parts одного файла (ceil(8 МиБ / 4 КиБ))
DEFAULT_CHUNK_LIMIT 3500 сколько символов в одном кадре message (нарезка ответа)
DEFAULT_PROMPT_TTL 300 с время жизни карточки, если владелец не переопределил
незавершённых загрузок 8 на устройство сколько многочастных media можно держать открытыми одновременно
суммарный буфер устройства 8 МиБ (8388608) сумма всех открытых многочастных загрузок одного устройства
hello-таймаут 15 с столько сервер ждёт первый кадр hello
typing idle 3 с пауза без сообщений → аватар возвращается в idle
код закрытия 1008 отказ в доступе (WebSocket policy violation)

Рукопожатие

  1. Клиент подключается к ws(s)://…/ws. Токен передаётся только полем token в кадре hello — приём через ?token= в URL удалён: query-строка попадает в логи reverse-proxy и утекает.
  2. Первым кадром клиент обязан отправить hello — иначе сервер пришлёт error и закроет соединение.
  3. Сервер отвечает ready с session, device, avatar_url и capabilities.
{"t":"hello","token":"<токен>","device":{"id":"tel-1","name":"Телефон","platform":"android","app_version":"1.0.0"},"user":{"id":"u-1","name":"Евгений"}}

Токен можно привязать к устройству: элемент MOBILE_EXTRA_TOKENS вида device_id=token разрешает этому токену подключаться только под указанным device.id. Попытка занять чужой id с другим токеном отклоняется кодом forbidden и закрытием 1008; то же устройство с тем же токеном — это переподключение (старое соединение закрывается). Подробнее — в Безопасности.

{"t":"ready","v":1,"session":"mobile:tel-1","device":{"id":"tel-1","name":"Телефон"},"avatar_url":"https://agent.example.com/avatar/","capabilities":{"text":true,"voice":true,"typing":true,"avatar_states":["idle","thinking","speaking"],"media_in":["image","video","document","audio"],"media_out":["image","voice","video","document","animation"],"prompts":["exec_approval","slash_confirm","clarify"],"edit":true,"max_media_bytes":8388608,"max_text_chars":12000}}

Поля capabilities:

Поле Тип Смысл
text / voice / typing bool базовые возможности (всегда true)
avatar_states список состояния аватара: idle, thinking, speaking
media_in список что сервер принимает от приложения: image, video, document, audio
media_out список что сервер может прислать: image, voice, video, document, animation
prompts список типы карточек: exec_approval, slash_confirm, clarify
edit bool сервер умеет править message по id (потоковая выдача)
max_media_bytes number потолок одного медиафайла (8388608)
max_text_chars number потолок текста (12000)

session (mobile:<device.id>) — идентификатор сессии в Hermes и адрес доставки для hermes send --to mobile:<device.id>.

Клиент → сервер: все кадры

hello

Поле Тип Обязательно Смысл
t string да "hello"
token string да токен приложения — только в кадре hello; query-параметр ?token= не поддерживается
device object нет {id,name,platform,app_version} — все строки, обрезаются до 120 символов
device.id string нет* постоянный идентификатор устройства → session; если нет — сгенерируется dev-<hex>
user object нет {id,name} — под каким пользователем вас видит Hermes

* device.id не обязателен формально, но без него меняется адрес сессии и усложняется доступ. Задавайте всегда. Повторный hello в одном соединении — ошибка bad_frame.

text

Поле Тип Обязательно Смысл
t string да "text"
id string да идентификатор сообщения (идемпотентность)
text string да текст сообщения, ≤ 12 000 символов, не пустой

audio

Поле Тип Обязательно Смысл
t string да "audio"
id string да идентификатор сообщения
format string да "wav" или "ogg" (другие → unsupported_frame)
data string да запись в base64, декод ≤ 8 МиБ
text string нет распознанный текст (если есть своё STT) — тогда агент получит и текст, и файл

media (приложение → агент)

Поле Тип Обязательно Смысл
t string да "media"
id string да идентификатор файла; один и тот же для всех кусков
kind string да image / video / document / audio (иначе unsupported_frame)
mime string нет MIME; если пусто — определяется по filename
filename string нет имя файла (обрезается до 200 символов), важно для документов
caption string нет подпись (≤ 12 000); для audio работает как распознанный текст
data string да содержимое куска в base64
part integer нет номер куска, с 0 (по умолчанию 0)
parts integer нет всего кусков, ≥ 1 и ≤ 2048 (по умолчанию 1)

Файл ≤ 8 МиБ — одним кадром (parts: 1); крупнее — кусками по ~3 МиБ (part: 0,1,2…). Адаптер собирает все куски и только потом отдаёт файл агенту: серию нельзя обрывать.

Ограничения памяти при загрузке файлов

Приложение не может выесть память шлюза незавершёнными загрузками:

  • не больше 8 незавершённых многочастных загрузок на одно устройство одновременно;
  • суммарный размер всех открытых буферов одного устройства — не больше 8 МиБ;
  • parts одного файла — не больше 2048 (ceil(8 МиБ / 4 КиБ)); parts считается от минимального куска 4 КиБ, а не от 3 МиБ.

При превышении числа загрузок, суммарного объёма (> 8 МиБ) или parts (> 2048) сервер отвечает too_big / bad_frame и не держит буфер. Незавершённые буферы устройства освобождаются при его отключении.

prompt (ответ на карточку)

Поле Тип Обязательно Смысл
t string да "prompt"
prompt_id string да id карточки, которую прислал сервер
option_id string условно выбранный вариант; если нет — нужен непустой text
text string условно «свой ответ» (для clarify) либо вместо option_id

Хотя бы одно из option_id / text должно быть непустым, иначе bad_frame.

Прочие кадры клиента

Кадр Поля Смысл
typing t «пользователь печатает»; сервер учитывает для аватара
ping t, ts? проверка связи; сервер отвечает pong с тем же ts
ack t, id приложение показало/проиграло сообщение; сервер игнорирует
deliver t, id, target?, text?, media? служебная доставка (cron/hermes send); хотя бы text или media

deliver.media — объект вложения: {kind, mime?, filename?, data(base64)}. target — адрес устройства; если пусто — используется MOBILE_HOME_DEVICE.

Кадр deliver — служебный: он принимается только соединением с loopback-адреса (127.0.0.0/8, ::1) или соединением с токеном из MOBILE_DELIVER_TOKENS. Остальным устройство отвечает error{code:"forbidden"} — так обычный клиент не может разослать сообщение от лица cron.

Сервер → клиент: все кадры

ready

Поле Тип Обязательно Смысл
t string да "ready"
v integer да версия протокола (1)
session string да mobile:<device.id>
device object да {id,name} — устройство, как его видит сервер
avatar_url string да URL HTML-страницы аватара (может быть пустой)
capabilities object да объявленные возможности (таблица выше)

message (ответ агента)

Поле Тип Обязательно Смысл
t string да "message"
id string да идентификатор сообщения агента — один и тот же для всех частей и правок
text string да очередная часть текста (≤ 3 500 символов на кадр)
chunk object да {index, total} — номер и число частей
final bool да true — последняя часть/правка
edit bool нет true — правка ранее отправленного сообщения с тем же id (стриминг)

Длинный ответ режется на несколько message с одним id; последний имеет final: true. Склеивайте по id — тогда ответ выглядит как одно сообщение.

media (агент → приложение)

Поле Тип Обязательно Смысл
t string да "media"
id string да идентификатор файла (собирайте куски по нему)
kind string да image / voice / video / document / animation
mime string да MIME-тип
filename string да имя файла
caption string нет подпись
part integer да номер куска, с 0
parts integer да всего кусков
final bool да true — последний кусок файла
size integer да полный размер файла в байтах (не куска)
data string нет кусок в base64 (может отсутствовать, если файл отдаётся ссылкой)
url string нет ссылка на файл (приложение качает само)
reply_to string нет id сообщения, на которое отвечает медиа

prompt (карточка с кнопками)

Поле Тип Обязательно Смысл
t string да "prompt"
id string да prompt_id — верните его в ответе
kind string да exec_approval / slash_confirm / clarify
text string да заголовок/вопрос
options array да кнопки: [{id, label, style}] (style — primary/danger/пусто)
command string нет для exec_approval: точная команда, которую одобряют (покажите рядом с кнопками)
description string нет для exec_approval: пояснение, что команда делает
session string да сессия Hermes, которой адресован ответ
timeout_seconds integer да время жизни карточки (по умолчанию 300)

Для карточки exec_approval сервер шлёт отдельные поля command и description: приложение показывает точную команду рядом с кнопками, чтобы человек видел, что именно одобряет. Карточку может закрыть только то устройство, которому она показана — ответ с другого соединения получает error{code:"forbidden"}.

prompt_done (карточка закрыта)

Поле Тип Обязательно Смысл
id string да id карточки
option_id string нет фактически применённый вариант (может отличаться от присланного)
expired bool нет true — карточка закрыта по таймауту или была неизвестна

avatar, typing, pong, error

Кадр Поля Смысл
avatar state (idle/thinking/speaking) состояние аватара
typing stop? агент работает; stop: true — гасить индикатор немедленно
pong ts ответ на ping (эхо ts)
error code, message, fatal ошибка; fatal: true — далее соединение закрывается

Объявлено, но адаптером не отправляется

Кадр delivered ({t,id,device}) объявлен в protocol.py, но текущий адаптер его не шлёт: служебная доставка подтверждается только на уровне самого соединения. Опирайтесь на факт отсутствия error/device_offline. Поле avatar.mood также объявлено, но не отправляется — читайте только state. Код ошибки auth_required в спецификации есть, но при первом кадре, отличном от hello, адаптер шлёт bad_frame (см. Диагностику).

Карточки-вопросы

Ответ на карточку не становится сообщением в чате: адаптер резолвит его штатными механизмами Hermes (одобрение команды, подтверждение слэш-команды, уточнение).

kind Допустимые option_id Неизвестное значение →
exec_approval once, session, always, deny deny
slash_confirm once, always, cancel cancel
clarify c0, c1, … (по порядку вариантов), other запрос текста (other)

Для clarify вариант cN — «выбран N-й предложенный вариант» (нумерация с нуля), other — «пользователь введёт свой ответ»: приложение должно передать text.

Ответ валидируется на сервере. Клиент не может «подделать» вариант: любое значение вне таблицы заменяется на безопасное, а фактический выбор возвращается в prompt_done.option_id. Полагаться на клиент нельзя.

Карточка живёт timeout_seconds; по истечении приходит prompt_done с expired: true. Ответ на неизвестный/просроченный prompt_id не рвёт соединение — приходит prompt_done{expired:true}.

Медиа в обе стороны

Один кадр media — один кусок одного файла. Крупные файлы идут несколькими кадрами и собираются на принимающей стороне.

  • вверх: kind ∈ image/video/document/audio; ≤ 8 МиБ — одним кадром, крупнее — кусками по ~3 МиБ с постоянным id; серию нельзя обрывать.
  • вниз: kind ∈ image/voice/video/document/animation; собирайте data по id и сохраняйте при final: true; ориентируйтесь на mime/filename; если есть url — качайте по ссылке.

Если входящий файл больше 8 МиБ или превышен размер кадра — сервер отвечает error с кодом too_big; разбивайте на куски (part/parts) или сжимайте. Помните про лимиты памяти: ≤ 8 незавершённых загрузок на устройство, суммарный буфер ≤ 8 МиБ, parts ≤ 2048 (см. выше).

Жизненный цикл соединения и состояния аватара

connect → hello → ready ──► обмен: text/audio/media/prompt ⇄ message/media/prompt/avatar/typing
   ▲                                                          │
   │                                                          ▼
 обрыв/сон ── переподключение с паузой ─┘            typing{stop:true} → avatar:idle

Порядок кадров при типичном ходе: avatar:thinking (и typing) → message (1..N частей) → avatar:speaking → через паузу (typing idle, ~3 с) avatar:idle. Если в конце приходит typing{stop:true} — индикатор гасится немедленно, не дожидаясь таймаута.

Состояния аватара: thinking (агент обрабатывает), speaking (агент выдаёт ответ/медиа), idle (покой). Любое неизвестное состояние сервер заменяет на idle.

Идемпотентность по id

  • id в кадрах text / audio / media — ключ дедупликации. Повтор с тем же id (например, после обрыва сети) игнорируется: агент не получит сообщение дважды.
  • Ключ дедупликации — пара <chat_id>:<id>; окно хранения — около 600 секунд (буфер ~4000 записей).
  • Дубликат text/audio отбрасывается молча (без error); медиа проверяется на дубль по целому файлу, а не по куску.
  • Держите id постоянным для каждого сообщения и повторяйте его при переотправке.

Коды ошибок

Код Когда приходит Что делать приложению
bad_frame не JSON/не UTF-8/не объект; нет обязательного поля (text, data, prompt_id); пустое сообщение; повторный hello исправить кадр; фатальные — переподключиться
auth_failed неверный токен (или устройство вне MOBILE_ALLOWED_DEVICES); шлётся затем закрытие 1008 показать «нет доступа», не повторять
forbidden действие запрещено этому соединению: занят чужой device.id (токен привязан к другому устройству), ответ на карточку чужого устройства, либо deliver не от cron (не loopback и нет токена из MOBILE_DELIVER_TOKENS) не повторять как есть: проверить привязку токена/своего устройства; deliver шлёт только cron
unsupported_frame неизвестный тип кадра; неподдерживаемый format аудио или kind медиа обновить приложение
too_big превышен MAX_FRAME_BYTES / MAX_TEXT_CHARS / MAX_AUDIO_BYTES / MAX_MEDIA_BYTES разбить сообщение, слать файл кусками или сжать
device_offline доставка (deliver) на устройство, которого нет в сети повторить позже
auth_required объявлен в спецификации; текущий адаптер вместо него шлёт bad_frame при первом кадре, отличном от hello отправить hello первым
internal_error внутренняя ошибка адаптера (объявлен; используется редко) показать общий текст, переподключиться

Фатальные ошибки (неверный токен, занятый чужой device.id, превышение лимита кадра, hello-таймаут) сопровождаются закрытием соединения кодом 1008 (в reason — код ошибки).

Версионирование и обратная совместимость

  • Новые необязательные поля можно добавлять в любой момент — старые клиенты их игнорируют.
  • Новые типы кадров добавляются так же: клиент обязан игнорировать неизвестный t.
  • Несовместимые изменения — только сменой v в ready и в документации.
  • Все описанные здесь возможности (карточки-вопросы, медиа, правки message, typing.stop) — обратно совместимые дополнения, поэтому версия остаётся 1.