Перейти к основному содержимому

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:

  1. Стороне чтения никогда не нужно было знать, кто решил. handoff.offered уже имеет actor в конверте события — ручной handoff лишь пишет actor: {kind: 'user', id: userId} вместо значения по умолчанию {kind: 'agent', id: fromAgent}, которое CreateHandoffUseCase фиксировал в коде. CreateHandoffInput получил необязательное поле actor?: Actor ровно для этого — совместимо с существовавшим, внутренний вызывающий его не передаёт и сохраняет прежнее поведение.
  2. Дублировать логику принятия/активации для второго «типа» 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 им через этот селектор создал бы handoff offered, который карточка принятия не может отрисовать. Этот 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_AGENTS
  • apps/api/src/application/use-cases/agents/request-manual-handoff.use-case.ts
  • apps/api/src/application/use-cases/agents/create-handoff.use-case.ts — добавление actor?: Actor
  • apps/api/src/interfaces/http/agents/agents.controller.ts — POST .../handoffs
  • apps/web/src/lib/agents.ts — addressableAgents, SOLO_CONVERSATIONAL_AGENTS
  • apps/web/src/routes/SessionPage.tsx — AGENTES_DE_CHAT, .manualHandoffRow
  • docs/business-rules.md — RN-440
  • docs/explanation/backlog.md — закрытая строка «Manual handoff to an agent of choice»