ADR 0109 — Handoff manual a um agente à escolha reusa oferta+aceite, nunca um terceiro estado de handoff
- Status: Aceito
- Data: 2026-08-23
- Contexto: fecha o item de backlog "Manual handoff to an agent of
choice" (
docs/explanation/backlog.md), cujo critério de ativação já estava escrito ali: "quando um agente FORA do pipeline fixo de handoff precisar ser alcançado por decisão humana — hoje é exatamente o caso do Staff (ADR 0088), alcançável só pela rota interna" - Precedente direto: ADR 0038
(regra de alvo do handoff —
assertHandoffTargetAllowed), ADR 0088 (o Staff, o caso real que motivou este ADR), ADR 0087 (UX Designer, achado endereçável pela mesma investigação)
Contexto
CreateHandoffUseCase é o único lugar do sistema que escreve toAgent (ADR
0038) — mas o único chamador dele era POST internal/sessions/:sessionId/handoffs,
uma rota só do engine. Todo handoff do produto hoje é criado por um AGENTE
chamando essa rota interna por iniciativa própria (o Criativo oferecendo ao
PO, o Arquiteto oferecendo à Infra/ao Dev Lead etc.). Não havia caminho para
um HUMANO, da tela de sessão, endereçar um handoff a um agente fora dessa
cadeia fixa.
O ADR 0088 nomeou a lacuna com precisão ao construir o Staff: "acionável
MANUALMENTE" queria dizer que a mecânica de domínio (ativação, validação de
alvo) já o permitia, mas "a UI genérica de 'handoff manual a um agente à
escolha' segue no backlog". O encanamento do Staff no engine
(StaffSupervisor, StaffServer, a cláusula "staff" em
agent_command_controller.ex) estava pronto e sem teste por nenhum caminho
real de usuário — a única forma de alcançá-lo era uma chamada HTTP crua à rota
interna.
A investigação antes de escrever qualquer código achou um segundo agente na
mesma situação: ux-designer (ADR 0087) também tem uma cláusula message/2
inteiramente ligada no engine (agent_command_controller.ex, confirmado
lendo o arquivo — a cláusula espelha exatamente a do Staff), e é igualmente
inalcançável a partir de SessionPage.tsx, que nunca o acrescentou a
AGENTES_DE_CHAT. Os dois fecham juntos.
Decisão
Nenhum terceiro estado de handoff. Um handoff manual é escrito pelo MESMO
CreateHandoffUseCase.execute que o offer_handoff do próprio agente já usa
— ele nasce offered, exatamente como um automático, e o fluxo de aceite
EXISTENTE (AcceptHandoffUseCase, a rota handoffs/:handoffId/accept, o
offeredHandoff/handleAcceptHandoff de SessionPage.tsx) o pega sem
mudança nenhuma. A única coisa nova é um segundo caminho de ESCRITA, voltado
ao humano. Dois motivos para isso ter sido escolhido em vez de inventar um
conceito distinto de manual_handoff:
- O lado da leitura nunca precisou saber quem decidiu.
handoff.offeredjá temactorno envelope do evento — um handoff manual só escreveactor: {kind: 'user', id: userId}em vez do padrão{kind: 'agent', id: fromAgent}queCreateHandoffUseCasefixava no código.CreateHandoffInputganhou um campo opcionalactor?: Actorexatamente para isso — compatível com o que existia, o chamador interno não o passa e mantém o comportamento antigo. - Duplicar a lógica de aceite/ativação para um segundo "tipo" de handoff
seria exatamente o antipadrão que o ADR 0086 já rejeitou uma vez (um
segundo mecanismo de suspensão em vez de reusar o existente). O card de
aceite em
SessionPage.tsxnão se importa com COMO um handoff foi oferecido — só que ele exista e mire um agente com quem a tela sabe conversar (AGENTES_DE_CHAT).
addressableAgents() — um catálogo mais estrito que assertHandoffTargetAllowed.
A regra de alvo existente (ADR 0038) só RECUSA um subagente; ela não tem
opinião sobre se toAgent é sequer um agente real e conhecido — um agente
citando "abc" como alvo passaria. Isso está bem para um agente, que só cita
agentes que já conhece pelas próprias instruções. NÃO está bem para um humano
escolhendo num dropdown, onde um cliente desatualizado ou uma requisição
feita à mão poderia mandar qualquer coisa. addressableAgents()
(apps/api/src/domain/agents/agent-areas.ts) é o conjunto fechado — leads de
área (AGENT_AREAS.map(a => a.lead)) ∪ SOLO_CONVERSATIONAL_AGENTS
(criativo, po, arquiteto, ux-designer, staff) — e
RequestManualHandoffUseCase confere a pertinência a ele ANTES de chamar
CreateHandoffUseCase.
SOLO_CONVERSATIONAL_AGENTS é uma lista nova, escrita à mão, no domínio da
api — não derivada de apps/web/src/lib/agents.ts (esse roster inclui também
agentes de gate como qa-automacao/secops e o Psicólogo/Anamnese, nenhum
deles endereçável por handoff) e não gerada por gerar:areas (que só cobriu
AGENT_AREAS). O lado web ganha a própria cópia espelhada à mão em
agents.ts, no mesmo arquivo cujo comentário já diz que ele "sobreviveu para
cor, ícone e a REGRA de endereçamento" — esta é exatamente essa regra. As
duas listas NÃO são conferidas uma contra a outra por teste hoje (isso
exigiria o mesmo mecanismo de gerador que a lista de áreas da Fase 18 tem);
uma deriva ali produz, no pior caso, uma opção velha num <select> que o
RequestManualHandoffUseCase do backend continua recusando com 400 — nunca um
handoff escrito errado.
O fromAgent de um handoff manual é derivado, não perguntado. O seletor
só pergunta o toAgent — pedir ao usuário que nomeie também um fromAgent
seria pedir que ele explicasse a contabilidade interna do engine.
RequestManualHandoffUseCase lê os eventos agent.activated da sessão
(SessionEventRepository.listByTypeInSession) e pega o mais recente, a mesma
regra "o mais recente por seq vence" que o activeAgent de
SessionPage.tsx já usa (achado 9-fix) — não existia antes, do lado do
servidor, consulta nenhuma de "agente corrente" da sessão, então este ADR
introduz a primeira, escopada a este caso de uso. Uma sessão ainda sem agente
ativado cai no sentinela "usuario" — nunca um nome de agente adivinhado, a
mesma regra de "nada de diagnóstico por eliminação" que a RN-059 já
estabeleceu para falhas de turno.
ux-designer e staff entraram os dois em AGENTES_DE_CHAT. Verificado
lendo agent_command_controller.ex antes de escrever qualquer código do web:
os dois têm uma cláusula message/2 de forma idêntica à de
po/dev-lead/arquiteto — nenhuma mudança de engine foi necessária para
nenhum deles. infra/qa continuam excluídos, sem mudança: são leads de área
sem kickoff/1 e sem cláusula message, o mesmo motivo documentado que o
comentário de AGENTES_DE_CHAT já dava para excluir a Infra.
Consequências
A favor
- O Staff (ADR 0088) e o UX Designer (ADR 0087) passam a ser alcançáveis pela tela de Sessão pela primeira vez — os dois tinham encanamento completo no engine, parado sem uso atrás de uma rota só interna.
- Zero semântica nova de handoff: a tabela
handoffs, a forma do eventohandoff.offerede o caminho de aceite/ativação ficam todos intocados. Um handoff manual e um automático são indistinguíveis para todo leitor existente, salvo peloactor.kind. - O catálogo endereçável é fechado e tipado, não um campo de texto livre —
RequestManualHandoffUseCaserecusa um slug desconhecido do mesmo jeito que recusa um subagente, os dois com 400 antes de qualquer escrita.
Contra, declarado
- Nenhum teste confere
SOLO_CONVERSATIONAL_AGENTSentre api e web. Um novo agente conversacional solo acrescentado à lista da api sem atualizar o espelho manual do web não reprova o CI — ele só não aparece como opção no seletor até alguém notar. Estendergerar:areas(Fase 18) para cobrir também esta lista é a correção, deixada para quando uma segunda deriva acontecer de fato (a mesma disciplina de "não refatorar o que está pronto" que o CLAUDE.md pede). - O seletor não oferece confirmação/prévia do que o agente-alvo fará com o
handoff. É um
<select>simples + botão, estilizado de propósito menor que o composer (.manualHandoffRow, intenção do ADR: "isto é redirecionamento, não conversa"). infra/qa/dev-<modulo>continuam inalcançáveis por este seletor.addressableAgents()inclui os leads deles (infra,qa,dev-lead) — eles SÃO endereçáveis pelo backend —, masAGENTES_DE_CHAT(a lista separada do web de "com quem esta tela consegue de fato conversar") não incluiinfra/qa, então um handoff a eles por este seletor criaria um handoffofferedque o card de aceite não consegue desenhar. Este ADR não corrige isso — é a mesma limitação que o ADR 0088 já declarava para o Staff antes desta funcionalidade, agora estreitada aos dois leads sem cláusulamessage.
Alternativas consideradas
Uma rota dedicada POST .../handoffs/manual, separada de um
POST .../handoffs genérico. Rejeitada: GET .../handoffs já é dono do
caminho handoffs puro, sem sufixo de verbo, e o Nest roteia por método HTTP,
não só por caminho — POST .../handoffs se lê como "criar um handoff" sem
precisar de qualificador, e o caminho de aceite
(.../handoffs/:handoffId/accept) já estabelece o padrão de sufixo de verbo
só onde acontece uma transição de estado.
Estender CreateHandoffInternalDto/a rota interna para aceitar um userId
e chamá-la direto do web. Rejeitada: a rota interna é classificada como
engine-service em docs/security-surface.md, autenticada pelo token de
serviço compartilhado, não pelo JWT de um usuário — rotear por ela uma
requisição iniciada por humano borraria a classificação que o
route-surface.spec.ts impõe, e pularia RequireRole('developer') inteiro.
Referências
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— acréscimo deactor?: 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— a linha fechada "Manual handoff to an agent of choice"