Skip to content

017 — Goal criada via conversa (write tool propose → confirm, idempotente)

O casal não quer abrir o app, achar o botão “Nova meta”, preencher 5 campos. Quer dizer no chat: “quero criar uma meta de viagem pra europa, €6000 até junho 2028, eu e Bruno contribuímos” — e pronto. O agente extrai os parâmetros, mostra preview, e persiste com o OK do casal.

Esse cenário completa a família de write tools iniciada no 013. Hoje existem RecordSpendTool, RegisterExpenseTool, ContributeToGoalTool, AdjustExpenseTool — todas mutam aggregates já existentes. Faltava o passo zero: criar a meta no chat. Com CreateGoalTool, o ciclo fica fechado: agora o casal cria e contribui inteiramente via conversa.

Mesma mecânica do 013:

  1. Propose → confirm. Primeira call retorna ToolReceipt com preview (o que aconteceria), confirmed: false, persisted: false. Casal lê, confirma. LLM chama de novo com confirm: true + mesmo operationIdGoalRepository.save.
  2. Idempotência por operationId. Mesma op com mesmo id + confirm: true chamada de novo vira no-op (confirmed: true, persisted: false).

Resolução de contributors (nomes humanos → Member.id) usa o Household injetado no construtor: household.members.find(m => m.name === name). Se algum nome não bater, a tool lança erro descritivo — nenhum Goal é criado nem persistido.

Tier domain testa com MockLanguageModelV1 + simulateReadableStream (ADR 001) e InMemoryGoalRepository (cenário 010). Sem rede, sem token.

Scenario: LLM extrai parâmetros e propõe a criação (turn 1 = preview)

Section titled “Scenario: LLM extrai parâmetros e propõe a criação (turn 1 = preview)”
  • Given o Household “Ana e Bruno” em BRL com Ana e Bruno como members
  • And um InMemoryGoalRepository vazio (goalRepository.list() retorna [])
  • And um AgentChat configurado com CreateGoalTool apontando pro GoalRepository + Household + now: () => 2026-06-01
  • And o LLM mockado, no primeiro turno, decide chamar createGoal({ name: "Lua de mel", target: { amount: 6000, currency: "EUR" }, deadline: "2028-06-01", contributors: ["Ana", "Bruno"], operationId: "op-1" }) (sem confirm)
  • When o casal envia “quero criar uma meta de €6000 pra junho 2028, Lua de mel, eu e Bruno”
  • Then o agente chama CreateGoalTool.createGoal com os args acima
  • And o resultado é um ToolReceipt com operationId: "op-1", confirmed: false, persisted: false
  • And preview é { name: "Lua de mel", target: "EUR 6000", deadline: "2028-06-01", contributors: ["Ana", "Bruno"], requiredMonthly: "EUR 250.00" } (24 meses entre now e deadline → €6000/24)
  • And goalRepository.list() continua vazio (preview não muta)
  • And goalRepository.save não foi chamado

Scenario: Casal confirma — segundo turn persiste

Section titled “Scenario: Casal confirma — segundo turn persiste”
  • Given o setup do scenario 1 + preview já mostrado no turno 1
  • And o LLM mockado, no segundo turno, decide chamar createGoal({ ..., operationId: "op-1", confirm: true }) (mesmos args + confirm: true)
  • When o casal envia “sim, pode criar”
  • Then o agente chama CreateGoalTool.createGoal de novo com confirm: true
  • And o resultado é um ToolReceipt com operationId: "op-1", confirmed: true, persisted: true, mesmo preview do turno 1
  • And goalRepository.save foi chamado uma vez com a Goal nova
  • And goalRepository.list().length === 1
  • And a Goal persistida (repo.load(<id>)) tem nome “Lua de mel”, target €6.000, deadline 01/06/2028, members Ana e Bruno (mesmas referências que vieram do Household — equals por id estável)

Scenario: Idempotência — terceira call com mesmo operationId é no-op

Section titled “Scenario: Idempotência — terceira call com mesmo operationId é no-op”
  • Given o setup do scenario 2 (Goal “Lua de mel” já persistida via operationId: "op-1")
  • And o LLM mockado decide chamar createGoal({ ..., operationId: "op-1", confirm: true }) de novo (mesmo id, mesmo args)
  • When o casal envia “cria a meta de novo” (retry de turno ou bug do LLM)
  • Then a tool detecta operationId: "op-1" já confirmado e retorna o receipt anterior (confirmed: true, persisted: false)
  • And goalRepository.save não foi chamado de novo (já tinha sido na call anterior)
  • And goalRepository.list().length permanece 1 (sem segunda Goal duplicada)

Scenario: Contributor desconhecido → erro descritivo, nenhum side-effect

Section titled “Scenario: Contributor desconhecido → erro descritivo, nenhum side-effect”
  • Given o Household “Ana e Bruno” (members: Ana, Bruno)
  • And um InMemoryGoalRepository vazio
  • And o LLM mockado decide chamar createGoal({ name: "Casa nova", target: { amount: 100000, currency: "BRL" }, deadline: "2030-01-01", contributors: ["Xyz"], operationId: "op-err" })
  • When a tool é executada
  • Then a tool lança erro descritivo do tipo Member "Xyz" não existe no Household — registre primeiro (mensagem contém o name desconhecido)
  • And goalRepository.save não foi chamado
  • And goalRepository.list() continua vazio
  • And nenhuma Goal foi persistida nem fica em estado “metade criada”

Scenario: Goal sem deadline — requiredMonthly omitido do preview

Section titled “Scenario: Goal sem deadline — requiredMonthly omitido do preview”
  • Given o setup do scenario 1
  • And o LLM mockado decide chamar createGoal({ name: "Reserva", target: { amount: 200000, currency: "BRL" }, contributors: ["Ana", "Bruno"], operationId: "op-3" }) (sem deadline, sem confirm)
  • When o agente executa a tool no turno 1
  • Then o resultado é um ToolReceipt com confirmed: false, persisted: false
  • And preview.name === "Reserva", preview.target === "BRL 200000", preview.contributors === ["Ana", "Bruno"]
  • And preview.deadline é undefined (sem prazo)
  • And preview.requiredMonthly é undefined (sem deadline → sem cálculo de aporte mensal)
  • And goalRepository.save não foi chamado
  • Write tool novaCreateGoalTool em agent/domain/tools/, junto com RecordSpendTool/ContributeToGoalTool/etc do 013. Mesma fronteira: schema LLM-friendly EN, conversão Money/Date na tool, repository injetado via construtor.
  • ConstrutorCreateGoalTool.create({ goalRepo: GoalRepository, household: Household, now?: () => Date }). Household é a fonte de verdade pros names dos contributors (decisão A do task: simplicidade > abstração até aparecer caso multi-household). now opcional pra cálculo de requiredMonthly no preview (default () => new Date()); mesmo padrão de injeção do adapter HTTP do 011.
  • Schema do tool (fronteira LLM, EN, POJO):
    createGoal({
    name: string, // "Lua de mel"
    target: { amount: number; currency: string }, // {amount:6000, currency:"EUR"} — POJO, não Money
    deadline?: string, // ISO "YYYY-MM-DD" — opcional
    contributors: string[], // ["Ana", "Bruno"] — nomes humanos
    operationId: string, // idempotência
    confirm?: boolean, // default false
    }): Promise<ToolReceipt<GoalPreview>>
  • GoalPreview (POJO, stringly-typed na fronteira LLM — gotcha “WorkingMemory.activeGoals[].target” do 012):
    interface GoalPreview {
    name: string;
    target: string; // "EUR 6000"
    deadline?: string; // "2028-06-01" — só se input tinha deadline
    contributors: string[]; // ["Ana", "Bruno"]
    requiredMonthly?: string; // "EUR 250.00" — só se deadline presente
    }
  • Resolução de contributorshousehold.members.find(m => m.name === name) por nome. Se algum não bater, lança erro antes de tocar o repo (nada salvo em estado inválido).
  • Idempotência — mesma mecânica do 013: tool guarda Map<operationId, ToolReceipt> interno. Mesmo id + confirm: true na segunda call → retorna receipt anterior com persisted: false.
src/contexts/agent/domain/tools/CreateGoalTool.ts
CreateGoalTool.create({
goalRepo: GoalRepository;
household: Household;
now?: () => Date;
}): CreateGoalTool
createGoalTool.createGoal(args: {
name: string;
target: { amount: number; currency: string };
deadline?: string;
contributors: string[];
operationId: string;
confirm?: boolean;
}): Promise<ToolReceipt>
// preview shape (dentro de ToolReceipt.preview)
interface GoalPreview {
name: string;
target: string; // "EUR 6000"
deadline?: string;
contributors: string[];
requiredMonthly?: string;
}
// src/contexts/agent/domain/AgentChat.ts (estende 008/013)
AgentChat.create({
model: LanguageModelV1;
tools: {
// ... read (008) + write (013)
createGoal?: CreateGoalTool;
};
}): AgentChat
  • Tool resolve contributors via Household injetado, não por HouseholdLookup port — opção A do task: simplicidade > abstração. Adapter pra multi-household entra quando aparecer caso (“casal X mora em duas casas/famílias”). Mesma filosofia do ExchangeRate injetado e do RecordSpendTool({ budgetRepo, budgetId }) do 013.
  • requiredMonthly no preview é stringly-typed ("EUR 250.00") — gotcha “Stringly-typed na fronteira LLM” do 012: preview é input pro LLM consumir e renderizar pro casal, não objeto de domain code. Conversão Money → string acontece na tool. Mesmo motivo de WorkingMemory.activeGoals[].target = "EUR 6000".
  • requiredMonthly omitido quando não há deadline — meta sem prazo (cenário “reserva sem deadline”) é válida no domínio (002 documenta deadline como obrigatório, mas o tool pode aceitar ausente: cálculo de aporte mensal vira undefined no preview). Documentado explicitamente pra o impl não confundir.
  • Erro estoura, não vira receipt — contributor desconhecido lança exception descritiva (string contendo o name que não bateu). Não retorna ToolReceipt com error?: string — happy path do receipt assume args válidos; validação de nomes é pré-condição. Se aparecer caso “LLM precisa do erro estruturado pra tentar de novo”, evolui pra ToolReceipt.error? cross-tool, não inventa shape específico aqui.
  • Sem today no schema do toolgoalStatus (008) aceita today: string porque a query é “no momento X”. createGoal não — a meta acabou de ser criada, requiredMonthly no preview usa now() injetado (clock testável). LLM não precisa decidir um “today” pra criar meta.
  • Goal id é gerado pela tool (UUID via Goal.create) — não vem do LLM. Reusa o overload UUID default do 010. LLM só passa operationId (que é da operação, não do aggregate). Se quiser referenciar a meta depois, busca por nome (gotcha 008: “UUID não vaza pra fronteira LLM”).
  • Goal vira persistida só com confirm: true — mesma regra do 013: write tool nunca persiste na primeira chamada. UX seguro: meta criada por engano é classe inteira de bug (“ué, quem mandou criar essa meta?”).
  • Edição/remoção de GoalupdateGoal, deleteGoal, archiveGoal ficam pra cenários separados quando aparecer caso real. Goal criada nesse cenário fica perpétua até o domínio ganhar verbo de “encerrar meta”.
  • Contributor com nome ambíguo (“Ana de Belo Horizonte vs Ana de SP” no mesmo Household) — happy path assume nomes únicos por Household. Quando aparecer caso real, vira Member.id na fronteira LLM ou disambiguation prompt.
  • Sugestão de meta — “olha, vocês têm sobra de R$ 1.500/mês, tal meta seria viável” — outro cenário (cross-context com FeasibilityCheck, planning).
  • Multi-tool transactions — “cria a meta E aporta R$ 500” no mesmo turno são duas tools separadas, cada uma com seu receipt + confirm. Sem transaction atômica cross-aggregate (gotcha 007).
  • Preview rico (FeasibilityCheck no preview) — preview hoje é só “isso vai ser criado”. Não roda FeasibilityCheck automaticamente pra dizer “essa meta é viável dado seu orçamento”. Cenário separado se aparecer demanda.
  • Member novo via chat — “cria meta com Ana, Bruno e Carla” onde Carla não existe ainda. Hoje: erro. Cenário futuro pode adicionar createMember write tool ou inferência (gotcha “Verbos do cenário revelam a Entity”).
  • Operations log persistido — receipts ficam em memória da tool. Mesma limitação documentada no 013. Recarregar o app perde o log de duplicatas.

Implementar CreateGoalTool em src/contexts/agent/domain/tools/. Estender AgentChat.create({tools}) pra aceitar createGoal? opcional. Reusar InMemoryGoalRepository (cenário 010) e Household.create + addMember (cenário 005) nos specs. Tier e2e (Playwright) dirige o fluxo “casal pede meta no chat → confirma” com LLM mockado.