Skip to content

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:

  1. Propose → confirm. Por default, a primeira chamada da write tool não persiste — devolve um ToolReceipt com preview (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 com confirm: true (e o mesmo operationId) → a mutação roda de verdade e repo.save é chamado.
  2. 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 via BudgetRepository.save

  • And Mercado.actualFor(junho/2026) ainda é R$ 0 (nenhum gasto registrado)

  • And um AgentChat configurado com RecordSpendTool apontando pro BudgetRepository

  • And o LLM mockado, no primeiro turno, decide chamar recordSpend({ expense: "Mercado", period: "2026-06", amount: { amount: 200, currency: "BRL" }, operationId: "op-1" }) (sem confirm)

  • When o casal envia “registra R$ 200 a mais no mercado essa semana”

  • Then o agente chama RecordSpendTool.recordSpend com os args acima

  • And o resultado da tool é um ToolReceipt com operationId: "op-1", confirmed: false, persisted: false, preview: { expense: "Mercado", period: "2026-06", delta: { amount: 200, currency: "BRL" }, newActual: { amount: 200, currency: "BRL" } }

  • And repo.save nã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.recordSpend de novo com confirm: true

  • And o resultado é um ToolReceipt com operationId: "op-1", confirmed: true, persisted: true, mesmo preview

  • And repo.save foi chamado uma vez com o Budget mutado

  • And recarregar o orçamento via repo.load(budgetId) mostra Mercado.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 AgentChat configurado com ContributeToGoalTool apontando pro GoalRepository

  • 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.contributeToGoal com os args acima

  • And o resultado é um ToolReceipt com confirmed: false, persisted: false, e preview: { goal: "Amsterdam", paidBy: "Gabriel", amount: { amount: 500, currency: "BRL" }, date: "2026-06-15", newSavedByCurrency: { BRL: { amount: 500, currency: "BRL" } } }

  • And goalRepo.save nã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.save foi chamado uma vez

  • And recarregando a meta via goalRepo.load(goalId), savedByCurrency().get("BRL") é R$ 500

  • And o paidBy da contribuição é o Member Gabriel (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-1 já 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.load não foi chamado nesse turno (no-op total, nem reabre o aggregate)
  • And repo.save não foi chamado de novo
  • And Mercado.actualFor(junho/2026) continua R$ 200 (não vira R$ 400)
  • 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 passa InMemoryBudgetRepository / InMemoryGoalRepository; prod passa adapter SQLite (cenário 007).

  • Tool não cria aggregate — assume aggregate já existe e tem id estável (cenário 007 promoveu Budget.id; cenário 010 planejado promove Goal.id/Household.id/Account.id). O id vem 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 args
    preview: Record<string, unknown>; // shape específico por tool
    confirmed: boolean; // true se o LLM passou confirm:true
    persisted: 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 OperationLog port).

    Spec domain valida o comportamento (mesmo id → no-op, repo.save uma vez), não a impl. Impl escolhe.

src/contexts/agent/domain/tools/ToolReceipt.ts
interface ToolReceipt {
operationId: string;
preview: Record<string, unknown>;
confirmed: boolean;
persisted: boolean;
}
// src/contexts/agent/domain/tools/RecordSpendTool.ts
RecordSpendTool.create({ budgetRepo: BudgetRepository; budgetId: string }): RecordSpendTool
recordSpendTool.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.ts
RegisterExpenseTool.create({ budgetRepo: BudgetRepository; budgetId: string }): RegisterExpenseTool
registerExpenseTool.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.ts
ContributeToGoalTool.create({ goalRepo: GoalRepository; goalId: string }): ContributeToGoalTool
contributeToGoalTool.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.ts
AdjustExpenseTool.create({ budgetRepo: BudgetRepository; budgetId: string }): AdjustExpenseTool
adjustExpenseTool.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;
};
}): AgentChat
  • 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: true no 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.
  • operationId vem 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 confirmed de persistedconfirmed: 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 existe confirmed: 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 que agent/domain/tools/ importa interfaces (BudgetRepository, GoalRepository) que vivem em budget/application/ e goals/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 toolsRecordSpendTool({expense: "Mercado"}) faz budget.findByName("Mercado") internamente, igual GoalTool.goalStatus({name}) em 008. UUID não vaza pra fronteira LLM (gotcha 008).
  • Tool não decide o que escrever — só traduzRecordSpendTool chama expense.addToActual(period, money) (cenário 004) ou recordSpend(period, money) (cenário 003): qual? addToActual pra “registra R$ 200 a mais” (incremental). recordSpend substitui o registro; addToActual soma. Tool escolhe baseado no schema (amount é delta, não absoluto). Documentado explicitamente pra o impl não confundir.
  • Member.name chega da fronteira LLM como stringContributeToGoalTool({paidBy: "Gabriel"}) resolve internamente via goal.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 opcionaisAgentChat.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.
  • Tool error handlingexpense não encontrado, paidBy não casa, args inválidos, repo.save falha. 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 operationId chega 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.
  • ToolReceipt persistido 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.

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.