Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 103 additions & 0 deletions backend/__tests__/service/native-memory-append.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
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);

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');
});

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);
});
});
132 changes: 132 additions & 0 deletions backend/services/agentMemoryService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean> {
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
Expand Down
17 changes: 4 additions & 13 deletions backend/services/nativeRuntimeService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import {
defaultByoHandoffName,
type ByoHandoffAdapter,
} from './byoHandoffService';
import { appendLongTermMemory } from './agentMemoryService';

// --- public surface --------------------------------------------------------

Expand Down Expand Up @@ -710,7 +711,7 @@ interface DispatchResult {
imagePngBase64?: string;
}

async function dispatchTool(
export async function dispatchTool(
name: string,
rawArgs: unknown,
ctx: DispatchContext,
Expand Down Expand Up @@ -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,
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
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 } };
}

Expand Down
Loading