Pular para conteúdo

ADR-009 — Refinamento Técnico Iterativo de Tasks via Agente de Chat

Status: ✅ Aceito e Implementado Data: 2026-08-06 (proposto) — 2026-08-07 (decisão revisada, aceita e implementada)

Contexto

O agente de chat de manutenção de histórias (agent.work_items.story_editor, perfil analista de negócios) hoje edita descrição, regras de negócio, critérios de aceite e cenários de teste de features e stories, e tem controle total sobre tasks (criar/editar/reordenar/remover via update_work_items). No entanto, seu prompt é o de um Engenheiro de Requisitos, não o de um Líder Técnico — a decomposição técnica rigorosa de uma story em tasks hoje é responsabilidade exclusiva do agente agent.work_items.task_detailing, executado apenas de forma assíncrona (RabbitMQ → worker → SaveTasks em lote), acionado pela ação "Gerar Tasks" da iteração.

Este ADR avalia como habilitar refinamento técnico de tasks de forma iterativa, dentro do próprio chat, sem descartar o fluxo assíncrono existente (que processa múltiplas stories de uma iteração em paralelo — RN05 da story 2.2.6 — um caso de uso que o chat interativo, por natureza 1:1, não substitui).

Assimetria de contexto entre os dois agentes (achado central)

task_detailing não é apenas uma persona diferente — ele opera com uma estrutura fundamentalmente distinta: temperature=0.0 (determinístico), 13 seções de spec do projeto pré-carregadas no prompt pelo gerador orquestrador, contexto de stories/features vizinhas com um procedimento formal de dedup, taxonomia obrigatória de domínio ((Backend)/(Frontend)/...) e saída via schema JSON estruturado. story_editor não tem nada disso hoje: temperature=0.3, sem specs pré-carregadas, sem noção de stories vizinhas, saída em texto livre.

Esta lacuna motivou a avaliação de três alternativas (A, B, C — abaixo). A decisão final (ver "Revisão do desenho" e "Decisão") fecha essa lacuna por construção: em vez de replicar o contexto do especialista dentro do prompt do story_editor (o que exigiria duplicar 13 seções de spec, dedup de vizinhos e taxonomia em dois prompts), o story_editor passou a invocar o especialista real — o mesmo código de produção usado pelo fluxo assíncrono — através de uma tool determinística. Não há duplicação de contexto porque não há duplicação de especialista.

Alternativas avaliadas

# Opção Escopo de avaliação
A Evolução do prompt único existente (dupla persona no mesmo story_editor) Avaliada analiticamente
B Encadeamento/composição de agentes via padrão Supervisor/Worker (AgentFactory/AgentToolWrapper) Testada empiricamente — POC real executada (ver seção de Achados Empíricos)
C Roteamento condicional por intenção do usuário (novo branch em chat.py, agente dedicado) Avaliada analiticamente — descartada nesta forma, ver revisão abaixo
Dimensão (A) Prompt único (B) Composição de Agentes (C) Roteamento condicional (desenho original)
Componentes impactados amIA-PromptAsCode apenas amIA-Agents + amIA-PromptAsCode amIA-Agents + amIA-Portal
Esforço estimado Baixo Médio-Alto Médio
Latência medida/estimada Baixa (1 ciclo ReAct) 42,0s medidos (delegação simples) / 15,2s em falha rápida Baixa (1 invocação, sem round-trip duplo)
Custo/velocidade de deploy Mais rápido (sem redeploy do Agents) Mais lento (redeploy do Agents a cada mudança) Mais lento que A, mais simples que B
Bloqueador conhecido Nenhum Sim — ver achado abaixo Sim — ver revisão abaixo
Impacto no fluxo assíncrono Coexistência trivial Coexistência pontual (não substitui lote) Coexistência natural (RN04)

Revisão do desenho de (C) — a decisão precisa ser tomada dentro do agente

O desenho original de (C) propunha um roteamento condicional análogo ao já existente em chat_websocket_handler (que despacha para RagChatWithClientUseCase, ChatWithEpicEditorUseCase etc. de acordo com qual campo de escopo — document_id, client_id, epic_code — está presente no payload do WebSocket), complementado por uma ação dedicada no Portal ("Refinar Tasks").

Esse desenho está incorreto para o caso de uso real. O dispatch de chat.py decide uma vez por conexão/mensagem, com base em qual campo de escopo está no payload — não com base no que o usuário digitou. Um usuário que já está numa conversa aberta com o story_editor e simplesmente escreve "refina as tasks dessa story" não passa por nenhum roteamento novo: a mensagem chega ao mesmo Use Case de sempre (ChatWithEpicEditorUseCase), com o mesmo agente de sempre. Exigir uma ação dedicada no Portal para acionar o refinamento contradiz o próprio objetivo do ADR — habilitar refinamento dentro do chat já existente, por linguagem natural, não por um botão novo.

A decisão de "isso é um pedido de refinamento técnico de tasks" precisa ser tomada pelo próprio agente, a partir do seu raciocínio sobre a mensagem do usuário — exatamente como ele já decide hoje, por raciocínio, se deve chamar get_work_items ou update_work_items. Não há necessidade de nenhum roteamento externo, campo de payload novo, ou ação de UI dedicada.

Desenho corrigido: tool determinística decidida pelo agente

A correção substitui o roteamento externo de (C) por uma tool nova exposta ao story_editor, refine_story_tasks(epic_code, feature_code, story_code), que:

  • Reaproveita o código de produção já existente e já testado do fluxo assíncrono — WorkItemsGenerator.generate_tasks_for_story (mesmo objeto injetado em CreateStoryTasksUseCase) — sem duplicar prompt, contexto ou lógica de coleta de specs.
  • Dentro da própria função da tool (não do LLM), busca as 13 seções de spec e o contexto de stories/features vizinhas, chama o LLM do especialista (agent.work_items.task_detailing, temperature=0.0, saída estruturada StoryTasksResponse) e já persiste o resultado no Core via work_items_repository.replace_tasks, tudo numa única chamada síncrona.
  • É uma chamada de função determinística, não um agente aninhado: não tem checkpointer, não tem ciclo ReAct próprio, não faz round-trip de síntese — o story_editor chama a tool, recebe a lista de tasks já persistida como retorno de texto, e apresenta ao usuário em linguagem de negócio.

A decisão de quando chamar a tool e qual story ela deve gerar é inteiramente do agente, via prompt:

  • O story_editor já opera escopado a um épico (context_addon injetado por ChatWithEpicEditorUseCase com o código e título do épico) — não precisa perguntar isso.
  • Se o usuário informa o código da story diretamente, o agente usa-o.
  • Se o usuário menciona a story por título, o agente consulta get_work_items (tool já existente) para ler a árvore do épico e resolver o código pelo título.
  • Se a resolução for ambígua — nenhuma story compatível ou mais de uma com título parecido — o agente pergunta ao usuário qual story antes de chamar refine_story_tasks. Essa lógica de clarificação já existe no prompt do story_editor para outras operações (bloco <clarification_before_action>); foi estendida com um bloco <geracao_tecnica_de_tasks> dedicado.

Escopo restrito a geração inicial (correção pós-implementação). generate_tasks_for_story sempre substitui todas as tasks existentes da story (replace_tasks, não merge) — é assim que o fluxo assíncrono ("Gerar Tasks") sempre funcionou. Ao expor essa mesma função como tool de chat, surgiu um risco novo que não existia no fluxo em lote: frases como "revisa as tasks" ou "gera as tasks técnicas", ditas numa conversa já aberta, são ambíguas entre "ajuste pontual" (o que o usuário provavelmente quer) e "regeneração completa" (o que a tool de fato faz). Antes da tool existir, qualquer frase desse tipo era necessariamente tratada via update_work_items (aditivo/pontual); depois, o LLM passou a poder escolher a interpretação destrutiva para a mesma frase.

A mitigação adotada foi restringir o escopo declarado da tool — tanto no docstring Python quanto no prompt — a somente geração inicial: refine_story_tasks só deve ser chamada quando a story ainda não tem nenhuma task. Se a story já tiver tasks, o agente deve usar update_work_items para qualquer ajuste, e só chamar refine_story_tasks mediante confirmação explícita do usuário de que deseja substituir todo o conjunto existente. O agente verifica isso consultando get_work_items (campo tasks) antes de decidir. Isso elimina a ambiguidade pela raiz — decisão do próprio agente, sem necessidade de guarda de código ou parâmetro adicional na tool — mantendo o desenho "decisão dentro do agente" já estabelecido nesta ADR.

Limitação conhecida, registrada e não bloqueadora: get_work_items resolve por código, não por busca fuzzy — a "busca por título" é o próprio LLM lendo a árvore retornada e comparando texto. Funciona bem para títulos claramente distintos; pode falhar em títulos muito parecidos, caso em que o próprio agente deve perceber a ambiguidade e perguntar.

Nota sobre subagents nativos da Anthropic (Claude Code / Claude Agent SDK)

Durante a avaliação, foi levantada a hipótese de usar o mecanismo de subagents do Claude Code (documentado em code.claude.com/docs/en/sub-agents) como alternativa ao padrão Supervisor/Worker próprio do projeto. A investigação da documentação oficial mostra que isso não muda a recomendação:

  • Subagents do Claude Code são uma feature do harness do Claude Code / Claude Agent SDK — um produto e biblioteca distintos do que o AMia-Agents usa (LangChain/LangGraph consumindo a Messages API diretamente). Adotá-los exigiria migrar o framework do agente, não apenas trocar uma tool.
  • Mesmo nessa implementação nativa e mais madura, a estrutura é a mesma da Alternativa (B): "each subagent runs in its own context window with a custom system prompt... delegates to that subagent, which works independently and returns results" — um agente completo aninhado, não uma chamada de função leve.
  • A própria documentação recomenda contra subagents no cenário deste ADR: "Use the main conversation when: ... Latency matters. Subagents start fresh and may need time to gather context" e "the task needs frequent back-and-forth or iterative refinement" → conversa principal, não subagent.

Ou seja: nem o padrão Supervisor/Worker do projeto, nem o mecanismo nativo mais recente da própria Anthropic, evitam o custo estrutural de um agente aninhado. A tool determinística (chamada de função, não de agente) é o que efetivamente elimina esse custo — porque uma tool customizada é só código: nada na arquitetura da Messages API exige que uma "delegação a um especialista" seja implementada como um segundo agente completo.

Decisão

Adotar a tool determinística refine_story_tasks, exposta diretamente ao story_editor, como mecanismo de refinamento técnico interativo. A decisão de quando invocá-la e qual story ela deve refinar é feita pelo próprio agente, por raciocínio sobre a mensagem do usuário — sem roteamento externo, sem campo de payload novo, sem ação dedicada no Portal.

A Alternativa (B) — composição de agentes via Supervisor/Worker — não é recomendada, pelos mesmos motivos já identificados na POC (bloqueador de nomenclatura de tool e 42s de latência medida), reforçados pela nota acima sobre subagents nativos.

A Alternativa (A) isolada — evoluir apenas o prompt único, sem tool — foi descartada como caminho definitivo: force um único modelo/temperatura a servir dois perfis com exigências opostas e não reaproveita o especialista determinístico já validado em produção. Seu valor foi absorvido pela solução final: o próprio bloco de prompt que documenta e orienta o uso de refine_story_tasks é, em essência, a evolução incremental de (A) que o ADR original recomendava como medida de curto prazo — só que agora aponta para a tool real em vez de pedir ao story_editor para simular o especialista.

O fluxo assíncrono existente (StoryTasksGenerateEvent → task_detailing → SaveTasks) é mantido sem alterações — continua sendo o caminho para refinamento em lote de todas as stories de uma iteração. refine_story_tasks reaproveita o mesmo gerador (WorkItemsGenerator.generate_tasks_for_story), então qualquer melhoria futura no especialista (prompt, specs pré-carregadas, dedup de vizinhos) beneficia os dois caminhos automaticamente.

Achados Empíricos (POC — Alternativa B)

Uma POC real foi implementada e executada em AMia-Agents/docs/poc_2_2_12_refinamento_subagente.py, reaproveitando o código de produção real de AgentFactory.create_agent/AgentToolWrapper (nunca antes exercitado com um role_id real — todas as 17 configs de agente em produção são STANDALONE, nenhuma usa o padrão SUPERVISOR já existente no código desde sua criação).

Achado bloqueador: AgentFactory.create_agent usa o role_id do worker literalmente como nome da tool exposta ao LLM (agent_factory.py:112-116). Todos os role_ids de produção seguem a convenção agent.<domínio>.<papel> (com pontos). Um teste isolado de bind_tools com um nome de tool pontuado retornou:

BadRequestError 400: tools.0.custom.name: String should match pattern '^[a-zA-Z0-9_-]{1,128}$'

Ou seja, a composição Supervisor/Worker, como está codificada hoje, não funciona para nenhum worker cujo role_id siga a convenção de nomenclatura do projeto — é necessário sanitizar o nome da tool (ex.: substituir pontos por underscore) antes de expor qualquer agente como tool de outro. Contornando esse bloqueador com um alias sem pontos, a composição foi executada de ponta a ponta com sucesso contra uma story real do projeto (2.2.6), produzindo os seguintes resultados:

Aspecto testado Resultado
Latência ponta a ponta (delegação simples) 42,0s — 1 ciclo do supervisor + 1 invocação completa do worker (LLM/checkpointer próprios) + 1 ciclo de síntese do supervisor
Latência em falha (timeout de 1s no worker) 15,2s — worker falha rápido, mas o supervisor ainda gasta a maior parte do tempo decidindo delegar e redigindo a resposta de erro
Propagação de contexto Confirmada — via context_addon (mecanismo já usado em produção por ChatWithEpicEditorUseCase); a tool get_work_items real não foi exercitada nesta POC por falta de credencial de Core no ambiente local (CORE_API_MANUAL_TOKEN ausente)
Formato de saída para persistência Não diretamente utilizável — o supervisor sintetiza a resposta do worker como texto livre em linguagem de negócio, não como JSON estruturado compatível com update_work_items(inserts=[...]); exigiria um passo adicional de parsing ou saída estruturada forçada no worker
Comportamento em falha do worker O try/except de AgentToolWrapper captura a exceção e devolve uma string genérica ao supervisor (nunca propaga como erro de sistema); o supervisor tratou a falha com transparência ao usuário e ofereceu-se para gerar as tasks manualmente como fallback — degradação graciosa, mas com risco de especulação não verificada sobre a causa raiz do erro

Justificativa

Por que não (B): o bloqueador de nomenclatura é uma falha concreta e reprodutível, não teórica — sem corrigi-la, a alternativa simplesmente não executa. Mesmo corrigida, o custo de latência (42s medidos para uma única delegação) contraria diretamente o Critério de Aceite 5 (responsividade do chat) e RN07. A nota sobre subagents nativos confirma que esse custo é inerente a qualquer implementação de "agente aninhado" — inclusive a mais recente e madura da própria Anthropic — não uma limitação específica da implementação atual do projeto.

Por que não (C) na forma original: roteamento por campo de payload/ação dedicada de Portal não cobre o caso real — usuário pedindo refinamento por texto livre numa conversa já aberta. Qualquer critério de roteamento externo ao raciocínio do agente falha nesse cenário por construção.

Por que a tool determinística resolve isso: uma tool customizada é código comum — não um agente, não uma sessão, não uma thread. Ela pode internamente fazer qualquer chamada (incluindo uma segunda chamada de LLM, com seu próprio prompt e temperatura) sem herdar nenhum dos custos de um agente aninhado. Ao reaproveitar WorkItemsGenerator.generate_tasks_for_story — o mesmo código já validado em produção pelo fluxo assíncrono — a solução fecha também a "assimetria de contexto" identificada no início deste ADR sem duplicar prompt, specs ou lógica de dedup em dois lugares.

Consequências

Positivas: - O fluxo assíncrono existente permanece intacto — nenhum risco de regressão para usuários que já usam "Gerar Tasks" em lote. - Nenhuma duplicação de especialista: refine_story_tasks chama o mesmo WorkItemsGenerator que o fluxo assíncrono, então specs pré-carregadas, dedup de vizinhos e schema estruturado são idênticos nos dois caminhos. - Nenhuma mudança em chat.py, no Portal, ou no payload do WebSocket — o refinamento interativo funciona na conversa já existente, sem ação de UI nova. - O bloqueador de nomenclatura identificado na POC de (B) fica documentado para qualquer uso futuro do padrão Supervisor/Worker no projeto, mesmo não sendo usado aqui.

Negativas: - A resolução de story por título (quando o usuário não informa o código) depende do LLM comparando texto contra a árvore retornada por get_work_items — não é uma busca fuzzy garantida; títulos muito parecidos podem exigir uma pergunta de clarificação ao usuário. - A alternativa (B), embora não recomendada, permanece com o padrão Supervisor/Worker no código de produção sem cobertura de teste de integração real — apenas o teste unitário com mocks existente cobre esse caminho, e o bloqueador de nomenclatura não foi corrigido (não era escopo deste ADR). - O retorno da tool não informa quantas tasks existiam antes da chamada — a transparência sobre "isso substitui as tasks atuais" depende inteiramente do agente seguir a instrução do prompt (<geracao_tecnica_de_tasks>), não de uma confirmação forçada em código. Ficou registrado como possível reforço futuro (ver Recomendação). - Acionar generate_tasks_for_story também pelo chat, além do botão "Gerar Tasks" em lote, aumenta a superfície de exposição a uma corrida de escrita: se as duas vias forem disparadas para a mesma story em paralelo, a última chamada a replace_tasks vence silenciosamente. Risco pré-existente na função reaproveitada, agora com mais um caminho de acionamento.

Riscos mitigados: - Ao não adotar (B) nem a forma original de (C), evita-se tanto o custo de latência medido quanto uma mudança de UI/roteamento desnecessária para o caso de uso real. - Reaproveitar o gerador de produção existente, em vez de escrever um novo caminho de geração de tasks, elimina o risco de divergência de comportamento entre o refinamento em lote e o refinamento interativo. - Restringir o escopo da tool a "somente geração inicial" (story sem tasks) elimina o risco de uma frase ambígua do usuário ("revisa as tasks") ser interpretada como substituição destrutiva de um conjunto de tasks já existente e validado.

Implementação

Implementado em 2026-08-07, nos três repositórios:

AMia-Agents: - src/agents/infrastructure/agents/factories/project_tools_factory.py — nova tool refine_story_tasks(epic_code, feature_code, story_code), injetando IWorkItemsGenerator (mesmo padrão de dependência já usado por CreateStoryTasksUseCase) e chamando generate_tasks_for_story + list_tasks para reportar o resultado persistido. - src/agents/infrastructure/container.py — spec_tool_factory passa a receber work_items_generator; providers reordenados (work_items_generator → spec_tool_factory → spec_agent_factory) para respeitar a ordem de declaração do dependency_injector. - src/agents/infrastructure/agents/registry/definitions.py — refine_story_tasks adicionada a _WORK_ITEM_SPECIFIC_EDITOR_TOOLS (config do agent.work_items.story_editor). - locales/pt_BR.yml — chaves chat.refining_story_tasks, tools.story_tasks_refined, tools.story_tasks_refine_error, tools.story_tasks_refine_unavailable. - tests/unit/infrastructure/agents/test_agent_tools.py — TestRefineStoryTasksTool (5 casos: delegação e listagem de tasks persistidas, erro de domínio, 403, exceção genérica, generator não configurado).

AMia-PromptAsCode: - prompts/coding/{new_development,ongoing_development,migration}/agents/product_owner/work_items/story_editor/v1.md — documentação da tool no bloco <tools>, novo bloco <geracao_tecnica_de_tasks> com a restrição de escopo (somente story sem tasks) e a lógica de resolução/clarificação de story, referência cruzada em <constraints>, exemplo em <tool_examples>.

Todos os prompts validados via npm run validate (schema OK); testes unitários e de agent_factory executados sem regressão (falhas pré-existentes não relacionadas, de tools já removidas em refatoração anterior, seguem sem alteração).

Recomendação

  1. Monitorar em produção a taxa de acerto da resolução de story por título (sem código explícito) — se o LLM errar o código com frequência perceptível, considerar adicionar um parâmetro de busca textual a get_work_items (fuzzy match) em vez de depender só da leitura da árvore pelo agente.
  2. Se, mesmo com a restrição de escopo a "somente geração inicial", o LLM ocasionalmente chamar refine_story_tasks numa story que já tem tasks, considerar um reforço em código: a tool reportar explicitamente quantas tasks existiam antes da chamada (hoje o retorno só informa a contagem final), dando ao agente e ao usuário visibilidade de que uma substituição ocorreu.
  3. Antes de qualquer trabalho futuro que reative a Alternativa (B) para outro caso de uso: corrigir a sanitização de nomes de tool em AgentToolWrapper/AgentFactory.create_agent (bloqueador confirmado nesta POC) e reavaliar a latência de composição multi-agente com o fix aplicado — o achado é reaproveitável, mesmo não tendo sido usado aqui.
  4. Caso o produto decida, no futuro, expor "Refinar Tasks" também como uma ação explícita de UI no Portal (não apenas por texto livre no chat), ela pode ser implementada como uma chamada direta ao mesmo Use Case/tool — sem precisar reabrir a discussão de arquitetura deste ADR.