Skip to content

Commit ad05dae

Browse files
fix(agent): retain conversation memory attachments (#7818)
1 parent 5a7b924 commit ad05dae

10 files changed

Lines changed: 1099 additions & 67 deletions

File tree

.github/workflows/test-build.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -170,15 +170,17 @@ jobs:
170170
if-no-files-found: ignore
171171
retention-days: 7
172172

173-
- name: Verify durable provenance bindings and concurrent memory writes
173+
- name: Verify durable provenance, concurrent memory writes, and attachment replay
174174
working-directory: apps/sim
175175
env:
176176
TABLE_PROVENANCE_TEST_DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/sim_auth_scim
177177
MEMORY_PROVENANCE_TEST_DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/sim_auth_scim
178+
AGENT_MEMORY_TEST_DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/sim_auth_scim
178179
run: >-
179180
bunx vitest run
180181
lib/table/rows/secret-provenance.postgres.test.ts
181182
lib/memory/message-provenance.postgres.test.ts
183+
executor/handlers/agent/memory-harness.postgres.test.ts
182184
183185
- name: Verify Search progress, pagination, and outbox scheduling in PostgreSQL
184186
working-directory: apps/sim

apps/docs/content/docs/academy/agents/memory.mdx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ import { AV_MEMORY_WORKFLOW } from '@/components/workflow-preview/academy-video-
1818

1919
By default, an agent keeps nothing between runs: every conversation starts completely fresh. The Memory setting changes that: choose Conversation, give it a conversation ID, and everything said under that key is kept and loaded back before the model runs.
2020

21+
Uploaded attachments stay linked to the message that included them. Memory stores file references; each later run reads the accessible files again and prepares them for the selected provider. Attachments follow the selected memory window and the source file's storage retention. A replay can include up to 20 attachment references; use a smaller memory window for longer file-heavy conversations. Files omitted by older versions of memory need to be attached again.
22+
2123
<WhatYouWillLearn
2224
items={[
2325
{

apps/sim/executor/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,7 @@ export const MEMORY = {
248248
CONTEXT_WINDOW_UTILIZATION: 0.9,
249249
MAX_CONVERSATION_ID_LENGTH: 255,
250250
MAX_MESSAGE_CONTENT_BYTES: 100 * 1024,
251+
MAX_REPLAY_FILE_REFERENCES: 20,
251252
} as const
252253

253254
export const ROUTER = {

apps/sim/executor/handlers/agent/agent-handler.test.ts

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import {
2+
dbChainMockFns,
23
loggerMock,
34
queueTableRows,
45
resetDbChainMock,
@@ -22,6 +23,7 @@ import * as userFileBase64 from '@/lib/uploads/utils/user-file-base64.server'
2223
import { getAllBlocks } from '@/blocks'
2324
import { AGENT, BlockType, isMcpTool } from '@/executor/constants'
2425
import { AgentBlockHandler } from '@/executor/handlers/agent/agent-handler'
26+
import type { AgentInputs, Message } from '@/executor/handlers/agent/types'
2527
import type { ExecutionContext, StreamingExecution } from '@/executor/types'
2628
import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'
2729
import { executeProviderRequest } from '@/providers'
@@ -322,6 +324,170 @@ describe('AgentBlockHandler', () => {
322324
})
323325
})
324326

327+
describe('conversation attachment replay', () => {
328+
beforeEach(() => {
329+
dbChainMockFns.returning.mockResolvedValue([{ id: 'memory-1' }])
330+
})
331+
332+
afterEach(() => {
333+
vi.restoreAllMocks()
334+
})
335+
336+
const file = {
337+
id: 'file-1',
338+
name: 'example.png',
339+
key: 'execution/test-workspace/test-workflow/exec-1/example.png',
340+
url: 'https://storage.example.com/expired',
341+
size: 8,
342+
type: 'image/png',
343+
context: 'execution',
344+
base64: 'iVBORw0KGgo=',
345+
}
346+
347+
it.each(['files', 'messages', 'userPrompt'] as const)(
348+
'replays a previous turn from %s with a fresh provider attachment',
349+
async (source) => {
350+
mockGetProviderFromModel.mockReturnValue('openai')
351+
const hydrate = vi
352+
.spyOn(userFileBase64, 'hydrateUserFilesWithBase64')
353+
.mockImplementation(async (value) => {
354+
const files = value as (typeof file)[]
355+
return files.map((attachment) => ({
356+
...attachment,
357+
base64: file.base64,
358+
})) as typeof value
359+
})
360+
const inputs: AgentInputs = {
361+
model: 'gpt-4o',
362+
memoryType: 'conversation',
363+
conversationId: 'conversation-1',
364+
...(source === 'userPrompt'
365+
? { userPrompt: 'Analyze this file', files: [file] }
366+
: {
367+
messages: [
368+
{
369+
role: 'user',
370+
content: 'Analyze this file',
371+
...(source === 'messages' ? { files: [file] } : {}),
372+
},
373+
],
374+
...(source === 'files' ? { files: [file] } : {}),
375+
}),
376+
}
377+
const original = structuredClone(inputs)
378+
await handler.execute({ ...mockContext, executionId: 'exec-1' }, mockBlock, inputs)
379+
const stored = dbChainMockFns.values.mock.calls
380+
.map(([row]) => row)
381+
.find((row) => Array.isArray(row.data) && row.data[0]?.role === 'user')?.data as Message[]
382+
expect(stored).toBeDefined()
383+
expect(stored[0].files).toEqual([
384+
{
385+
id: file.id,
386+
name: file.name,
387+
key: file.key,
388+
url: '',
389+
size: file.size,
390+
type: file.type,
391+
context: file.context,
392+
},
393+
])
394+
expect(inputs).toEqual(original)
395+
396+
queueTableRows(schemaMock.memory, [
397+
{ data: [...stored, { role: 'assistant', content: 'First answer' }] },
398+
])
399+
mockGetProviderFromModel.mockReturnValue('anthropic')
400+
const nextContext = { ...mockContext, executionId: 'exec-2' }
401+
await handler.execute(nextContext, mockBlock, {
402+
model: 'claude-sonnet-4-5',
403+
memoryType: 'conversation',
404+
conversationId: 'conversation-1',
405+
messages: [{ role: 'user', content: 'What is in that file?' }],
406+
})
407+
const request = mockExecuteProviderRequest.mock.calls.at(-1)?.[1]
408+
expect(request.messages[0]).toMatchObject({
409+
role: 'user',
410+
content: 'Analyze this file',
411+
files: [{ key: file.key, base64: file.base64 }],
412+
})
413+
expect(request.messages.at(-1)).toMatchObject({
414+
role: 'user',
415+
content: 'What is in that file?',
416+
})
417+
expect(request.messages.at(-1).files).toBeUndefined()
418+
expect(hydrate.mock.calls.at(-1)?.[0]).toEqual(stored[0].files)
419+
expect(hydrate.mock.calls.at(-1)?.[1]).toMatchObject({
420+
executionId: 'exec-2',
421+
fileKeys: [file.key],
422+
})
423+
hydrate.mockRestore()
424+
}
425+
)
426+
427+
it('does not duplicate an attachment when the same execution revisits the agent', async () => {
428+
mockGetProviderFromModel.mockReturnValue('openai')
429+
queueTableRows(schemaMock.memory, [
430+
{
431+
data: [
432+
{ role: 'user', content: 'Analyze this file', executionId: 'exec-1', files: [file] },
433+
],
434+
},
435+
])
436+
await handler.execute({ ...mockContext, executionId: 'exec-1' }, mockBlock, {
437+
model: 'gpt-4o',
438+
memoryType: 'conversation',
439+
conversationId: 'conversation-1',
440+
messages: [{ role: 'user', content: 'Analyze this file' }],
441+
files: [file],
442+
})
443+
expect(mockExecuteProviderRequest.mock.calls[0][1].messages[0].files).toHaveLength(1)
444+
expect(dbChainMockFns.values.mock.calls.some(([row]) => row.data?.[0]?.role === 'user')).toBe(
445+
false
446+
)
447+
})
448+
449+
it('saves a new attachment appended to an existing conversation', async () => {
450+
mockGetProviderFromModel.mockReturnValue('openai')
451+
queueTableRows(schemaMock.memory, [{ data: [{ role: 'assistant', content: 'Hello' }] }])
452+
await handler.execute({ ...mockContext, executionId: 'exec-2' }, mockBlock, {
453+
model: 'gpt-4o',
454+
memoryType: 'conversation',
455+
conversationId: 'conversation-1',
456+
messages: [{ role: 'user', content: 'Analyze this file' }],
457+
files: [file],
458+
})
459+
const stored = dbChainMockFns.values.mock.calls
460+
.map(([row]) => row)
461+
.find((row) => row.data?.[0]?.role === 'user')?.data as Message[]
462+
expect(stored[0].files?.[0]).toMatchObject({ key: file.key, url: '' })
463+
expect(stored[0].files?.[0].base64).toBeUndefined()
464+
})
465+
466+
it('does not hydrate attachments excluded by the conversation window', async () => {
467+
mockGetProviderFromModel.mockReturnValue('openai')
468+
const hydrate = vi.spyOn(userFileBase64, 'hydrateUserFilesWithBase64')
469+
queueTableRows(schemaMock.memory, [
470+
{
471+
data: [
472+
{ role: 'user', content: 'Old file', files: [file] },
473+
{ role: 'assistant', content: 'Recent answer' },
474+
],
475+
},
476+
])
477+
const context = { ...mockContext, executionId: 'exec-2' }
478+
await handler.execute(context, mockBlock, {
479+
model: 'gpt-4o',
480+
memoryType: 'sliding_window',
481+
slidingWindowSize: '1',
482+
conversationId: 'conversation-1',
483+
messages: [{ role: 'user', content: 'Hello' }],
484+
})
485+
expect(hydrate).not.toHaveBeenCalled()
486+
expect(context.fileKeys).toBeUndefined()
487+
hydrate.mockRestore()
488+
})
489+
})
490+
325491
describe('execute', () => {
326492
it('should execute a basic agent block request', async () => {
327493
const inputs = {

0 commit comments

Comments
 (0)