Skip to content

012 — Chat persistido + retrieval (memória do agente — conversation history + working memory)

O casal abre o chat na noite de segunda, conta que vai juntar pra Amsterdam em setembro e que ganham juntos R$ 14.000 por mês. Fecha o app. Quarta de manhã, volta no chat e pergunta “e a meta de Amsterdam, lembra?”. O agente lembra: sabe o nome da meta, o target (€6.000), a deadline (01/09/2026), e que a renda do casal é R$ 14.000 — sem o casal ter que reexplicar nada. Não precisa rolar pra trás na conversa, não precisa colar contexto manualmente.

Essa é a fronteira que o cenário 008 (AgentChat stateless, histórico passa como input) deixou em aberto. ADR 004 escolheu a arquitetura: @mastra/memory + @mastra/libsql, injetado via DI no AgentChat, com quatro tiers possíveis (conversation history durável, working memory denormalizada por couple, observational memory comprimindo turns antigos, semantic recall opt-in). Este cenário ativa os dois primeiros tiers — basta pra resolver “fecha e abre” com retrieval de facts-chave. Observational e semantic ficam dormentes (ativam quando miss-rate doer).

O contract de AgentChat.ask ganha dois IDs opacos — resourceId (mapeia ao Household no futuro; por agora é string como "casa-gabriel") e threadId (por conversa). O service continua stateless: não armazena estado, não tem singleton de memória escondido. Memory chega via construtor (mirror do pattern FeasibilityCheck.evaluate({ today }) — dependências externas como input, não global).

A spec do cenário roda contra InMemoryAgentMemory (test fake) — Map de threadId pra messages + Map de resourceId pra working memory. Tier domain, ms por spec, sem rede. A mecânica do MastraAgentMemory real (round-trip via libSQL :memory:) vive em spec colocada src/contexts/agent/infrastructure/MastraAgentMemory.spec.ts (ADR 003).

  • AgentMemory = port em agent/application/. Quatro métodos espelhando a API do Mastra Memory: getMessages({ threadId, limit? }), appendMessages({ threadId, messages }), getWorkingMemory({ resourceId }), updateWorkingMemory({ resourceId, patch }). Sync ou async — adapter decide; spec aguarda com await.
  • InMemoryAgentMemory = test fake legítimo (adapter, não mock inline). Mora em infrastructure/ junto do MastraAgentMemory porque ambos implementam o port.
  • MastraAgentMemory = adapter de produção, wraps Memory + LibSQLStore do Mastra. Coberto por spec colocada (ADR 003).
  • WorkingMemory = VO em agent/domain/. POJO denormalizado per resourceId: { members, currencyPreferred, monthlyIncomeBRL, activeGoals }. LLM atualiza via tool-call específico (Mastra default, ADR 004) — sem fact extraction silencioso rodando em background.

Scenario: Primeiro turno na thread — persiste mensagens, working memory continua vazio

Section titled “Scenario: Primeiro turno na thread — persiste mensagens, working memory continua vazio”
  • Given um InMemoryAgentMemory vazio (threads: {}, workingMemory: {})
  • And um AgentChat configurado com esse memory + BudgetTool apontando pro orçamento da casa
  • And resourceId: "casa-gabriel" e threadId: "thread-1" (novos, ainda sem histórico)
  • And o LLM mockado decide chamar budgetTotal({ month: "2026-06" }) e depois responder em PT-BR
  • When o casal envia “quanto a gente tem de orçamento esse mês?”
  • Then o agente chama BudgetTool.budgetTotal com { month: "2026-06" } (igual cenário 008)
  • And memory.getMessages({ threadId: "thread-1" }) retornou [] no início do turno (thread nova)
  • And memory.appendMessages foi chamado com o turno completo: a user message + a assistant reply final (com o content que o mock devolveu)
  • And depois do turno, memory.getMessages({ threadId: "thread-1" }) retorna [user, assistant] na ordem (durável pro próximo turno)
  • And memory.getWorkingMemory({ resourceId: "casa-gabriel" }) continua vazio — o LLM mockado não emitiu tool-call de update nesse turno (working memory só hidrata quando o LLM decide salvar fact)

Scenario: Turno seguinte na mesma thread — history carregado, working memory hidratada

Section titled “Scenario: Turno seguinte na mesma thread — history carregado, working memory hidratada”
  • Given o mesmo InMemoryAgentMemory com a thread "thread-1" já contendo o turno 1 do scenario anterior (user “quanto a gente tem de orçamento esse mês?” + assistant “R$ 2.820…”)
  • And working memory de "casa-gabriel" já hidratada do turno passado: { members: [{ name: "Gabriel" }, { name: "esposa" }], currencyPreferred: "BRL", monthlyIncomeBRL: 14000, activeGoals: [] }
  • And o LLM mockado decide, neste turno, chamar updateWorkingMemory({ patch: { activeGoals: [{ name: "Amsterdam", target: "EUR 6000", deadline: "2026-09-01", savedInTarget: "EUR 500" }] } }) (Mastra-default tool de update) e depois responder em PT-BR
  • When o casal envia “lembra que a gente tá juntando pra Amsterdam? aportei €500 ontem”
  • Then o agente carrega memory.getMessages({ threadId: "thread-1", limit: N }) com N >= 2 (vê o turno passado)
  • And o agente injeta memory.getWorkingMemory({ resourceId: "casa-gabriel" }) no prompt do LLM (denormalized read model com renda e members)
  • And memory.updateWorkingMemory({ resourceId: "casa-gabriel", patch: { activeGoals: [...] } }) foi chamado com o patch acima
  • And depois do turno, memory.getWorkingMemory({ resourceId: "casa-gabriel" }) retorna o working memory atualizado com activeGoals[0].name === "Amsterdam"
  • And memory.appendMessages adicionou o novo par (user + assistant) à thread; memory.getMessages({ threadId: "thread-1" }) agora tem 4 mensagens

Scenario: Nova thread, mesma resourceId — working memory compartilhada, history isolada

Section titled “Scenario: Nova thread, mesma resourceId — working memory compartilhada, history isolada”
  • Given o mesmo InMemoryAgentMemory com "thread-1" contendo 4 mensagens e working memory de "casa-gabriel" com activeGoals: [{ name: "Amsterdam", ... }] (estado do scenario 2)
  • And o casal abre uma thread nova "thread-2" (ex: tela “Nova conversa” na UI)
  • And o LLM mockado decide responder direto em PT-BR, sem tool-call (basta o que tem no working memory injetado)
  • When o casal envia “e a meta de Amsterdam, lembra?”
  • Then o agente chama memory.getMessages({ threadId: "thread-2" }) e recebe [] (thread nova, history isolada por thread)
  • And o agente chama memory.getWorkingMemory({ resourceId: "casa-gabriel" }) e recebe o snapshot completo com activeGoals[0].name === "Amsterdam" (working memory é per resourceId, compartilhada entre threads do mesmo couple)
  • And o LLM consegue responder sem tool-call de leitura — working memory já carrega o fact (“Amsterdam, target EUR 6000, deadline 01/09/2026, savedInTarget EUR 500”)
  • And memory.appendMessages salvou o turno na "thread-2"; memory.getMessages({ threadId: "thread-1" }) continua com 4 mensagens (não vazou)
  • PortAgentMemory em src/contexts/agent/application/. Interface alinhada com Mastra Memory pra adapter ser mapeamento direto (sem tradução semântica):
    interface AgentMemory {
    getMessages(input: { threadId: string; limit?: number }): Promise<ChatMessage[]> | ChatMessage[];
    appendMessages(input: { threadId: string; resourceId: string; messages: ChatMessage[] }): Promise<void> | void;
    getWorkingMemory(input: { resourceId: string }): Promise<WorkingMemory | undefined> | WorkingMemory | undefined;
    updateWorkingMemory(input: { resourceId: string; patch: Partial<WorkingMemory> }): Promise<void> | void;
    }
  • VOWorkingMemory em src/contexts/agent/domain/. POJO serializável, sem behavior:
    interface WorkingMemory {
    members: Array<{ name: string }>;
    currencyPreferred: string; // "BRL"
    monthlyIncomeBRL: number; // 14000
    activeGoals: Array<{
    name: string;
    target: string; // "EUR 6000" — string pra LLM consumir sem perder unit
    deadline: string; // ISO yyyy-mm-dd
    savedInTarget: string; // "EUR 500"
    }>;
    }
    Shape denormalizado (read model), atualizado por tool-call do LLM (Mastra default). Domain não converte moeda — strings carregam unit junto (segue gotcha “domínio não converte moeda; UX converte”).
  • AgentChat extendidoAgentChat.create({ model, tools, memory }). ask({ messages, resourceId, threadId }) orquestra:
    1. Carrega history: memory.getMessages({ threadId, limit }).
    2. Carrega working memory: memory.getWorkingMemory({ resourceId }).
    3. Injeta os dois no prompt do LLM (mecânica fica a critério do impl — system message, prefix, etc).
    4. Roda turno (tool-calls + reply, igual 008).
    5. Persiste o turno: memory.appendMessages({ threadId, resourceId, messages: [user, assistant] }).
    6. Se LLM emitiu updateWorkingMemory tool-call, propaga pro memory.
  • Adapter test fakeInMemoryAgentMemory. Dois Maps: threads: Map<threadId, ChatMessage[]> e workingMemory: Map<resourceId, WorkingMemory>. Sem coerção, sem TTL, sem limite — comportamento simples pro spec asserir.
  • Adapter de produçãoMastraAgentMemory. Wraps new Memory({ storage: new LibSQLStore({ url: "file:./mel.db" }) }). Cada método do port é uma chamada direta no Mastra Memory API. Mecânica em spec colocada src/contexts/agent/infrastructure/MastraAgentMemory.spec.ts.
src/contexts/agent/domain/WorkingMemory.ts
export interface WorkingMemory {
members: Array<{ name: string }>;
currencyPreferred: string;
monthlyIncomeBRL: number;
activeGoals: Array<{
name: string;
target: string;
deadline: string;
savedInTarget: string;
}>;
}
// src/contexts/agent/application/AgentMemory.ts
export interface AgentMemory {
getMessages(input: {
threadId: string;
limit?: number;
}): Promise<ChatMessage[]> | ChatMessage[];
appendMessages(input: {
threadId: string;
resourceId: string;
messages: ChatMessage[];
}): Promise<void> | void;
getWorkingMemory(input: {
resourceId: string;
}): Promise<WorkingMemory | undefined> | WorkingMemory | undefined;
updateWorkingMemory(input: {
resourceId: string;
patch: Partial<WorkingMemory>;
}): Promise<void> | void;
}
// src/contexts/agent/domain/AgentChat.ts (contract extendido)
AgentChat.create({
model: LanguageModelV1;
tools: { budget?: BudgetTool; goal?: GoalTool; feasibility?: FeasibilityTool };
memory: AgentMemory;
}): AgentChat
agentChat.ask({
messages: ChatMessage[]; // novo turno (1 user message, normalmente)
resourceId: string; // "casa-gabriel"
threadId: string; // "thread-1"
}): Promise<{
reply: ChatMessage;
toolCalls: Array<{ name: string; args: unknown; result: unknown }>;
}>
  • Port em application/, NÃO em domain/AgentMemory é infra-facing (abstrai persistência + LLM-side memory). Mesma regra do BudgetRepository (cenário 007). Domain não importa de application/infrastructure (gotcha “Port em application/, Domain Service em domain/services/”).
  • AgentChat continua stateless — só ganha IDs opacosresourceId e threadId chegam como input, não como state interno. Memory é dependência via DI no construtor. Mirror de FeasibilityCheck.evaluate({ today }): caller traz tudo que o service precisa, service não esconde clock nem state (ADR 004).
  • Interface do port espelha Mastra MemorygetMessages/appendMessages/getWorkingMemory/updateWorkingMemory casam 1:1 com a API do @mastra/memory. Reduz tradução no adapter real e mantém o port mockável de forma trivial (test fake = dois Maps).
  • Working memory cross-thread per resourceId, history per threadId — facts (“renda do casal”, “metas ativas”) pertencem ao couple/household, não à conversa. Mudar de thread (nova conversa) não esquece a renda. History por thread isola contexto efêmero (alinha com paradigma do ChatGPT/Claude).
  • LLM atualiza working memory via tool-call, NÃO via fact extraction em background — Mastra default. Menos determinístico que rules-based mas evita lógica de extração custom no domínio. Trade-off aceito em ADR 004.
  • Observational memory + semantic recall ficam OFF nesse cenário — só conversation history + working memory entram. Observational e semantic ativam quando miss-rate doer (ADR 004). Cenário 012 não exercita compressão nem retrieval semântico — fica trivialmente extensível.
  • WorkingMemory.activeGoals[].target é string "EUR 6000", NÃO { amount, currency } — Working memory é read model pro LLM consumir. LLM vê string natural, não objeto. Conversão pra Money fica na write tool quando o agente decide aportar (cenário 013). Mantém o shape stringly-typed do lado da fronteira LLM, alinha com gotcha “fronteira LLM é EN/string”.
  • appendMessages recebe resourceId junto — Mastra Memory associa cada message ao resource (couple) E ao thread (conversa). Permite queries cross-thread por resourceId no futuro. Test fake guarda só por threadId (resourceId é validation/futuro).
  • Spec não asserta wording do prompt injectioncomo o AgentChat injeta working memory no prompt (system message, prefix, structured-output schema) fica a critério do impl. Spec asserta o comportamento observável: getWorkingMemory foi chamado antes do turno, updateWorkingMemory foi chamado quando o LLM emitiu o tool-call de update, working memory cross-thread reflete o estado salvo. Mecânica de prompt fica fora do contract domain-facing.
  • Test fake é adapter legítimoInMemoryAgentMemory mora em infrastructure/ junto do MastraAgentMemory. Mock inline na spec seria mais barato hoje, mas o fake vira reuso quando 013 (write tools) e e2e (Playwright + mock LLM) precisarem de memory na mão.
  • Cenário 008AgentChat.ask({ messages }) continua passando se o impl tolerar resourceId/threadId opcionais (default "default"/"default") E memory opcional no construtor (no-op stub interno). Senão, specs 008 precisam touchup mínimo (passar resourceId: "test", threadId: "test", memory: new InMemoryAgentMemory()). Decisão fica no impl — gotcha “back-compat via props opcionais” já tem precedente (Budget.create overload).
  • Cenário 013 (write tools) — write tools usam BudgetRepository/GoalRepository direto, não passam pela memory. Mas o AgentChat.ask enriquecido (resourceId/threadId + memory injetada) é o mesmo entry point. Memory e write tools são ortogonais: write tools persistem domain state; memory persiste conversation + working memory.
  • Cenário 005 (Household)resourceId mapeia ao Household.id quando o cenário aparecer. Por agora é string opaca ("casa-gabriel"). Lookup resourceId → Household é responsabilidade do caller (UI/composition root), não do AgentChat.
  • Observational memory (compressão de turns antigos) — Mastra suporta nativo (summarization in-place). Ativa quando thread crescer e custo de token doer. Cenário 012 testa só history bruto + working memory.
  • Semantic recall (embeddings) — OFF por default (ADR 004). Liga quando miss-rate de retrieval doer (working memory denso não cobre tudo). Gemini Embedding 001 via OpenRouter quando ativar.
  • Schema migration do libSQL — Mastra gerencia internamente. Sem drizzle schema-as-TS aqui (contraste com budget/infrastructure/schema.ts do 007). Spec colocada do MastraAgentMemory cria a memory com libSQL :memory: e assume bootstrap automático.
  • Conflito de updates concorrentes em working memory — local-first, single-user (casal num app). Last-write-wins é OK. Lock/optimistic versioning entra se aparecer caso.
  • Compactação cross-thread — quando working memory de um resourceId fica grande (muitos activeGoals), Mastra cuida. Spec 012 testa um goal ativo só.
  • Mapping resourceId → Household.id — caller resolve. Spec usa string opaca.
  • TTL/cleanup de threads antigas — fora de escopo. Mastra ou layer de UX decide quando promover thread pra archive.
  • Multi-tenant — um casal por app, local-first. resourceId distingue casas só conceitualmente (futuro multi-casa).
  • Tool error handling em updateWorkingMemory — LLM emite patch inválido (campo desconhecido, type errado): impl decide se ignora, loga, ou pede pro LLM reformular. Spec só cobre happy path.
  1. Implementar WorkingMemory (VO POJO) em src/contexts/agent/domain/.
  2. Implementar AgentMemory (port) em src/contexts/agent/application/.
  3. Implementar InMemoryAgentMemory (test fake) em src/contexts/agent/infrastructure/.
  4. Estender AgentChat: construtor aceita memory; ask aceita resourceId/threadId; orquestração carrega history + working memory antes do turno e persiste depois.
  5. Instalar @mastra/memory + @mastra/libsql + @mastra/ai-sdk (ADR 001 amendment quando virar dep instalada).
  6. Implementar MastraAgentMemory adapter + passar spec colocada contra libSQL :memory:.