Skip to content

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 expected somando R$ 2.820 (Aluguel R$ 2.500 + Energia R$ 200 + Internet R$ 120) pra junho/2026
  • And um AgentChat configurado com BudgetTool apontando 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.budgetTotal com { 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)
  • 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 AgentChat configurado com GoalTool apontando 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.goalStatus com { 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 BudgetTool e FeasibilityTool nã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 expected somando R$ 5.000
  • And a meta “Amsterdam Setembro/2026” target €6.000, deadline 01/09/2026, com ExchangeRate EUR→BRL = 5,0
  • And um AgentChat configurado com FeasibilityTool que internamente chama FeasibilityCheck.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.feasibility com 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
  • Context novoagent/ (sem aggregate root próprio; capability-named, igual planning/). Orquestra LLM + tools que leem outros aggregates.
  • Domain ServiceAgentChat (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: string no 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).
  • VOChatMessage (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á.
src/contexts/agent/domain/ChatMessage.ts
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.ts
BudgetTool.create({ budget: Budget }): BudgetTool
budgetTool.budgetTotal({ month: string /* "YYYY-MM" */ }):
{ expected: { amount: number; currency: string }; period: string }
// src/contexts/agent/domain/tools/GoalTool.ts
GoalTool.create({ goal: Goal }): GoalTool
goalTool.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.ts
FeasibilityTool.create({ goal: Goal; household: Household; budget: Budget }): FeasibilityTool
feasibilityTool.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.ts
AgentChat.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 }>;
}>
  • AgentChat stateless, histórico passa como input — domínio não armazena conversa. Caller (UX/persistência futura) decide se persiste e como. Mesmo motivo de FeasibilityCheck.evaluate({ today }): sem clock implícito, sem estado escondido, fácil de testar.
  • Tools são wrappers finos, sem regra de negócio novaBudgetTool.budgetTotal chama budget.actualTotal(period) (cenário 003) ou expectedTotal; 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. Money no domínio vira { amount, currency } na fronteira; Date vira 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 conhece Member.id (UUID). Tool aceita name: string e 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 domainMockLanguageModelV1 + simulateReadableStream do 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 @real quando aparecer bug que o mock não pega.
  • Asserções no spec validam tool CALLS, não wordingexpect(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.
  • 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.

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).