ADR 0109 — Ручной handoff агенту по выбору переиспользует предложение+принятие, а не третье состояние handoff
- Статус: Принят
- Дата: 2026-08-23
- Контекст: закрывает пункт backlog «Manual handoff to an agent of
choice» (
docs/explanation/backlog.md), чей критерий активации там уже был записан: «когда агента ВНЕ фиксированного конвейера handoff нужно будет достичь по человеческому решению — сегодня это ровно случай Staff (ADR 0088), достижимого только через внутренний маршрут» - Прямой прецедент: ADR 0038
(правило цели handoff —
assertHandoffTargetAllowed), ADR 0088 (Staff, реальный случай, мотивировавший этот ADR), ADR 0087 (UX Designer, находка, решаемая тем же исследованием)
Контекст
CreateHandoffUseCase — единственное место системы, записывающее toAgent
(ADR 0038), — но его единственным вызывающим был
POST internal/sessions/:sessionId/handoffs, маршрут только для engine.
Каждый handoff продукта сегодня создаётся АГЕНТОМ, вызывающим этот внутренний
маршрут по собственной инициативе (Criativo, предлагающий PO, Arquiteto,
предлагающий Infra/Dev Lead и т. д.). Не было пути, чтобы ЧЕЛОВЕК с экрана
сессии адресовал handoff агенту вне этой фиксированной цепочки.
ADR 0088 точно назвал пробел, строя Staff: «вызываемый ВРУЧНУЮ» означало, что
доменная механика (активация, проверка цели) уже это позволяла, но «общий UI
"ручного handoff агенту по выбору" остаётся в backlog». Обвязка Staff в
engine (StaffSupervisor, StaffServer, клауза "staff" в
agent_command_controller.ex) была готова и не протестирована ни одним
реальным путём пользователя — единственным способом до неё добраться был
сырой HTTP-вызов внутреннего маршрута.
Исследование до написания какого-либо кода нашло второго агента в той же
ситуации: у ux-designer (ADR 0087) тоже есть клауза message/2, полностью
подключённая в engine (agent_command_controller.ex, подтверждено чтением
файла — клауза точно зеркалит клаузу Staff), и он так же недостижим из
SessionPage.tsx, который никогда не добавлял его в AGENTES_DE_CHAT. Оба
закрываются вместе.
Решение
Никакого третьего состояния handoff. Ручной handoff записывается ТЕМ ЖЕ
CreateHandoffUseCase.execute, которым уже пользуется offer_handoff самого
агента, — он рождается offered, ровно как автоматический, и СУЩЕСТВУЮЩИЙ
поток принятия (AcceptHandoffUseCase, маршрут
handoffs/:handoffId/accept, offeredHandoff/handleAcceptHandoff в
SessionPage.tsx) подхватывает его без каких-либо изменений. Единственное
новое — второй путь ЗАПИСИ, обращённый к человеку. Две причины, по которым это
выбрано вместо изобретения отдельного понятия manual_handoff:
- Стороне чтения никогда не нужно было знать, кто решил.
handoff.offeredуже имеетactorв конверте события — ручной handoff лишь пишетactor: {kind: 'user', id: userId}вместо значения по умолчанию{kind: 'agent', id: fromAgent}, котороеCreateHandoffUseCaseфиксировал в коде.CreateHandoffInputполучил необязательное полеactor?: Actorровно для этого — совместимо с существовавшим, внутренний вызывающий его не передаёт и сохраняет прежнее поведение. - Дублировать логику принятия/активации для второго «типа» handoff было бы
ровно тем антипаттерном, который ADR 0086 уже однажды отверг (второй
механизм приостановки вместо переиспользования существующего). Карточку
принятия в
SessionPage.tsxне волнует, КАК handoff был предложен, — лишь то, что он существует и нацелен на агента, с которым экран умеет разговаривать (AGENTES_DE_CHAT).
addressableAgents() — каталог строже, чем assertHandoffTargetAllowed.
Существующее правило цели (ADR 0038) лишь ОТКЛОНЯЕТ субагента; у него нет
мнения о том, является ли toAgent вообще реальным, известным агентом, —
агент, назвавший целью «abc», прошёл бы. Это нормально для агента, который
называет лишь агентов, уже известных ему из собственных инструкций. Это НЕ
нормально для человека, выбирающего из dropdown, где устаревший клиент или
запрос, собранный вручную, могли бы прислать что угодно.
addressableAgents() (apps/api/src/domain/agents/agent-areas.ts) — закрытое
множество: leads областей (AGENT_AREAS.map(a => a.lead)) ∪
SOLO_CONVERSATIONAL_AGENTS (criativo, po, arquiteto, ux-designer,
staff), — и RequestManualHandoffUseCase проверяет принадлежность ему ДО
вызова CreateHandoffUseCase.
SOLO_CONVERSATIONAL_AGENTS — новый список, написанный вручную, в домене api
— не выведенный из apps/web/src/lib/agents.ts (этот состав включает также
агентов gate вроде qa-automacao/secops и Psicólogo/Anamnese, ни один из
которых не адресуем через handoff) и не генерируемый gerar:areas (который
покрывал только AGENT_AREAS). Сторона web получает собственную вручную
отражённую копию в agents.ts, в том же файле, чей комментарий уже говорит,
что он «выжил ради цвета, значка и ПРАВИЛА адресации», — это ровно то правило.
Два списка сегодня НЕ сверяются друг с другом тестом (это потребовало бы того
же механизма генератора, что есть у списка областей Фазы 18); дрейф там в
худшем случае даёт устаревшую опцию в <select>, которую
RequestManualHandoffUseCase бэкенда по-прежнему отклоняет с 400, — никогда
неправильно записанный handoff.
fromAgent ручного handoff выводится, а не спрашивается. Селектор
спрашивает только toAgent — просить пользователя назвать ещё и fromAgent
значило бы просить его объяснить внутреннюю бухгалтерию engine.
RequestManualHandoffUseCase читает события agent.activated сессии
(SessionEventRepository.listByTypeInSession) и берёт самое свежее, то же
правило «побеждает самое свежее по seq», которое уже использует
activeAgent в SessionPage.tsx (находка 9-fix), — на стороне сервера раньше
не существовало ни одного запроса «текущего агента» сессии, так что этот ADR
вводит первый, ограниченный этим use case. Сессия без активированного агента
сводится к сентинелу "usuario" — никогда к угаданному имени агента, то же
правило «никакой диагностики методом исключения», которое RN-059 уже
установила для отказов хода.
ux-designer и staff оба вошли в AGENTES_DE_CHAT. Проверено чтением
agent_command_controller.ex до написания какого-либо кода web: у обоих есть
клауза message/2 той же формы, что у po/dev-lead/arquiteto, — ни для
одного из них изменений engine не потребовалось. infra/qa остаются
исключёнными, без изменений: это leads областей без kickoff/1 и без клаузы
message, по той же задокументированной причине, которую комментарий
AGENTES_DE_CHAT уже давал для исключения Infra.
Последствия
За
- Staff (ADR 0088) и UX Designer (ADR 0087) впервые становятся достижимыми с экрана Сессии — у обоих была полная обвязка в engine, простаивавшая без использования за маршрутом только для внутреннего пользования.
- Ноль новой семантики handoff: таблица
handoffs, форма событияhandoff.offeredи путь принятия/активации остаются нетронутыми. Ручной и автоматический handoff неразличимы для каждого существующего читателя, за исключениемactor.kind. - Адресуемый каталог закрыт и типизирован, а не свободное текстовое поле —
RequestManualHandoffUseCaseотклоняет неизвестный slug так же, как отклоняет субагента, оба с 400 до какой-либо записи.
Против, объявлено
- Ни один тест не сверяет
SOLO_CONVERSATIONAL_AGENTSмежду api и web. Новый одиночный разговорный агент, добавленный в список api без обновления ручного зеркала web, не проваливает CI — он просто не появляется опцией в селекторе, пока кто-нибудь не заметит. Расширениеgerar:areas(Фаза 18), чтобы покрыть и этот список, — исправление, оставленное на тот момент, когда второй дрейф действительно произойдёт (та же дисциплина «не рефакторить готовое», которой требует CLAUDE.md). - Селектор не предлагает подтверждения/предпросмотра того, что
агент-цель сделает с handoff. Это простой
<select>+ кнопка, намеренно стилизованные меньше composer (.manualHandoffRow, намерение ADR: «это перенаправление, а не разговор»). infra/qa/dev-<modulo>остаются недостижимыми этим селектором.addressableAgents()включает их leads (infra,qa,dev-lead) — они АДРЕСУЕМЫ бэкендом, — ноAGENTES_DE_CHAT(отдельный список web «с кем этот экран действительно может разговаривать») не включаетinfra/qa, так что handoff им через этот селектор создал бы handoffoffered, который карточка принятия не может отрисовать. Этот ADR этого не исправляет — это то же ограничение, которое ADR 0088 уже объявлял для Staff до этой функциональности, теперь суженное до двух leads без клаузыmessage.
Рассмотренные альтернативы
Выделенный маршрут POST .../handoffs/manual, отдельный от общего
POST .../handoffs. Отвергнуто: GET .../handoffs уже владеет голым путём
handoffs, без суффикса-глагола, а Nest маршрутизирует по HTTP-методу, а не
только по пути, — POST .../handoffs читается как «создать handoff» без
нужды в уточнителе, а путь принятия (.../handoffs/:handoffId/accept) уже
устанавливает образец суффикса-глагола только там, где происходит переход
состояния.
Расширить CreateHandoffInternalDto/внутренний маршрут, чтобы он принимал
userId, и вызывать его прямо из web. Отвергнуто: внутренний маршрут
классифицирован как engine-service в docs/security-surface.md,
аутентифицирован общим сервисным токеном, а не JWT пользователя, —
маршрутизация через него запроса, инициированного человеком, размыла бы
классификацию, которую навязывает route-surface.spec.ts, и целиком
пропустила бы RequireRole('developer').
Ссылки
apps/api/src/domain/agents/agent-areas.ts—addressableAgents,SOLO_CONVERSATIONAL_AGENTSapps/api/src/application/use-cases/agents/request-manual-handoff.use-case.tsapps/api/src/application/use-cases/agents/create-handoff.use-case.ts— добавлениеactor?: Actorapps/api/src/interfaces/http/agents/agents.controller.ts—POST .../handoffsapps/web/src/lib/agents.ts—addressableAgents,SOLO_CONVERSATIONAL_AGENTSapps/web/src/routes/SessionPage.tsx—AGENTES_DE_CHAT,.manualHandoffRowdocs/business-rules.md— RN-440docs/explanation/backlog.md— закрытая строка «Manual handoff to an agent of choice»