008 — Chat com agente conversacional (LLM + tools, leitura cross-context)
O casal abre o chat e pergunta, em PT-BR natural, coisas sobre o orçamento, metas e viabilidade. O agente — LLM (Gemini 2.5 Flash via OpenRouter) orquestrado pelo Vercel AI SDK — decide quais tools chamar pra ler o estado do domínio, recebe os resultados, e responde de volta em PT-BR.
As tools são wrappers finos sobre os aggregates já modelados (Budget, Goal, Household) e sobre o Domain Service FeasibilityCheck (planning). O agente não muta nada — só lê. Toda escrita continua sendo via os aggregates diretamente (cenários 000–005); o chat é interface de consulta.
O agente vive em src/contexts/agent/ — context capability-named (sem aggregate root próprio, igual planning/). AgentChat é um Domain Service stateless: recebe { messages, budget, goal, household, model } como input e devolve a próxima mensagem. Histórico de conversa fica fora do domínio (UX/persistência cuida).
Pra teste, o LLM é trocado pelo MockLanguageModelV1 do Vercel AI SDK — sem rede, sem token, sem flakiness. As asserções olham quais tools foram chamadas com quais args, não wording exato da resposta final (LLM real renderiza prosa; mock devolve o que a gente programou).
Scenario: Pergunta sobre o orçamento do mês
Section titled “Scenario: Pergunta sobre o orçamento do mês”- Given um Budget da casa com despesas
expectedsomando R$ 2.820 (Aluguel R$ 2.500 + Energia R$ 200 + Internet R$ 120) pra junho/2026 - And um
AgentChatconfigurado comBudgetToolapontando pra esse Budget - And o LLM mockado decide chamar
budgetTotal({ month: "2026-06" })e depois responde em PT-BR - When o casal envia a mensagem “quanto a gente tem de orçamento esse mês?”
- Then o agente chama
BudgetTool.budgetTotalcom{ month: "2026-06" } - And o resultado da tool é
{ expected: { amount: 2820, currency: "BRL" }, period: "2026-06" } - And a resposta final do agente contém o valor R$ 2.820 (renderizado pelo mock)
- And nenhuma outra tool foi chamada (GoalTool/FeasibilityTool ficam em zero)
Scenario: Pergunta sobre o ritmo da meta
Section titled “Scenario: Pergunta sobre o ritmo da meta”- Given a meta “Amsterdam Setembro/2026” target €6.000, startedOn 01/06/2026, deadline 01/09/2026, com Gabriel tendo aportado €500 em 30/07/2026
- And um
AgentChatconfigurado comGoalToolapontando pra essa meta - And o LLM mockado decide chamar
goalStatus({ name: "Amsterdam", today: "2026-08-01" })e depois responde em PT-BR - When o casal pergunta “e a meta de Amsterdam, como tá indo?”
- Then o agente chama
GoalTool.goalStatuscom{ name: "Amsterdam", today: "2026-08-01" } - And o resultado da tool inclui
requiredMonthly: { amount: 5500, currency: "EUR" },pace: { amount: 250, currency: "EUR" },onTrack: false,forecast: "2028-02-01" - And a resposta final menciona “não está no ritmo” /
onTrack=false(via wording que o mock controla) - And
BudgetTooleFeasibilityToolnão foram chamadas
Scenario: Pergunta cross-context sobre viabilidade
Section titled “Scenario: Pergunta cross-context sobre viabilidade”- Given o household “Casa” em BRL com renda total R$ 14.000 (Gabriel R$ 8.000 + esposa R$ 6.000)
- And um Budget com
expectedsomando R$ 5.000 - And a meta “Amsterdam Setembro/2026” target €6.000, deadline 01/09/2026, com
ExchangeRateEUR→BRL = 5,0 - And um
AgentChatconfigurado comFeasibilityToolque internamente chamaFeasibilityCheck.evaluate(planning) - And o LLM mockado decide chamar
feasibility({ goal: "Amsterdam", today: "2026-06-01", rate: { from: "EUR", to: "BRL", value: 5 } })e depois responde em PT-BR - When o casal pergunta “a gente consegue pagar Amsterdam até setembro?”
- Then o agente chama
FeasibilityTool.feasibilitycom os args acima - And o resultado da tool inclui
status: "tight",surplus: { amount: 9000, currency: "BRL" },requiredMonthly: { amount: 10000, currency: "BRL" },gap: { amount: 1000, currency: "BRL" } - And a resposta final do agente menciona status “tight” e o gap de R$ 1.000
Modelo
Section titled “Modelo”- Context novo —
agent/(sem aggregate root próprio; capability-named, igualplanning/). Orquestra LLM + tools que leem outros aggregates. - Domain Service —
AgentChat(stateless). Recebe input completo (mensagens + aggregates + model), devolve próxima mensagem. Histórico não vive no service. - Tools — wrappers finos sobre operações de leitura do domínio. Cada tool tem:
- schema de entrada (validação leve, ex:
month: stringno formato “YYYY-MM”), - função pura que extrai info do aggregate (
budget.actualTotal(...),goal.pace(...), etc), - schema de saída serializável (POJO — vira string JSON pro LLM consumir).
- schema de entrada (validação leve, ex:
- VO —
ChatMessage(role,content,toolCalls?,toolCallId?). Genérico o bastante pra cobrir user / assistant / tool-result. Equivalente ao shape do Vercel AI SDK (CoreMessage), mas tipado no domínio pra não vazar SDK pra cá.
type ChatRole = "user" | "assistant" | "tool";interface ChatMessage { role: ChatRole; content: string; toolCalls?: Array<{ id: string; name: string; args: unknown }>; toolCallId?: string;}
// src/contexts/agent/domain/tools/BudgetTool.tsBudgetTool.create({ budget: Budget }): BudgetToolbudgetTool.budgetTotal({ month: string /* "YYYY-MM" */ }): { expected: { amount: number; currency: string }; period: string }
// src/contexts/agent/domain/tools/GoalTool.tsGoalTool.create({ goal: Goal }): GoalToolgoalTool.goalStatus({ name: string; today: string /* "YYYY-MM-DD" */ }): { saved: { amount: number; currency: string }; requiredMonthly: { amount: number; currency: string }; pace: { amount: number; currency: string }; onTrack: boolean; forecast: string | null; }
// src/contexts/agent/domain/tools/FeasibilityTool.tsFeasibilityTool.create({ goal: Goal; household: Household; budget: Budget }): FeasibilityToolfeasibilityTool.feasibility({ goal: string; today: string; rate?: { from: string; to: string; value: number };}): { status: "feasible" | "tight" | "infeasible" | "indeterminate"; surplus: { amount: number; currency: string }; requiredMonthly: { amount: number; currency: string }; gap?: { amount: number; currency: string }; }
// src/contexts/agent/domain/AgentChat.tsAgentChat.create({ model: LanguageModelV1; // do Vercel AI SDK (real ou MockLanguageModelV1) tools: { budget?: BudgetTool; goal?: GoalTool; feasibility?: FeasibilityTool };}): AgentChat
agentChat.ask({ messages: ChatMessage[] }): Promise<{ reply: ChatMessage; // role "assistant", content é texto final toolCalls: Array<{ name: string; args: unknown; result: unknown }>;}>Decisões de design
Section titled “Decisões de design”AgentChatstateless, histórico passa como input — domínio não armazena conversa. Caller (UX/persistência futura) decide se persiste e como. Mesmo motivo deFeasibilityCheck.evaluate({ today }): sem clock implícito, sem estado escondido, fácil de testar.- Tools são wrappers finos, sem regra de negócio nova —
BudgetTool.budgetTotalchamabudget.actualTotal(period)(cenário 003) ouexpectedTotal; não soma nada por fora. Tool só traduz schema LLM-friendly ↔ chamada de domínio. - Schema de tool é POJO serializável — input/output viram JSON pro LLM.
Moneyno domínio vira{ amount, currency }na fronteira;Datevira string ISO. Conversão acontece na tool, não no aggregate. Mantém domínio agnóstico de LLM. Member/datas/moedas como string na fronteira — LLM não conheceMember.id(UUID). Tool aceitaname: stringe resolve internamente (lookup no aggregate). Reentrant: o LLM passa nomes humanos, a tool faz a tradução. Se name não bater, tool devolve erro structured pro LLM tentar de novo (escopo futuro, fora desse cenário).- Mock LLM é a única forma de testar no tier domain —
MockLanguageModelV1+simulateReadableStreamdo Vercel AI SDK. Spec roda em ms, determinístico. Tier e2e (Playwright + Next.js) também usa mock (ADR 002). Cassette/LLM real fica pra tier opt-in@realquando aparecer bug que o mock não pega. - Asserções no spec validam tool CALLS, não wording —
expect(mock.calls[0].name).toBe("budgetTotal"),expect(mock.calls[0].args.month).toBe("2026-06"). A resposta final é o que o mock devolveu (a gente controla). Wording natural fica pro LLM real renderizar em prod. - Não há “ChatSession” aggregate — não persiste histórico nesse cenário. Persistência da conversa (se vier) é cenário separado. Hoje: stateless, in-memory, aggregate refs passadas a cada chamada.
- Tool naming = camelCase ação (“budgetTotal”, “goalStatus”, “feasibility”) — combina com convenção do Vercel AI SDK e fica natural pro LLM chamar. Não força tradução PT pra nome de tool — domínio interno é PT/EN mix, mas a fronteira com LLM é EN (alinha com OpenAI/Anthropic function-calling defaults).
- Tools ficam em
agent/domain/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 (lê aggregate, formata POJO), sem I/O, sem rede.
Fora de escopo
Section titled “Fora de escopo”- Persistência de histórico de chat — cenário separado (não decidido ainda; provável extensão de 007).
- Multi-turn com follow-up complexo — assume LLM resolve tool-call em 1 round (mock devolve toolCall + texto final). Multi-round (tool → tool → texto) entra quando aparecer cenário pedindo.
- Streaming visível no spec — domain spec testa
ask({ messages })que resolve uma vez. Streaming é detalhe da UI/SDK; e2e testa via Playwright. - Mutações via chat — “registra aluguel R$ 2.500” pelo chat é cenário futuro. Hoje: read-only.
- Tool error handling — name não encontrado, args inválidos, fallback. Fica pro impl agent decidir; spec só cobre happy path.
- Suporte a múltiplos Budgets/Goals na mesma conversa — assume um de cada por chat. Multi-aggregate selector entra depois.
- Memory/context window management — Vercel AI SDK + Gemini Flash cuidam disso. Domínio não decide truncation.
- Function-calling parallel — assume single tool per turn no mock. Paralelo entra com cenário real.
Próximo passo
Section titled “Próximo passo”Criar context agent/ com AgentChat, BudgetTool, GoalTool, FeasibilityTool, ChatMessage. Reusar MockLanguageModelV1 do Vercel AI SDK (ai/test) no spec domain. Tier e2e (Playwright) vem depois, dirigindo a UI com mock LLM gated por NODE_ENV=test (ADR 002).