Skip to content

Commit 7e9c3ba

Browse files
committed
Add durable startup memory context
1 parent 8693453 commit 7e9c3ba

4 files changed

Lines changed: 278 additions & 26 deletions

File tree

docs/memory.md

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -46,11 +46,14 @@ Results are fused using Reciprocal Rank Fusion (RRF). This means searching for "
4646
Before each agent invocation, the context builder:
4747

4848
1. Embeds the user's message
49-
2. Searches episodic memory (top 10 episodes)
50-
3. Searches semantic memory (top 20 facts)
51-
4. Searches procedural memory (top 5 procedures)
52-
5. Budgets results to fit within the token limit (default: 50,000 tokens)
53-
6. Formats results into the memory section of the system prompt
49+
2. On the first turn of a brand-new session, adds a compact durable context section
50+
3. Searches episodic memory (top 10 episodes)
51+
4. Searches semantic memory (top 20 facts)
52+
5. Searches procedural memory (top 5 procedures)
53+
6. Budgets results to fit within the token limit (default: 50,000 tokens)
54+
7. Formats results into the memory section of the system prompt
55+
56+
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.
5457

5558
## Consolidation
5659

src/agent/runtime.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ export class AgentRuntime {
108108
let memoryContext: string | undefined;
109109
if (this.memoryContextBuilder) {
110110
try {
111-
memoryContext = (await this.memoryContextBuilder.build(text)) || undefined;
111+
memoryContext = (await this.memoryContextBuilder.build(text, { isNewSession: !isResume })) || undefined;
112112
} catch {
113113
// Memory unavailable, continue without it
114114
}

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",
@@ -125,7 +135,7 @@ describe("MemoryContextBuilder", () => {
125135
});
126136

127137
test("formats procedure section correctly", async () => {
128-
const memory = createMockMemorySystem({
138+
const { memory } = createMockMemorySystem({
129139
procedure: Promise.resolve({
130140
id: "proc1",
131141
name: "deploy_staging",
@@ -170,6 +180,177 @@ describe("MemoryContextBuilder", () => {
170180
expect(result).toContain("5 successes");
171181
});
172182

183+
test("adds durable context on the first turn of a new session", async () => {
184+
const { memory, recallEpisodes } = createMockMemorySystem({
185+
episodes: Promise.resolve([
186+
{
187+
id: "ep1",
188+
type: "task" as const,
189+
summary: "Refreshed the deployment runbook",
190+
detail: "Full detail",
191+
parent_id: null,
192+
session_id: "s1",
193+
user_id: "u1",
194+
tools_used: ["Edit"],
195+
files_touched: [],
196+
outcome: "success" as const,
197+
outcome_detail: "",
198+
lessons: [],
199+
started_at: new Date(Date.now() - 3600000).toISOString(),
200+
ended_at: new Date().toISOString(),
201+
duration_seconds: 3600,
202+
importance: 0.9,
203+
access_count: 3,
204+
last_accessed_at: new Date().toISOString(),
205+
decay_rate: 1.0,
206+
},
207+
{
208+
id: "ep2",
209+
type: "interaction" as const,
210+
summary: "Discussed rollout timing for tomorrow",
211+
detail: "Full detail",
212+
parent_id: null,
213+
session_id: "s2",
214+
user_id: "u1",
215+
tools_used: [],
216+
files_touched: [],
217+
outcome: "partial" as const,
218+
outcome_detail: "",
219+
lessons: [],
220+
started_at: new Date(Date.now() - 7200000).toISOString(),
221+
ended_at: new Date().toISOString(),
222+
duration_seconds: 1800,
223+
importance: 0.7,
224+
access_count: 1,
225+
last_accessed_at: new Date().toISOString(),
226+
decay_rate: 1.0,
227+
},
228+
]),
229+
durableEpisodes: Promise.resolve([
230+
{
231+
id: "ep1",
232+
type: "task" as const,
233+
summary: "Refreshed the deployment runbook",
234+
detail: "Full detail",
235+
parent_id: null,
236+
session_id: "s1",
237+
user_id: "u1",
238+
tools_used: ["Edit"],
239+
files_touched: [],
240+
outcome: "success" as const,
241+
outcome_detail: "",
242+
lessons: [],
243+
started_at: new Date(Date.now() - 3600000).toISOString(),
244+
ended_at: new Date().toISOString(),
245+
duration_seconds: 3600,
246+
importance: 0.9,
247+
access_count: 3,
248+
last_accessed_at: new Date().toISOString(),
249+
decay_rate: 1.0,
250+
},
251+
]),
252+
facts: Promise.resolve([
253+
{
254+
id: "f1",
255+
subject: "user",
256+
predicate: "prefers",
257+
object: "small PRs",
258+
natural_language: "The user prefers small PRs",
259+
source_episode_ids: [],
260+
confidence: 0.9,
261+
valid_from: new Date().toISOString(),
262+
valid_until: null,
263+
version: 1,
264+
previous_version_id: null,
265+
category: "user_preference" as const,
266+
tags: [],
267+
},
268+
{
269+
id: "f2",
270+
subject: "repo",
271+
predicate: "uses",
272+
object: "Bun",
273+
natural_language: "This repo uses Bun for task execution",
274+
source_episode_ids: [],
275+
confidence: 0.6,
276+
valid_from: new Date().toISOString(),
277+
valid_until: null,
278+
version: 1,
279+
previous_version_id: null,
280+
category: "codebase" as const,
281+
tags: [],
282+
},
283+
]),
284+
});
285+
286+
const builder = new MemoryContextBuilder(memory, TEST_CONFIG);
287+
const result = await builder.build("help me deploy", { isNewSession: true });
288+
289+
expect(recallEpisodes).toHaveBeenCalledTimes(2);
290+
expect(result).toContain("## Durable Context");
291+
expect(result).toContain("Fact: The user prefers small PRs");
292+
expect(result).toContain("Memory: [task] Refreshed the deployment runbook");
293+
expect(result).toContain("## Known Facts");
294+
expect(result).toContain("This repo uses Bun for task execution");
295+
expect(result).toContain("## Recent Memories");
296+
expect(result).toContain("Discussed rollout timing for tomorrow");
297+
expect(result.split("The user prefers small PRs").length - 1).toBe(1);
298+
expect(result.split("Refreshed the deployment runbook").length - 1).toBe(1);
299+
});
300+
301+
test("skips durable startup context on resumed turns", async () => {
302+
const { memory, recallEpisodes } = createMockMemorySystem({
303+
episodes: Promise.resolve([]),
304+
durableEpisodes: Promise.resolve([
305+
{
306+
id: "ep1",
307+
type: "task" as const,
308+
summary: "Should not be recalled durably",
309+
detail: "Full detail",
310+
parent_id: null,
311+
session_id: "s1",
312+
user_id: "u1",
313+
tools_used: [],
314+
files_touched: [],
315+
outcome: "success" as const,
316+
outcome_detail: "",
317+
lessons: [],
318+
started_at: new Date().toISOString(),
319+
ended_at: new Date().toISOString(),
320+
duration_seconds: 60,
321+
importance: 0.9,
322+
access_count: 0,
323+
last_accessed_at: "",
324+
decay_rate: 1.0,
325+
},
326+
]),
327+
facts: Promise.resolve([
328+
{
329+
id: "f1",
330+
subject: "user",
331+
predicate: "prefers",
332+
object: "small PRs",
333+
natural_language: "The user prefers small PRs",
334+
source_episode_ids: [],
335+
confidence: 0.9,
336+
valid_from: new Date().toISOString(),
337+
valid_until: null,
338+
version: 1,
339+
previous_version_id: null,
340+
category: "user_preference" as const,
341+
tags: [],
342+
},
343+
]),
344+
});
345+
346+
const builder = new MemoryContextBuilder(memory, TEST_CONFIG);
347+
const result = await builder.build("help me deploy");
348+
349+
expect(recallEpisodes).toHaveBeenCalledTimes(1);
350+
expect(result).not.toContain("## Durable Context");
351+
expect(result).toContain("## Known Facts");
352+
});
353+
173354
test("respects token budget and truncates", async () => {
174355
// Create many facts that would exceed a tiny budget
175356
const manyFacts = Array.from({ length: 100 }, (_, i) => ({
@@ -188,7 +369,7 @@ describe("MemoryContextBuilder", () => {
188369
tags: [],
189370
}));
190371

191-
const memory = createMockMemorySystem({
372+
const { memory } = createMockMemorySystem({
192373
facts: Promise.resolve(manyFacts),
193374
});
194375

@@ -204,7 +385,7 @@ describe("MemoryContextBuilder", () => {
204385
});
205386

206387
test("handles errors from memory system gracefully", async () => {
207-
const memory = createMockMemorySystem({
388+
const { memory } = createMockMemorySystem({
208389
episodes: Promise.reject(new Error("Qdrant down")),
209390
facts: Promise.reject(new Error("Qdrant down")),
210391
procedure: Promise.reject(new Error("Qdrant down")),

0 commit comments

Comments
 (0)