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 comawait. - InMemoryAgentMemory = test fake legítimo (adapter, não mock inline). Mora em
infrastructure/junto doMastraAgentMemoryporque ambos implementam o port. - MastraAgentMemory = adapter de produção, wraps
Memory+LibSQLStoredo Mastra. Coberto por spec colocada (ADR 003). - WorkingMemory = VO em
agent/domain/. POJO denormalizado perresourceId:{ 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
InMemoryAgentMemoryvazio (threads: {},workingMemory: {}) - And um
AgentChatconfigurado com esse memory +BudgetToolapontando pro orçamento da casa - And
resourceId: "casa-gabriel"ethreadId: "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.budgetTotalcom{ month: "2026-06" }(igual cenário 008) - And
memory.getMessages({ threadId: "thread-1" })retornou[]no início do turno (thread nova) - And
memory.appendMessagesfoi chamado com o turno completo: ausermessage + aassistantreply final (com ocontentque 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
InMemoryAgentMemorycom 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 comactiveGoals[0].name === "Amsterdam" - And
memory.appendMessagesadicionou 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
InMemoryAgentMemorycom"thread-1"contendo 4 mensagens e working memory de"casa-gabriel"comactiveGoals: [{ 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 comactiveGoals[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.appendMessagessalvou o turno na"thread-2";memory.getMessages({ threadId: "thread-1" })continua com 4 mensagens (não vazou)
Modelo
Section titled “Modelo”- Port —
AgentMemoryemsrc/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;} - VO —
WorkingMemoryemsrc/contexts/agent/domain/. POJO serializável, sem behavior: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”).interface WorkingMemory {members: Array<{ name: string }>;currencyPreferred: string; // "BRL"monthlyIncomeBRL: number; // 14000activeGoals: Array<{name: string;target: string; // "EUR 6000" — string pra LLM consumir sem perder unitdeadline: string; // ISO yyyy-mm-ddsavedInTarget: string; // "EUR 500"}>;} - AgentChat extendido —
AgentChat.create({ model, tools, memory }).ask({ messages, resourceId, threadId })orquestra:- Carrega history:
memory.getMessages({ threadId, limit }). - Carrega working memory:
memory.getWorkingMemory({ resourceId }). - Injeta os dois no prompt do LLM (mecânica fica a critério do impl — system message, prefix, etc).
- Roda turno (tool-calls + reply, igual 008).
- Persiste o turno:
memory.appendMessages({ threadId, resourceId, messages: [user, assistant] }). - Se LLM emitiu
updateWorkingMemorytool-call, propaga pro memory.
- Carrega history:
- Adapter test fake —
InMemoryAgentMemory. DoisMaps:threads: Map<threadId, ChatMessage[]>eworkingMemory: Map<resourceId, WorkingMemory>. Sem coerção, sem TTL, sem limite — comportamento simples pro spec asserir. - Adapter de produção —
MastraAgentMemory. Wrapsnew Memory({ storage: new LibSQLStore({ url: "file:./mel.db" }) }). Cada método do port é uma chamada direta no Mastra Memory API. Mecânica em spec colocadasrc/contexts/agent/infrastructure/MastraAgentMemory.spec.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.tsexport 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 }>;}>Decisões de design
Section titled “Decisões de design”- Port em
application/, NÃO emdomain/—AgentMemoryé infra-facing (abstrai persistência + LLM-side memory). Mesma regra doBudgetRepository(cenário 007). Domain não importa de application/infrastructure (gotcha “Port emapplication/, Domain Service emdomain/services/”). - AgentChat continua stateless — só ganha IDs opacos —
resourceIdethreadIdchegam como input, não como state interno. Memory é dependência via DI no construtor. Mirror deFeasibilityCheck.evaluate({ today }): caller traz tudo que o service precisa, service não esconde clock nem state (ADR 004). - Interface do port espelha Mastra Memory —
getMessages/appendMessages/getWorkingMemory/updateWorkingMemorycasam 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 = doisMaps). - Working memory cross-thread per
resourceId, history perthreadId— 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 praMoneyfica 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”.appendMessagesreceberesourceIdjunto — 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 injection — como o
AgentChatinjeta working memory no prompt (system message, prefix, structured-output schema) fica a critério do impl. Spec asserta o comportamento observável:getWorkingMemoryfoi chamado antes do turno,updateWorkingMemoryfoi 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ítimo —
InMemoryAgentMemorymora eminfrastructure/junto doMastraAgentMemory. 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.
Impacto em outros cenários
Section titled “Impacto em outros cenários”- Cenário 008 —
AgentChat.ask({ messages })continua passando se o impl tolerarresourceId/threadIdopcionais (default"default"/"default") Ememoryopcional no construtor (no-op stub interno). Senão, specs 008 precisam touchup mínimo (passarresourceId: "test",threadId: "test",memory: new InMemoryAgentMemory()). Decisão fica no impl — gotcha “back-compat via props opcionais” já tem precedente (Budget.createoverload). - Cenário 013 (write tools) — write tools usam
BudgetRepository/GoalRepositorydireto, não passam pela memory. Mas oAgentChat.askenriquecido (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) —
resourceIdmapeia aoHousehold.idquando o cenário aparecer. Por agora é string opaca ("casa-gabriel"). LookupresourceId → Householdé responsabilidade do caller (UI/composition root), não doAgentChat.
Fora de escopo
Section titled “Fora de escopo”- 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.tsdo 007). Spec colocada doMastraAgentMemorycria 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.
resourceIddistingue 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.
Próximo passo
Section titled “Próximo passo”- Implementar
WorkingMemory(VO POJO) emsrc/contexts/agent/domain/. - Implementar
AgentMemory(port) emsrc/contexts/agent/application/. - Implementar
InMemoryAgentMemory(test fake) emsrc/contexts/agent/infrastructure/. - Estender
AgentChat: construtor aceitamemory;askaceitaresourceId/threadId; orquestração carrega history + working memory antes do turno e persiste depois. - Instalar
@mastra/memory+@mastra/libsql+@mastra/ai-sdk(ADR 001 amendment quando virar dep instalada). - Implementar
MastraAgentMemoryadapter + passar spec colocada contra libSQL:memory:.