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:
- Propose → confirm. Primeira call retorna
ToolReceiptcompreview(o que aconteceria),confirmed: false,persisted: false. Casal lê, confirma. LLM chama de novo comconfirm: true+ mesmooperationId→GoalRepository.save. - Idempotência por
operationId. Mesma op com mesmo id +confirm: truechamada 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
InMemoryGoalRepositoryvazio (goalRepository.list()retorna[]) - And um
AgentChatconfigurado comCreateGoalToolapontando proGoalRepository+ 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" })(semconfirm) - When o casal envia “quero criar uma meta de €6000 pra junho 2028, Lua de mel, eu e Bruno”
- Then o agente chama
CreateGoalTool.createGoalcom os args acima - And o resultado é um
ToolReceiptcomoperationId: "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 entrenowe deadline → €6000/24) - And
goalRepository.list()continua vazio (preview não muta) - And
goalRepository.savenã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.createGoalde novo comconfirm: true - And o resultado é um
ToolReceiptcomoperationId: "op-1",confirmed: true,persisted: true, mesmopreviewdo turno 1 - And
goalRepository.savefoi chamado uma vez com a Goal nova - And
goalRepository.list().length === 1 - And a Goal persistida (
repo.load(<id>)) temnome“Lua de mel”,target€6.000,deadline01/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.savenão foi chamado de novo (já tinha sido na call anterior) - And
goalRepository.list().lengthpermanece 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
InMemoryGoalRepositoryvazio - 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.savenã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" })(semdeadline, semconfirm) - When o agente executa a tool no turno 1
- Then o resultado é um
ToolReceiptcomconfirmed: 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.savenão foi chamado
Modelo
Section titled “Modelo”- Write tool nova —
CreateGoalToolemagent/domain/tools/, junto comRecordSpendTool/ContributeToGoalTool/etc do 013. Mesma fronteira: schema LLM-friendly EN, conversãoMoney/Datena tool, repository injetado via construtor. - Construtor —
CreateGoalTool.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).nowopcional pra cálculo derequiredMonthlyno 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 Moneydeadline?: string, // ISO "YYYY-MM-DD" — opcionalcontributors: string[], // ["Ana", "Bruno"] — nomes humanosoperationId: string, // idempotênciaconfirm?: 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 deadlinecontributors: string[]; // ["Ana", "Bruno"]requiredMonthly?: string; // "EUR 250.00" — só se deadline presente}- Resolução de contributors —
household.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: truena segunda call → retorna receipt anterior compersisted: false.
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; };}): AgentChatDecisões de design
Section titled “Decisões de design”- Tool resolve contributors via
Householdinjetado, não porHouseholdLookupport — 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 doExchangeRateinjetado e doRecordSpendTool({ budgetRepo, budgetId })do 013. requiredMonthlyno 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ãoMoney→ string acontece na tool. Mesmo motivo deWorkingMemory.activeGoals[].target = "EUR 6000".requiredMonthlyomitido 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 viraundefinedno 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
ToolReceiptcomerror?: 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 praToolReceipt.error?cross-tool, não inventa shape específico aqui. - Sem
todayno schema do tool —goalStatus(008) aceitatoday: stringporque a query é “no momento X”.createGoalnão — a meta acabou de ser criada,requiredMonthlyno preview usanow()injetado (clock testável). LLM não precisa decidir um “today” pra criar meta. - Goal
idé gerado pela tool (UUID viaGoal.create) — não vem do LLM. Reusa o overload UUID default do 010. LLM só passaoperationId(que é da operação, não do aggregate). Se quiser referenciar a meta depois, busca pornome(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?”).
Fora de escopo
Section titled “Fora de escopo”- Edição/remoção de Goal —
updateGoal,deleteGoal,archiveGoalficam 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.idna 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
FeasibilityCheckautomaticamente 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
createMemberwrite 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.
Próximo passo
Section titled “Próximo passo”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.