013 — Agente escreve no domínio via write tools (propose → confirm, idempotente)
O casal não quer abrir o app, achar o expense, clicar em “registrar gasto”. Quer dizer pro chat: “registra R$ 200 a mais no mercado essa semana” — e pronto. Ou “aporta R$ 500 na meta de Amsterdam esse mês”. O agente vira a porta de entrada também pra escrita, não só consulta (cenário 008).
Pra isso o agente ganha write tools — wrappers finos sobre métodos de mutação dos aggregates (Budget.register, RecurringExpense.addToActual, Goal.contribute, Budget.adjustAmount), com persistência via repositories (cenários 007 + 010 planejado). A regra de “tools são wrappers finos sobre aggregates, sem regra de negócio nova” do cenário 008 continua valendo: a tool só carrega o aggregate via repo, chama o método de domínio, e salva de volta.
Duas garantias novas vivem na fronteira tool ↔ chat:
- Propose → confirm. Por default, a primeira chamada da write tool não persiste — devolve um
ToolReceiptcompreview(o que aconteceria),confirmed: false,persisted: false. O casal lê, vê “ah, vou debitar R$ 500 na meta — confirma?”, e dá OK. Aí o LLM chama a mesma tool comconfirm: true(e o mesmooperationId) → a mutação roda de verdade erepo.saveé chamado. - Idempotência por
operationId. Chamadas repetidas com o mesmo id viram no-op no segundo run: a tool detecta a operação já confirmada, retorna o receipt anterior, não chama o domínio nem o repo de novo. Mecanismo (in-memory log de operations, persistência) fica a critério do impl.
Tier domain testa as duas regras com MockLanguageModelV1 (ADR 001) e InMemoryBudgetRepository (cenário 007). Sem rede, sem token, ms por spec.
Scenario: Registrar gasto a mais no mercado — propose → confirm
Section titled “Scenario: Registrar gasto a mais no mercado — propose → confirm”-
Given um orçamento da casa em BRL com Mercado (expected R$ 1.500, aliases
["mercado"]) registrado e persistido viaBudgetRepository.save -
And
Mercado.actualFor(junho/2026)ainda é R$ 0 (nenhum gasto registrado) -
And um
AgentChatconfigurado comRecordSpendToolapontando proBudgetRepository -
And o LLM mockado, no primeiro turno, decide chamar
recordSpend({ expense: "Mercado", period: "2026-06", amount: { amount: 200, currency: "BRL" }, operationId: "op-1" })(semconfirm) -
When o casal envia “registra R$ 200 a mais no mercado essa semana”
-
Then o agente chama
RecordSpendTool.recordSpendcom os args acima -
And o resultado da tool é um
ToolReceiptcomoperationId: "op-1",confirmed: false,persisted: false,preview: { expense: "Mercado", period: "2026-06", delta: { amount: 200, currency: "BRL" }, newActual: { amount: 200, currency: "BRL" } } -
And
repo.savenão foi chamado nesse turno -
And
Mercado.actualFor(junho/2026)continua R$ 0 no aggregate em memória do repo (preview não muta) -
Given o casal responde “confirma” no chat
-
And o LLM mockado decide chamar
recordSpend({ ..., operationId: "op-1", confirm: true })(mesmo id, agora confirmado) -
When o casal envia “confirma”
-
Then o agente chama
RecordSpendTool.recordSpendde novo comconfirm: true -
And o resultado é um
ToolReceiptcomoperationId: "op-1",confirmed: true,persisted: true, mesmopreview -
And
repo.savefoi chamado uma vez com o Budget mutado -
And recarregar o orçamento via
repo.load(budgetId)mostraMercado.actualFor(junho/2026)= R$ 200
Scenario: Aportar na meta de Amsterdam — propose → confirm
Section titled “Scenario: Aportar na meta de Amsterdam — propose → confirm”-
Given a meta “Amsterdam Setembro/2026” criada em 01/06/2026, target €6.000, deadline 01/09/2026, com Gabriel e esposa como members
-
And a meta persistida via
GoalRepository.save -
And um
AgentChatconfigurado comContributeToGoalToolapontando proGoalRepository -
And o LLM mockado decide chamar
contributeToGoal({ goal: "Amsterdam", paidBy: "Gabriel", amount: { amount: 500, currency: "BRL" }, date: "2026-06-15", operationId: "op-2" }) -
When o casal envia “adiciona meta de R$ 500 esse mês pra Amsterdam, eu paguei”
-
Then o agente chama
ContributeToGoalTool.contributeToGoalcom os args acima -
And o resultado é um
ToolReceiptcomconfirmed: false,persisted: false, epreview: { goal: "Amsterdam", paidBy: "Gabriel", amount: { amount: 500, currency: "BRL" }, date: "2026-06-15", newSavedByCurrency: { BRL: { amount: 500, currency: "BRL" } } } -
And
goalRepo.savenão foi chamado -
Given o casal confirma
-
And o LLM mockado decide chamar
contributeToGoal({ ..., operationId: "op-2", confirm: true }) -
When o casal envia “pode mandar”
-
Then
goalRepo.savefoi chamado uma vez -
And recarregando a meta via
goalRepo.load(goalId),savedByCurrency().get("BRL")é R$ 500 -
And o
paidByda contribuição é oMemberGabriel (lookup interno pelo name — fronteira EN, gotcha 008)
Scenario: Chamada duplicada com mesmo operationId é idempotente
Section titled “Scenario: Chamada duplicada com mesmo operationId é idempotente”- Given o mesmo orçamento da Casa, com a operação
op-1já confirmada (Mercado R$ 200 em junho/2026 persistido) - And o LLM mockado decide chamar
recordSpend({ expense: "Mercado", period: "2026-06", amount: { amount: 200, currency: "BRL" }, operationId: "op-1", confirm: true })de novo (mesmo id) - When o casal envia “registra os R$ 200 do mercado de novo” (cenário sintético — retry de turno, ou bug do LLM)
- Then a tool detecta
operationId: "op-1"já confirmado e retorna o receipt anterior (confirmed: true,persisted: true) - And
repo.loadnão foi chamado nesse turno (no-op total, nem reabre o aggregate) - And
repo.savenão foi chamado de novo - And
Mercado.actualFor(junho/2026)continua R$ 200 (não vira R$ 400)
Modelo
Section titled “Modelo”-
Write tools moram em
agent/domain/tools/— mesmo lugar dos read tools (gotcha 008: “tools fazem parte do domínio do agente, são a linguagem que o LLM fala com o domínio”). Não viram adapter de infraestrutura: lógica pura (load → mutate → save), repository é injetado como port. -
Cada write tool recebe via construtor o(s) repository(s) que precisa — port-style injection.
RecordSpendTool.create({ budgetRepo, budgetId }),ContributeToGoalTool.create({ goalRepo, goalId }), etc. Spec domain passaInMemoryBudgetRepository/InMemoryGoalRepository; prod passa adapter SQLite (cenário 007). -
Tool não cria aggregate — assume aggregate já existe e tem
idestável (cenário 007 promoveuBudget.id; cenário 010 planejado promoveGoal.id/Household.id/Account.id). Oidvem do wiring (impl agent escolhe qual aggregate “ativo” o chat está mutando — provavelmente um por context por household). -
ToolReceipt— POJO de saída comum a todas as write tools:interface ToolReceipt {operationId: string; // dado pelo LLM no argspreview: Record<string, unknown>; // shape específico por toolconfirmed: boolean; // true se o LLM passou confirm:truepersisted: boolean; // true se rolou repo.save (= confirmed && primeira vez)} -
Operations log — onde a tool guarda receipts confirmados pra detectar duplicata. Pode ser:
- in-memory
Map<operationId, ToolReceipt>dentro da tool (mais simples, perde no reload); - persistido junto com o aggregate (mais robusto, mais ceremônia);
- delegado pro caller (passa um
OperationLogport).
Spec domain valida o comportamento (mesmo id → no-op, repo.save uma vez), não a impl. Impl escolhe.
- in-memory
interface ToolReceipt { operationId: string; preview: Record<string, unknown>; confirmed: boolean; persisted: boolean;}
// src/contexts/agent/domain/tools/RecordSpendTool.tsRecordSpendTool.create({ budgetRepo: BudgetRepository; budgetId: string }): RecordSpendToolrecordSpendTool.recordSpend(args: { expense: string; // ExpenseName, fronteira LLM em string period: string; // "YYYY-MM" amount: { amount: number; currency: string }; operationId: string; confirm?: boolean; // default false}): Promise<ToolReceipt>
// src/contexts/agent/domain/tools/RegisterExpenseTool.tsRegisterExpenseTool.create({ budgetRepo: BudgetRepository; budgetId: string }): RegisterExpenseToolregisterExpenseTool.registerExpense(args: { nome: string; expected: { amount: number; currency: string }; categoria: string; vencimento: number; // BillingDay 1-31 pagamento: string; operationId: string; confirm?: boolean;}): Promise<ToolReceipt>
// src/contexts/agent/domain/tools/ContributeToGoalTool.tsContributeToGoalTool.create({ goalRepo: GoalRepository; goalId: string }): ContributeToGoalToolcontributeToGoalTool.contributeToGoal(args: { goal: string; // nome — fronteira EN paidBy: string; // Member.name — fronteira EN, lookup interno amount: { amount: number; currency: string }; date: string; // ISO YYYY-MM-DD operationId: string; confirm?: boolean;}): Promise<ToolReceipt>
// src/contexts/agent/domain/tools/AdjustExpenseTool.tsAdjustExpenseTool.create({ budgetRepo: BudgetRepository; budgetId: string }): AdjustExpenseTooladjustExpenseTool.adjustExpense(args: { expense: string; // ExpenseName — lookup interno por nome newExpected: { amount: number; currency: string }; operationId: string; confirm?: boolean;}): Promise<ToolReceipt>
// src/contexts/agent/domain/AgentChat.ts (estende 008)AgentChat.create({ model: LanguageModelV1; tools: { // read (008) budget?: BudgetTool; goal?: GoalTool; feasibility?: FeasibilityTool; // write (013) recordSpend?: RecordSpendTool; registerExpense?: RegisterExpenseTool; contributeToGoal?: ContributeToGoalTool; adjustExpense?: AdjustExpenseTool; // 009 importInvoice?: ImportInvoiceTool; };}): AgentChatDecisões de design
Section titled “Decisões de design”- Propose → confirm é default, não opt-in — agente que muta domínio sem ack do casal é classe inteira de bug (“ué, quem mandou registrar isso?”). A primeira chamada sempre retorna preview; só com
confirm: trueno args persiste. Documentado como gotcha potencial: UX precisa renderizar preview de forma legível (“vou debitar R$ 500 da Amsterdam, confirma?”) — se a UX só joga JSON na tela, o “amigável” some. Trade-off aceito pela segurança; UX seguro vence sobre UX amigável até aparecer caso real de fricção. operationIdvem do LLM, não do agente — cada tool-call decide seu próprio id (LLM gera no schema). Idempotência fica observável pelo casal: “essa op tem id X”. O risco é o LLM passar ids diferentes em retries — aceito como trade-off vs gerar id no agente (que perderia o anchor entre turnos quando o LLM precisa repetir o id pra confirmar).- Idempotência é por
operationId, não por hash dos args — duas operações iguais com ids diferentes rodam duas vezes (intencional: “registra R$ 200 no mercado de novo” é cenário válido — segunda compra do mês). O id é o handle explícito do LLM pra dizer “isso é a mesma operação”. - Receipt distingue
confirmeddepersisted—confirmed: true, persisted: falseé o caso da idempotência (segunda chamada com mesmo id já confirmado: foi confirmado, mas não foi persistido dessa vez).confirmed: true, persisted: trueé o primeiro confirm.confirmed: false, persisted: falseé o preview. Não existeconfirmed: false, persisted: true— domínio fica em modo coerente. - Tool carrega aggregate via
repo.load(id)cada call — não cacheia. Garante que a mutação parte do estado mais recente (outra tool pode ter salvado no meio). Custo é OK porque o tier domain usa fake in-memory e o tier prod usa SQLite síncrono local. - Repository é port em
<ctx>/application/, write tool importa o port — domain não importa de application: regra mantida apontando queagent/domain/tools/importa interfaces (BudgetRepository,GoalRepository) que vivem embudget/application/egoals/application/(paralelo ao 007). Agent context atravessa fronteira de port mas não de adapter. Inversão de dependência é o padrão: domínio define a interface, infra implementa. - Lookup por nome continua válido pra write tools —
RecordSpendTool({expense: "Mercado"})fazbudget.findByName("Mercado")internamente, igualGoalTool.goalStatus({name})em 008. UUID não vaza pra fronteira LLM (gotcha 008). - Tool não decide o que escrever — só traduz —
RecordSpendToolchamaexpense.addToActual(period, money)(cenário 004) ourecordSpend(period, money)(cenário 003): qual?addToActualpra “registra R$ 200 a mais” (incremental).recordSpendsubstitui o registro;addToActualsoma. Tool escolhe baseado no schema (amounté delta, não absoluto). Documentado explicitamente pra o impl não confundir. Member.namechega da fronteira LLM como string —ContributeToGoalTool({paidBy: "Gabriel"})resolve internamente viagoal.members.find(m => m.name === "Gabriel"). Se name não bater, tool devolve erro structured (escopo futuro — happy path assume nome casa).- AgentChat aceita repositories opcionais —
AgentChat.create({model, tools: {recordSpend?, contributeToGoal?}}). Caller (impl agent) decide quais habilita por sessão. Read-only chat habilita só read tools; chat administrativo habilita tudo.
Fora de escopo
Section titled “Fora de escopo”- Tool error handling —
expensenão encontrado,paidBynão casa, args inválidos,repo.savefalha. Happy path assume tudo casa. Fica pro impl agent decidir como tratar (cenário separado quando aparecer). - Confirmação parcial / cancelamento — casal diz “não, deixa pra lá” depois do preview. Hoje: tool só age com
confirm: true; sem confirm = preview fica pendente até nova call. Limpeza/expiração de previews é detalhe de impl. - Multi-tool transactions — “registra esse gasto E aporta na meta” no mesmo turno são duas tools separadas, cada uma com seu receipt. Sem transação atômica cross-aggregate (gotcha cenário 007: “transactions cross-aggregate fora do escopo”).
- Audit log de quem mutou — receipt fica em memória da tool. Persistência de receipts (pra auditoria, “quem registrou esse gasto, quando?”) é cenário futuro.
- Edição/remoção — não tem
removeExpense,removeContribution,undo. Add-only happy path por enquanto. - Rollback automático em duplicata diferente — se mesmo
operationIdchega com args diferentes, hoje a tool retorna o receipt original (ignora os args novos). Não é uma colisão maliciosa esperada; documentar caso vire dor. ToolReceiptpersistido junto com aggregate — hoje fica em memória da tool. Quando o app recarregar, log some. OK enquanto o casal opera dentro de uma sessão. Persistência do receipt log entra com cenário próprio.
Próximo passo
Section titled “Próximo passo”Implementar RecordSpendTool, RegisterExpenseTool, ContributeToGoalTool, AdjustExpenseTool + ToolReceipt em src/contexts/agent/domain/tools/. Estender AgentChat.create({tools}) pra aceitar as novas tools opcionalmente. Reusar InMemoryBudgetRepository (cenário 007) e InMemoryGoalRepository (cenário 010 — quando implementado) nos specs domain. Tier e2e (Playwright) dirige o fluxo propose → confirm via UI mockada com LLM determinístico.