Skip to content

Commit 19e97aa

Browse files
committed
Add durable startup memory context
1 parent 58571e1 commit 19e97aa

4 files changed

Lines changed: 280 additions & 28 deletions

File tree

docs/memory.md

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -48,12 +48,15 @@ Results are fused using Reciprocal Rank Fusion (RRF). This means searching for "
4848
Before each agent invocation, the context builder:
4949

5050
1. Embeds the user's message
51-
2. Searches episodic memory (top 10 episodes)
52-
3. Searches semantic memory (top 20 facts)
53-
4. Searches procedural memory (top 5 procedures)
54-
5. Budgets results to fit within the token limit (default: 50,000 tokens)
55-
6. Filters out stale, low-signal episodic memories before prompt injection
56-
7. Formats results into the memory section of the system prompt
51+
2. On the first turn of a brand-new session, adds a compact durable context section
52+
3. Searches episodic memory (top 10 episodes)
53+
4. Searches semantic memory (top 20 facts)
54+
5. Searches procedural memory (top 5 procedures)
55+
6. Budgets results to fit within the token limit (default: 50,000 tokens)
56+
7. Filters out stale, low-signal episodic memories before prompt injection
57+
8. Formats results into the memory section of the system prompt
58+
59+
The durable context section is startup-only and intentionally small. It favors high-confidence facts and metadata-ranked memories so a new session begins with a little long-term continuity before normal retrieval takes over.
5760

5861
## Consolidation
5962

src/agent/runtime.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,7 @@ export class AgentRuntime {
120120
let memoryContext: string | undefined;
121121
if (this.memoryContextBuilder) {
122122
try {
123-
memoryContext = (await this.memoryContextBuilder.build(text)) || undefined;
123+
memoryContext = (await this.memoryContextBuilder.build(text, { isNewSession: !isResume })) || undefined;
124124
} catch {
125125
// Memory unavailable, continue without it
126126
}

src/memory/__tests__/context-builder.test.ts

Lines changed: 194 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -14,37 +14,47 @@ const TEST_CONFIG: MemoryConfig = {
1414
function createMockMemorySystem(overrides?: {
1515
ready?: boolean;
1616
episodes?: ReturnType<MemorySystem["recallEpisodes"]>;
17+
durableEpisodes?: ReturnType<MemorySystem["recallEpisodes"]>;
1718
facts?: ReturnType<MemorySystem["recallFacts"]>;
1819
procedure?: ReturnType<MemorySystem["findProcedure"]>;
19-
}): MemorySystem {
20-
const ms = {
20+
}) {
21+
const recallEpisodes = mock((_query: string, options?: { strategy?: string }) => {
22+
if (options?.strategy === "metadata") {
23+
return overrides?.durableEpisodes ?? Promise.resolve([]);
24+
}
25+
26+
return overrides?.episodes ?? Promise.resolve([]);
27+
});
28+
const recallFacts = mock(() => overrides?.facts ?? Promise.resolve([]));
29+
const findProcedure = mock(() => overrides?.procedure ?? Promise.resolve(null));
30+
const memory = {
2131
isReady: () => overrides?.ready ?? true,
22-
recallEpisodes: mock(() => overrides?.episodes ?? Promise.resolve([])),
23-
recallFacts: mock(() => overrides?.facts ?? Promise.resolve([])),
24-
findProcedure: mock(() => overrides?.procedure ?? Promise.resolve(null)),
32+
recallEpisodes,
33+
recallFacts,
34+
findProcedure,
2535
} as unknown as MemorySystem;
26-
return ms;
36+
return { memory, recallEpisodes, recallFacts, findProcedure };
2737
}
2838

2939
describe("MemoryContextBuilder", () => {
3040
test("returns empty string when memory system is not ready", async () => {
31-
const memory = createMockMemorySystem({ ready: false });
41+
const { memory } = createMockMemorySystem({ ready: false });
3242
const builder = new MemoryContextBuilder(memory, TEST_CONFIG);
3343

3444
const result = await builder.build("test query");
3545
expect(result).toBe("");
3646
});
3747

3848
test("returns empty string when no memories found", async () => {
39-
const memory = createMockMemorySystem();
49+
const { memory } = createMockMemorySystem();
4050
const builder = new MemoryContextBuilder(memory, TEST_CONFIG);
4151

4252
const result = await builder.build("test query");
4353
expect(result).toBe("");
4454
});
4555

4656
test("formats facts section correctly", async () => {
47-
const memory = createMockMemorySystem({
57+
const { memory } = createMockMemorySystem({
4858
facts: Promise.resolve([
4959
{
5060
id: "f1",
@@ -89,7 +99,7 @@ describe("MemoryContextBuilder", () => {
8999
});
90100

91101
test("formats episodes section correctly", async () => {
92-
const memory = createMockMemorySystem({
102+
const { memory } = createMockMemorySystem({
93103
episodes: Promise.resolve([
94104
{
95105
id: "ep1",
@@ -180,7 +190,7 @@ describe("MemoryContextBuilder", () => {
180190
});
181191

182192
test("formats procedure section correctly", async () => {
183-
const memory = createMockMemorySystem({
193+
const { memory } = createMockMemorySystem({
184194
procedure: Promise.resolve({
185195
id: "proc1",
186196
name: "deploy_staging",
@@ -225,6 +235,177 @@ describe("MemoryContextBuilder", () => {
225235
expect(result).toContain("5 successes");
226236
});
227237

238+
test("adds durable context on the first turn of a new session", async () => {
239+
const { memory, recallEpisodes } = createMockMemorySystem({
240+
episodes: Promise.resolve([
241+
{
242+
id: "ep1",
243+
type: "task" as const,
244+
summary: "Refreshed the deployment runbook",
245+
detail: "Full detail",
246+
parent_id: null,
247+
session_id: "s1",
248+
user_id: "u1",
249+
tools_used: ["Edit"],
250+
files_touched: [],
251+
outcome: "success" as const,
252+
outcome_detail: "",
253+
lessons: [],
254+
started_at: new Date(Date.now() - 3600000).toISOString(),
255+
ended_at: new Date().toISOString(),
256+
duration_seconds: 3600,
257+
importance: 0.9,
258+
access_count: 3,
259+
last_accessed_at: new Date().toISOString(),
260+
decay_rate: 1.0,
261+
},
262+
{
263+
id: "ep2",
264+
type: "interaction" as const,
265+
summary: "Discussed rollout timing for tomorrow",
266+
detail: "Full detail",
267+
parent_id: null,
268+
session_id: "s2",
269+
user_id: "u1",
270+
tools_used: [],
271+
files_touched: [],
272+
outcome: "partial" as const,
273+
outcome_detail: "",
274+
lessons: [],
275+
started_at: new Date(Date.now() - 7200000).toISOString(),
276+
ended_at: new Date().toISOString(),
277+
duration_seconds: 1800,
278+
importance: 0.7,
279+
access_count: 1,
280+
last_accessed_at: new Date().toISOString(),
281+
decay_rate: 1.0,
282+
},
283+
]),
284+
durableEpisodes: Promise.resolve([
285+
{
286+
id: "ep1",
287+
type: "task" as const,
288+
summary: "Refreshed the deployment runbook",
289+
detail: "Full detail",
290+
parent_id: null,
291+
session_id: "s1",
292+
user_id: "u1",
293+
tools_used: ["Edit"],
294+
files_touched: [],
295+
outcome: "success" as const,
296+
outcome_detail: "",
297+
lessons: [],
298+
started_at: new Date(Date.now() - 3600000).toISOString(),
299+
ended_at: new Date().toISOString(),
300+
duration_seconds: 3600,
301+
importance: 0.9,
302+
access_count: 3,
303+
last_accessed_at: new Date().toISOString(),
304+
decay_rate: 1.0,
305+
},
306+
]),
307+
facts: Promise.resolve([
308+
{
309+
id: "f1",
310+
subject: "user",
311+
predicate: "prefers",
312+
object: "small PRs",
313+
natural_language: "The user prefers small PRs",
314+
source_episode_ids: [],
315+
confidence: 0.9,
316+
valid_from: new Date().toISOString(),
317+
valid_until: null,
318+
version: 1,
319+
previous_version_id: null,
320+
category: "user_preference" as const,
321+
tags: [],
322+
},
323+
{
324+
id: "f2",
325+
subject: "repo",
326+
predicate: "uses",
327+
object: "Bun",
328+
natural_language: "This repo uses Bun for task execution",
329+
source_episode_ids: [],
330+
confidence: 0.6,
331+
valid_from: new Date().toISOString(),
332+
valid_until: null,
333+
version: 1,
334+
previous_version_id: null,
335+
category: "codebase" as const,
336+
tags: [],
337+
},
338+
]),
339+
});
340+
341+
const builder = new MemoryContextBuilder(memory, TEST_CONFIG);
342+
const result = await builder.build("help me deploy", { isNewSession: true });
343+
344+
expect(recallEpisodes).toHaveBeenCalledTimes(2);
345+
expect(result).toContain("## Durable Context");
346+
expect(result).toContain("Fact: The user prefers small PRs");
347+
expect(result).toContain("Memory: [task] Refreshed the deployment runbook");
348+
expect(result).toContain("## Known Facts");
349+
expect(result).toContain("This repo uses Bun for task execution");
350+
expect(result).toContain("## Recent Memories");
351+
expect(result).toContain("Discussed rollout timing for tomorrow");
352+
expect(result.split("The user prefers small PRs").length - 1).toBe(1);
353+
expect(result.split("Refreshed the deployment runbook").length - 1).toBe(1);
354+
});
355+
356+
test("skips durable startup context on resumed turns", async () => {
357+
const { memory, recallEpisodes } = createMockMemorySystem({
358+
episodes: Promise.resolve([]),
359+
durableEpisodes: Promise.resolve([
360+
{
361+
id: "ep1",
362+
type: "task" as const,
363+
summary: "Should not be recalled durably",
364+
detail: "Full detail",
365+
parent_id: null,
366+
session_id: "s1",
367+
user_id: "u1",
368+
tools_used: [],
369+
files_touched: [],
370+
outcome: "success" as const,
371+
outcome_detail: "",
372+
lessons: [],
373+
started_at: new Date().toISOString(),
374+
ended_at: new Date().toISOString(),
375+
duration_seconds: 60,
376+
importance: 0.9,
377+
access_count: 0,
378+
last_accessed_at: "",
379+
decay_rate: 1.0,
380+
},
381+
]),
382+
facts: Promise.resolve([
383+
{
384+
id: "f1",
385+
subject: "user",
386+
predicate: "prefers",
387+
object: "small PRs",
388+
natural_language: "The user prefers small PRs",
389+
source_episode_ids: [],
390+
confidence: 0.9,
391+
valid_from: new Date().toISOString(),
392+
valid_until: null,
393+
version: 1,
394+
previous_version_id: null,
395+
category: "user_preference" as const,
396+
tags: [],
397+
},
398+
]),
399+
});
400+
401+
const builder = new MemoryContextBuilder(memory, TEST_CONFIG);
402+
const result = await builder.build("help me deploy");
403+
404+
expect(recallEpisodes).toHaveBeenCalledTimes(1);
405+
expect(result).not.toContain("## Durable Context");
406+
expect(result).toContain("## Known Facts");
407+
});
408+
228409
test("respects token budget and truncates", async () => {
229410
// Create many facts that would exceed a tiny budget
230411
const manyFacts = Array.from({ length: 100 }, (_, i) => ({
@@ -243,7 +424,7 @@ describe("MemoryContextBuilder", () => {
243424
tags: [],
244425
}));
245426

246-
const memory = createMockMemorySystem({
427+
const { memory } = createMockMemorySystem({
247428
facts: Promise.resolve(manyFacts),
248429
});
249430

@@ -259,7 +440,7 @@ describe("MemoryContextBuilder", () => {
259440
});
260441

261442
test("handles errors from memory system gracefully", async () => {
262-
const memory = createMockMemorySystem({
443+
const { memory } = createMockMemorySystem({
263444
episodes: Promise.reject(new Error("Qdrant down")),
264445
facts: Promise.reject(new Error("Qdrant down")),
265446
procedure: Promise.reject(new Error("Qdrant down")),

0 commit comments

Comments
 (0)