Skip to content

Commit a557e34

Browse files
authored
fix(context): auto-inject lessons into mem::context payload (#457) (#458)
Lessons were generated (via mem::lesson-save / mem::reflect) and stored in the lessons KV scope, but never folded into the auto-inject payload the session-start hook prepends to the first turn. Agents only saw lessons if they explicitly called memory_lesson_recall — which they rarely did unprompted. Half the loop: lessons learned, lessons never reused. mem::context now reads lessons KV alongside slots + profile, filters out tombstoned entries + cross-project leakage, ranks project-scoped lessons above globals (1.5x boost) then by confidence, caps at 10, and emits a "## Lessons Learned" block sized like every other context block so the outer token-budget loop drops it cleanly when tight. Surfaced in #381 (@rockarxiv asked where lessons get used — answer was "nowhere, automatically"). Filed as #457, fixed here. - src/functions/context.ts: parallel-fetch lessons with slots + profile, render block after profile and before sessions - test/context-lessons.test.ts: 8 cases covering inclusion, empty state, project ranking, cross-project isolation, deleted-skip, top-N cap, confidence rendering, optional context-string append 1007/1007 tests pass.
1 parent 49377db commit a557e34

2 files changed

Lines changed: 265 additions & 1 deletion

File tree

src/functions/context.ts

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type {
66
ContextBlock,
77
ProjectProfile,
88
MemorySlot,
9+
Lesson,
910
} from "../types.js";
1011
import { KV } from "../state/schema.js";
1112
import { StateKV } from "../state/kv.js";
@@ -39,13 +40,14 @@ export function registerContextFunction(
3940
const budget = data.budget || tokenBudget;
4041
const blocks: ContextBlock[] = [];
4142

42-
const [pinnedSlots, profile] = await Promise.all([
43+
const [pinnedSlots, profile, lessons] = await Promise.all([
4344
isSlotsEnabled()
4445
? listPinnedSlots(kv).catch(() => [] as MemorySlot[])
4546
: Promise.resolve([] as MemorySlot[]),
4647
kv
4748
.get<ProjectProfile>(KV.profiles, data.project)
4849
.catch(() => null),
50+
kv.list<Lesson>(KV.lessons).catch(() => [] as Lesson[]),
4951
]);
5052

5153
const slotContent = renderPinnedContext(pinnedSlots);
@@ -94,6 +96,42 @@ export function registerContextFunction(
9496
}
9597
}
9698

99+
// Lessons — closes the loop opened by mem::lesson-save / mem::reflect.
100+
// Without this block, lessons sit in KV and only surface when the agent
101+
// thinks to call memory_lesson_recall. Ranking puts project-scoped
102+
// lessons ahead of global ones, then weights by confidence; we cap at
103+
// 10 to keep the block bounded since the outer token-budget loop
104+
// below will drop the whole block if it doesn't fit. #457.
105+
const relevantLessons = lessons
106+
.filter((l) => !l.deleted && (!l.project || l.project === data.project))
107+
.sort((a, b) => {
108+
const scoreA = (a.project === data.project ? 1.5 : 1) * a.confidence;
109+
const scoreB = (b.project === data.project ? 1.5 : 1) * b.confidence;
110+
return scoreB - scoreA;
111+
})
112+
.slice(0, 10);
113+
114+
if (relevantLessons.length > 0) {
115+
const items = relevantLessons
116+
.map(
117+
(l) =>
118+
`- (${l.confidence.toFixed(2)}) ${l.content}${l.context ? ` — ${l.context}` : ""}`,
119+
)
120+
.join("\n");
121+
const lessonsContent = `## Lessons Learned\n${items}`;
122+
const mostRecent = relevantLessons.reduce((acc, l) => {
123+
const t = new Date(l.lastReinforcedAt || l.updatedAt).getTime();
124+
return t > acc ? t : acc;
125+
}, 0);
126+
blocks.push({
127+
type: "memory",
128+
content: lessonsContent,
129+
tokens: estimateTokens(lessonsContent),
130+
recency: mostRecent,
131+
sourceIds: relevantLessons.map((l) => l.id),
132+
});
133+
}
134+
97135
const allSessions = await kv.list<Session>(KV.sessions);
98136
const sessions = allSessions
99137
.filter((s) => s.project === data.project && s.id !== data.sessionId)

test/context-lessons.test.ts

Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
import { describe, it, expect, beforeEach, vi } from "vitest";
2+
import { registerContextFunction } from "../src/functions/context.js";
3+
import { KV } from "../src/state/schema.js";
4+
import type { Lesson } from "../src/types.js";
5+
6+
function mockKV() {
7+
const store = new Map<string, Map<string, unknown>>();
8+
return {
9+
get: async <T>(scope: string, key: string): Promise<T | null> => {
10+
return (store.get(scope)?.get(key) as T) ?? null;
11+
},
12+
set: async <T>(scope: string, key: string, data: T): Promise<T> => {
13+
if (!store.has(scope)) store.set(scope, new Map());
14+
store.get(scope)!.set(key, data);
15+
return data;
16+
},
17+
delete: async (scope: string, key: string): Promise<void> => {
18+
store.get(scope)?.delete(key);
19+
},
20+
list: async <T>(scope: string): Promise<T[]> => {
21+
if (!store.has(scope)) return [];
22+
return Array.from(store.get(scope)!.values()) as T[];
23+
},
24+
};
25+
}
26+
27+
type ContextHandler = (data: {
28+
sessionId: string;
29+
project: string;
30+
budget?: number;
31+
}) => Promise<{ context: string; blocks: number; tokens: number }>;
32+
33+
function wireContext(kv: ReturnType<typeof mockKV>, budget = 4000) {
34+
let handler: ContextHandler | undefined;
35+
const sdk = {
36+
registerFunction: vi.fn((id: string, cb: ContextHandler) => {
37+
if (id === "mem::context") handler = cb;
38+
}),
39+
} as unknown as import("iii-sdk").ISdk;
40+
registerContextFunction(sdk, kv as never, budget);
41+
if (!handler) throw new Error("mem::context not registered");
42+
return handler;
43+
}
44+
45+
function makeLesson(over: Partial<Lesson> = {}): Lesson {
46+
const now = new Date().toISOString();
47+
return {
48+
id: over.id ?? `lesson_${Math.random().toString(36).slice(2)}`,
49+
content: over.content ?? "default lesson content",
50+
context: over.context ?? "",
51+
confidence: over.confidence ?? 0.7,
52+
reinforcements: over.reinforcements ?? 1,
53+
source: over.source ?? "manual",
54+
sourceIds: over.sourceIds ?? [],
55+
project: over.project,
56+
tags: over.tags ?? [],
57+
createdAt: over.createdAt ?? now,
58+
updatedAt: over.updatedAt ?? now,
59+
lastReinforcedAt: over.lastReinforcedAt,
60+
lastDecayedAt: over.lastDecayedAt,
61+
decayRate: over.decayRate ?? 0.05,
62+
deleted: over.deleted,
63+
};
64+
}
65+
66+
async function seedLesson(
67+
kv: ReturnType<typeof mockKV>,
68+
partial: Partial<Lesson>,
69+
) {
70+
const lesson = makeLesson(partial);
71+
await kv.set(KV.lessons, lesson.id, lesson);
72+
return lesson;
73+
}
74+
75+
describe("mem::context — lessons auto-injection (#457)", () => {
76+
let kv: ReturnType<typeof mockKV>;
77+
let handler: ContextHandler;
78+
79+
beforeEach(() => {
80+
kv = mockKV();
81+
handler = wireContext(kv);
82+
});
83+
84+
it("includes a 'Lessons Learned' block when KV has lessons for the project", async () => {
85+
await seedLesson(kv, {
86+
id: "lesson_a",
87+
content: "always run npm test before commit",
88+
project: "/tmp/proj",
89+
confidence: 0.85,
90+
});
91+
92+
const result = await handler({
93+
sessionId: "ses_a",
94+
project: "/tmp/proj",
95+
});
96+
97+
expect(result.context).toContain("Lessons Learned");
98+
expect(result.context).toContain("always run npm test before commit");
99+
expect(result.blocks).toBeGreaterThan(0);
100+
});
101+
102+
it("omits the lessons block entirely when KV has no lessons", async () => {
103+
const result = await handler({
104+
sessionId: "ses_empty",
105+
project: "/tmp/proj",
106+
});
107+
108+
expect(result.context).not.toContain("Lessons Learned");
109+
});
110+
111+
it("ranks project-scoped lessons above global lessons", async () => {
112+
await seedLesson(kv, {
113+
id: "lesson_global",
114+
content: "global-lesson-marker",
115+
project: undefined,
116+
confidence: 0.9,
117+
});
118+
await seedLesson(kv, {
119+
id: "lesson_project",
120+
content: "project-lesson-marker",
121+
project: "/tmp/proj",
122+
confidence: 0.7,
123+
});
124+
125+
const result = await handler({
126+
sessionId: "ses_rank",
127+
project: "/tmp/proj",
128+
});
129+
130+
const projectIdx = result.context.indexOf("project-lesson-marker");
131+
const globalIdx = result.context.indexOf("global-lesson-marker");
132+
expect(projectIdx).toBeGreaterThan(-1);
133+
expect(globalIdx).toBeGreaterThan(-1);
134+
expect(projectIdx).toBeLessThan(globalIdx);
135+
});
136+
137+
it("excludes lessons scoped to a different project", async () => {
138+
await seedLesson(kv, {
139+
id: "lesson_other",
140+
content: "other-project-lesson",
141+
project: "/tmp/other-project",
142+
confidence: 0.9,
143+
});
144+
145+
const result = await handler({
146+
sessionId: "ses_isolate",
147+
project: "/tmp/proj",
148+
});
149+
150+
expect(result.context).not.toContain("other-project-lesson");
151+
});
152+
153+
it("excludes deleted lessons", async () => {
154+
await seedLesson(kv, {
155+
id: "lesson_deleted",
156+
content: "tombstoned-lesson",
157+
project: "/tmp/proj",
158+
confidence: 0.9,
159+
deleted: true,
160+
});
161+
162+
const result = await handler({
163+
sessionId: "ses_deleted",
164+
project: "/tmp/proj",
165+
});
166+
167+
expect(result.context).not.toContain("tombstoned-lesson");
168+
});
169+
170+
it("caps at the top 10 lessons by confidence", async () => {
171+
for (let i = 0; i < 15; i++) {
172+
await seedLesson(kv, {
173+
id: `lesson_${i}`,
174+
content: `lesson-marker-${i}`,
175+
project: "/tmp/proj",
176+
confidence: i / 100,
177+
});
178+
}
179+
180+
const result = await handler({
181+
sessionId: "ses_cap",
182+
project: "/tmp/proj",
183+
});
184+
185+
const matched = result.context.match(/lesson-marker-/g) ?? [];
186+
expect(matched.length).toBe(10);
187+
expect(result.context).toContain("lesson-marker-14");
188+
expect(result.context).toContain("lesson-marker-5");
189+
expect(result.context).not.toContain("lesson-marker-0");
190+
});
191+
192+
it("shows lesson confidence in the rendered line", async () => {
193+
await seedLesson(kv, {
194+
id: "lesson_conf",
195+
content: "test confidence rendering",
196+
project: "/tmp/proj",
197+
confidence: 0.83,
198+
});
199+
200+
const result = await handler({
201+
sessionId: "ses_conf",
202+
project: "/tmp/proj",
203+
});
204+
205+
expect(result.context).toContain("(0.83)");
206+
});
207+
208+
it("appends optional context string when present", async () => {
209+
await seedLesson(kv, {
210+
id: "lesson_ctx",
211+
content: "use TaskCreate for >5-file work",
212+
context: "when working on multi-file refactors",
213+
project: "/tmp/proj",
214+
confidence: 0.8,
215+
});
216+
217+
const result = await handler({
218+
sessionId: "ses_ctx",
219+
project: "/tmp/proj",
220+
});
221+
222+
expect(result.context).toContain(
223+
"use TaskCreate for >5-file work — when working on multi-file refactors",
224+
);
225+
});
226+
});

0 commit comments

Comments
 (0)