From 13b9db1e63b473858c6c44d32cf0082a15fa4b82 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 24 Oct 2026 02:27:47 -0700 Subject: [PATCH 1/2] fix(memory): atomically append native notes Co-authored-by: folio Agent-Model: codex/gpt-5.6-luna/xhigh --- .../service/native-memory-append.test.js | 75 ++++++++++ backend/services/agentMemoryService.ts | 132 ++++++++++++++++++ backend/services/nativeRuntimeService.ts | 15 +- 3 files changed, 210 insertions(+), 12 deletions(-) create mode 100644 backend/__tests__/service/native-memory-append.test.js diff --git a/backend/__tests__/service/native-memory-append.test.js b/backend/__tests__/service/native-memory-append.test.js new file mode 100644 index 0000000000..dd29b31eea --- /dev/null +++ b/backend/__tests__/service/native-memory-append.test.js @@ -0,0 +1,75 @@ +const { setupMongoDb, closeMongoDb } = require('../utils/testUtils'); + +const AgentMemory = require('../../models/AgentMemory'); +const { appendLongTermMemory } = require('../../services/agentMemoryService'); + +jest.setTimeout(60000); + +describe('native long-term memory appends', () => { + beforeAll(async () => { + await setupMongoDb(); + }); + + afterAll(async () => { + await closeMongoDb(); + }); + + beforeEach(async () => { + await AgentMemory.deleteMany({}); + }); + + it('serializes overlapping appends and keeps v1/v2 memory aligned', async () => { + await AgentMemory.create({ + agentName: 'native-race', + instanceId: 'default', + content: 'seed', + sections: { + long_term: { + content: 'seed', + visibility: 'private', + byteSize: Buffer.byteLength('seed', 'utf8'), + }, + }, + }); + + await Promise.all( + Array.from({ length: 12 }, (_, index) => appendLongTermMemory({ + agentName: 'native-race', + instanceId: 'default', + content: `concurrent note ${index}`, + now: new Date(`2026-10-24T09:30:${String(index).padStart(2, '0')}.000Z`), + })), + ); + + const saved = await AgentMemory.findOne({ agentName: 'native-race', instanceId: 'default' }).lean(); + const longTerm = saved.sections.long_term; + expect(saved.content).toBe(longTerm.content); + expect(longTerm.content).toContain('seed'); + for (let index = 0; index < 12; index += 1) { + expect(longTerm.content).toContain(`concurrent note ${index}`); + } + expect(longTerm.byteSize).toBe(Buffer.byteLength(longTerm.content, 'utf8')); + expect(longTerm.source).toMatchObject({ runtime: 'native', via: 'native-tool' }); + expect(longTerm.history).toHaveLength(10); + }); + + it('uses the legacy content as the prefix when no long_term section exists', async () => { + await AgentMemory.create({ + agentName: 'legacy-native', + instanceId: 'default', + content: 'legacy note', + }); + + await appendLongTermMemory({ + agentName: 'legacy-native', + instanceId: 'default', + content: 'new native note', + now: new Date('2026-10-24T09:40:00.000Z'), + }); + + const saved = await AgentMemory.findOne({ agentName: 'legacy-native', instanceId: 'default' }).lean(); + expect(saved.content).toBe('legacy note\n\n[2026-10-24T09:40:00.000Z] new native note'); + expect(saved.sections.long_term.content).toBe(saved.content); + expect(saved.sections.long_term.history[0].content).toBe('legacy note'); + }); +}); diff --git a/backend/services/agentMemoryService.ts b/backend/services/agentMemoryService.ts index fa197ecae1..f67bba9fa5 100644 --- a/backend/services/agentMemoryService.ts +++ b/backend/services/agentMemoryService.ts @@ -160,6 +160,138 @@ export function mirrorContentFromSections( return sections?.long_term?.content ?? ''; } +export interface AppendLongTermMemoryArgs { + agentName: string; + instanceId: string; + content: string; + now?: Date; +} + +/** + * Append one native memory note without a read-modify-write race. + * + * Native writes and the runtime memory API address the same AgentMemory + * document. A client-side read followed by `$set` lets two writers observe + * the same prefix and makes whichever update arrives last erase the other + * note. The aggregation update below computes the prefix from Mongo's current + * document while taking the document lock, so overlapping appends serialize + * and both `content` (v1) and `sections.long_term` (v2) receive the same + * result. + */ +export async function appendLongTermMemory( + args: AppendLongTermMemoryArgs, +): Promise { + const { agentName, instanceId, now = new Date() } = args; + const content = typeof args.content === 'string' ? args.content.trim() : ''; + if (!agentName || !instanceId || !content) return false; + + const note = `[${now.toISOString()}] ${content}`; + const priorContent = { + $cond: [ + { $eq: [{ $type: '$sections.long_term.content' }, 'string'] }, + '$sections.long_term.content', + { $ifNull: ['$content', ''] }, + ], + }; + const withPrior = (expression: unknown) => ({ + $let: { vars: { prior: priorContent }, in: expression }, + }); + const nextContent = withPrior({ + $concat: [ + '$$prior', + { $cond: [{ $gt: [{ $strLenBytes: '$$prior' }, 0] }, '\n\n', ''] }, + note, + ], + }); + const historyEntry = { + $mergeObjects: [ + { content: '$$prior', replacedAt: now }, + { + $cond: [ + { $eq: [{ $type: '$sections.long_term.source' }, 'missing'] }, + {}, + { source: '$sections.long_term.source' }, + ], + }, + ], + }; + const history = withPrior({ + $cond: [ + { $gt: [{ $strLenBytes: '$$prior' }, 0] }, + { + history: { + $slice: [ + { + $concatArrays: [ + [historyEntry], + { $ifNull: ['$sections.long_term.history', []] }, + ], + }, + MEMORY_HISTORY_CAP, + ], + }, + }, + { + $cond: [ + { $eq: [{ $type: '$sections.long_term.history' }, 'missing'] }, + {}, + { history: '$sections.long_term.history' }, + ], + }, + ], + }); + + await AgentMemory.findOneAndUpdate( + { agentName, instanceId }, + [ + { + $set: { + agentName: { $ifNull: ['$agentName', agentName] }, + instanceId: { $ifNull: ['$instanceId', instanceId] }, + content: nextContent, + sourceRuntime: 'native', + schemaVersion: 2, + lastSyncKey: '$$REMOVE', + lastSyncAt: '$$REMOVE', + 'sections.long_term': withPrior({ + $mergeObjects: [ + { + content: { + $concat: [ + '$$prior', + { $cond: [{ $gt: [{ $strLenBytes: '$$prior' }, 0] }, '\n\n', ''] }, + note, + ], + }, + visibility: 'private', + updatedAt: now, + byteSize: { + $strLenBytes: { + $concat: [ + '$$prior', + { $cond: [{ $gt: [{ $strLenBytes: '$$prior' }, 0] }, '\n\n', ''] }, + note, + ], + }, + }, + source: { + runtime: 'native', + via: 'native-tool', + writtenAt: now, + }, + }, + history, + ], + }), + }, + }, + ] as any, + { upsert: true, new: true }, + ); + + return true; +} + // Server-stamp metadata the caller shouldn't be trusted to compute: `byteSize` // (derived from `content`) and `updatedAt` (now). Callers supply `content` + // `visibility`; everything else is enforced server-side. Only stamps the diff --git a/backend/services/nativeRuntimeService.ts b/backend/services/nativeRuntimeService.ts index 8829a3b6e4..28d192aaba 100644 --- a/backend/services/nativeRuntimeService.ts +++ b/backend/services/nativeRuntimeService.ts @@ -52,6 +52,7 @@ import { defaultByoHandoffName, type ByoHandoffAdapter, } from './byoHandoffService'; +import { appendLongTermMemory } from './agentMemoryService'; // --- public surface -------------------------------------------------------- @@ -763,21 +764,11 @@ async function dispatchTool( if (!content) { return { content: { ok: false, error: 'content is required' }, error: 'missing_content' }; } - const AgentMemory = require('../models/AgentMemory'); - const existing = await AgentMemory.findOne({ + await appendLongTermMemory({ agentName: ctx.agentName, instanceId: ctx.instanceId, + content, }); - const prior = (existing?.content as string) || ''; - const nextContent = `${prior}${prior ? '\n\n' : ''}[${new Date().toISOString()}] ${content}`; - // ADR-003 Phase 2: clear /memory/sync dedup cache when a non-sync - // writer mutates the doc, so the next sync promotion isn't wrongly - // short-circuited on a stale hash. - await AgentMemory.findOneAndUpdate( - { agentName: ctx.agentName, instanceId: ctx.instanceId }, - { $set: { content: nextContent }, $unset: { lastSyncKey: '', lastSyncAt: '' } }, - { upsert: true, new: true, setDefaultsOnInsert: true }, - ); return { content: { ok: true, written: true } }; } From d9e3a8970121af09deb89f77544bef380ac87f9b Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 24 Oct 2026 02:43:31 -0700 Subject: [PATCH 2/2] test(memory): cover native memory dispatcher Co-authored-by: folio Agent-Model: codex/gpt-5.6-luna/xhigh --- .../service/native-memory-append.test.js | 28 +++++++++++++++++++ backend/services/nativeRuntimeService.ts | 2 +- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/backend/__tests__/service/native-memory-append.test.js b/backend/__tests__/service/native-memory-append.test.js index dd29b31eea..8a55d49b39 100644 --- a/backend/__tests__/service/native-memory-append.test.js +++ b/backend/__tests__/service/native-memory-append.test.js @@ -2,6 +2,12 @@ const { setupMongoDb, closeMongoDb } = require('../utils/testUtils'); const AgentMemory = require('../../models/AgentMemory'); const { appendLongTermMemory } = require('../../services/agentMemoryService'); +jest.mock('../../services/agentComputerBroker', () => ({ + agentComputerBroker: {}, + agentComputerEnabled: false, +})); +const { dispatchTool } = require('../../services/nativeRuntimeService'); +const { GivenAddresses } = require('../../services/webAddressProvenance'); jest.setTimeout(60000); @@ -72,4 +78,26 @@ describe('native long-term memory appends', () => { expect(saved.sections.long_term.content).toBe(saved.content); expect(saved.sections.long_term.history[0].content).toBe('legacy note'); }); + + it('writes through commonly_write_memory dispatch and persists both memory shapes', async () => { + const result = await dispatchTool('commonly_write_memory', { content: 'dispatcher note' }, { + installation: {}, + podId: 'dispatcher-pod', + agentName: 'dispatcher-native', + instanceId: 'default', + displayName: 'Dispatcher Native', + installationConfig: {}, + givenAddresses: new GivenAddresses(), + ownUserId: null, + }); + + expect(result).toEqual({ content: { ok: true, written: true } }); + + const saved = await AgentMemory.findOne({ + agentName: 'dispatcher-native', + instanceId: 'default', + }).lean(); + expect(saved.content).toContain('dispatcher note'); + expect(saved.sections.long_term.content).toBe(saved.content); + }); }); diff --git a/backend/services/nativeRuntimeService.ts b/backend/services/nativeRuntimeService.ts index 28d192aaba..9ab792cbd4 100644 --- a/backend/services/nativeRuntimeService.ts +++ b/backend/services/nativeRuntimeService.ts @@ -711,7 +711,7 @@ interface DispatchResult { imagePngBase64?: string; } -async function dispatchTool( +export async function dispatchTool( name: string, rawArgs: unknown, ctx: DispatchContext,