Двухфазная граница для голосового агента: модель предлагает, приложение коммитит

Голосовой агент с инструментами внешнего эффекта без явного согласия — та же боль, что у MCP-сервера или IDE-агента, которому вы открыли «опубликовать» или «удалить». В туториале Susie Wang разбирает TypeScript-паттерн для Tencent RTC Voice Companion: модель может подготовить действие, но закоммитить его может только свежее явное «да» пользователя — с state machine, короткоживущими tickets и drills перед релизом. Для соло-разработчика, который собирает voice- или agent-стек без отдельного backend-отдела, это переносимый приём human-in-the-loop на границе tool-calling.
Почему «гладкий голос» не заменяет границу authority
Сценарий из вводного примера: пользователь говорит «Tell the room I'll leave at eight—actually, don't send that». Модель может успеть сформировать аргументы tool до перебивания; без transaction boundary сообщение уходит в комнату, пока ассистент ещё озвучивает «Sure».
Автор формулирует тезис прямо: «A smooth voice is a presentation layer. Trust comes from the state machine behind it.» Разработческая ценность смещается от prompt polishing к authority, окну валидности, expiry и UX, когда certainty недостижима.
Для агентных архитектур — voice, Dify-workflow или MCP-tool с внешним эффектом — это не косметика: LLM и агент-платформа не должны получать прямое право коммитить consequential action.
Фаза prepare и фаза commit: путь без прямого commit из модели
Инвариант паттерна: вывод модели может подготовить действие, но только явное подтверждение пользователя может его закоммитить.
Фаза 1 (prepare / preview): распознанная речь → координатор хода приложения → LLM или Dify → валидированное предложение (MessageProposal) → серверный prepared ticket (PreparedAction) → озвученный preview («Post this message to the room: … Say yes to confirm or no to cancel.»).
Фаза 2 (confirm / commit): узкий классификатор подтверждения (не LLM) → повторная проверка прав (canPublish) → идемпотентный commit через ActionBroker.commit(ticket, context).
RTC audio → speech recognition → application turn coordinator → LLM or Dify
→ validated action proposal → server-side prepared ticket → spoken preview
→ explicit user confirmation → permission recheck → idempotent commit
Модель и Dify не получают credential для commit; переход commit принадлежит координатору приложения. Для OpenAI-compatible стека автор подчёркивает: commit(ticket) не должен быть tool, доступным модели.
Consequential tools: одно демо-действие и таблица эффектов
В туториале демонстрируется одно application-defined действие — publishRoomMessage (kind: 'publishRoomMessage', поля roomId, text). Конкретный сценарий в коде и тестах: сообщение «I will leave at eight.» в комнату room-1.
Таблица классификации эффектов задаёт политику по умолчанию:
| Эффект | Пример из статьи | Политика |
|---|---|---|
| Read-only | Search a public catalog | Allow with normal validation |
| Local and reversible | Change companion's temporary voice style | Allow or Undo |
| Shared and reversible | Add a track to a room queue | Confirm when social impact is meaningful |
| External or audience-visible | Publish a room message | Require explicit confirmation |
| Sensitive or difficult to reverse | Purchase, delete, invite, disclose private data | Strong confirmation or do not expose |
Публикация в комнату попадает в класс «external or audience-visible» — именно здесь паттерн двух фаз становится обязательным, а не опциональным UX-улучшением.
State machine, tickets и idempotency
Состояние описано discriminated union VoiceActionState: idle | requesting-model | previewing | awaiting-confirmation | executing | completed | recovery (причины recovery: expired, denied, provider-error, commit-uncertain).
Ключевые механизмы:
- Ticket —
randomUUID(), одноразовый идентификатор prepared action, не reusable credential для модели. - Срок жизни —
expiresAt = now + 30_000(30 секунд в коде примера). - Idempotency — ticket передаётся в
RoomPublisher.publish(..., { idempotencyKey: ticket }). - Повторный commit уже закоммиченного ticket — ранний return без дублирования эффекта.
При ошибке publish после executing записи переходят в uncertain; контроллер озвучивает, что результат не верифицирован и автоматический retry не выполняется.
Валидация proposal жёстко привязана к сессии: roomId сверяется с доверенным session context; текст сообщения после trim — от 1 до 280 символов; JSON от модели парсится как untrusted input.
Перебивание, ambiguous и граница Dify/OpenAI
Если пользователь начинает говорить во время previewing, speech.stop() останавливает озвучку, состояние → awaiting-confirmation; commit не происходит.
Подтверждение — только после транскрипта с классификацией yes / no / ambiguous. Фразы вроде yes, yes post it, confirm, send it против no, cancel, dont send it, do not send it. ambiguous не считается согласием — публикация не выполняется.
После старта внешнего commit прерывание не откатывает операцию; компенсация — отдельное авторизованное действие.
LlmAdapter.propose возвращает только proposal с allowedActions: readonly ['publishRoomMessage']. Один application-generated request ID на voice turn — для логов и маршрутизации. Для Dify: workflow может дать proposal, но не credential публикации. Инструкция модели: «Never claim an action completed» — приложение валидирует, подтверждает и исполняет.
Восемь failure drills перед релизом consequential tool
Раздел «Failure drills to run before release» перечисляет сценарии, которые автор считает обязательными до включения side-effect tool:
- LLM returns a different room ID — отклонить на prepare; scope только из аутентифицированной RTC-сессии.
- User loses permission after preview — recheck authorization at commit time.
- Speech recognition produces “yes” from background audio — узкая фраза подтверждения, корреляция с активным ticket, button fallback; для high-risk voice-only может быть недостаточно.
- Ticket expires while user is thinking — cancel; новый preview при сохранении намерения; не продлевать старый ticket молча.
- Model provider times out — recoverable conversational state; ticket не создаётся; retry с тем же turn correlation без дублирования prepared actions.
- Publish request times out after reaching server — uncertain; query by idempotency key если downstream поддерживает; иначе visible recovery choice, не автоповтор publish.
- RTC connection drops during confirmation — ticket истекает; reconnect не трактует старый transcript как новое согласие.
- Prompt injection asks model to skip confirmation — модель имеет только proposal authority; commit capability в коде недоступна модели.
Юнит-тесты (controller.test.ts) покрывают: interruption + «no» → published пуст; ambiguous speech → остаётся awaiting-confirmation; double «Yes, post it» → одна публикация, completed.
Tencent RTC: orchestration layer отдельно от media и model
Сценарий Tencent RTC Conversational AI описан как real-time voice interaction с подключением к нескольким LLM-провайдерам. Документация LLM-конфигурации покрывает OpenAI-compatible модели и агент-платформы вроде Dify, а также request identifiers для маршрутизации и observability.
Слои media, speech, model и application authorization остаются раздельными; код паттерна живёт в application orchestration layer и не подменяет RTC/messaging API. Интерфейсы (SpeechOutput, LlmAdapter, события onUserSpeechStarted, onTranscript) обозначены как integration seams — точная SDK-wiring зависит от клиентской платформы.
Автор указывает relationship disclosure: материал написан в связи с Tencent RTC; официальная документация Tencent RTC использована как implementation reference.
Чеклист и перенос на MCP и IDE-агенты
Перед релизом consequential tool автор предлагает двенадцать пунктов: parse proposal as untrusted; trusted session scope; model propose / app commit; single-purpose short-lived user-bound tickets; preview before confirm; ambiguous ≠ consent; barge-in без commit; permission recheck before execute; duplicate callbacks не дублируют эффект; timeouts → uncertain без выдуманного success/failure; коррелированные логи без лишнего хранения приватного аудио/текста; visible stop/cancel/confirm control.
Прямых сравнений с Cursor или MCP в первоисточнике нет — но логика «proposal vs commit» переносима на любой агентный стек, где LLM или workflow предлагает side-effect, а приложение держит state machine, tickets и human confirmation. Тот же принцип, что при проектировании MCP-tool boundary: модель видит инструмент, но commit с внешним эффектом остаётся на стороне приложения с явным consent.
Если вы собираете voice- или coding-agent workflow в одиночку, начните с drills №3, №4 и №8 — фоновое «yes», истёкший ticket и prompt injection — до полировки промпта.
Источники
- Susie Wang — Build a Two-Phase Tool Boundary for a Tencent RTC Voice Companion (Dev.to, 9 августа 2026 г.; оценочное время чтения по API площадки — 10 минут)