Skip to content

Commit 48aaa90

Browse files
committed
feat(context-engine): add subagent memory handoff
1 parent df3905f commit 48aaa90

File tree

2 files changed

+252
-0
lines changed

2 files changed

+252
-0
lines changed

context-engine/basic-memory-context-engine.test.ts

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@ describe("BasicMemoryContextEngine", () => {
3535
search: jest.Mock
3636
recentActivity: jest.Mock
3737
indexConversation: jest.Mock
38+
writeNote: jest.Mock
39+
editNote: jest.Mock
40+
deleteNote: jest.Mock
3841
}
3942

4043
beforeEach(() => {
@@ -56,6 +59,26 @@ describe("BasicMemoryContextEngine", () => {
5659
},
5760
]),
5861
indexConversation: jest.fn().mockResolvedValue(undefined),
62+
writeNote: jest.fn().mockResolvedValue({
63+
title: "subagent-handoff-agent-test-subagent-child-1",
64+
permalink: "agent/subagents/subagent-handoff-agent-test-subagent-child-1",
65+
file_path:
66+
"memory/agent/subagents/subagent-handoff-agent-test-subagent-child-1.md",
67+
content: "",
68+
}),
69+
editNote: jest.fn().mockResolvedValue({
70+
title: "subagent-handoff-agent-test-subagent-child-1",
71+
permalink: "agent/subagents/subagent-handoff-agent-test-subagent-child-1",
72+
file_path:
73+
"memory/agent/subagents/subagent-handoff-agent-test-subagent-child-1.md",
74+
operation: "append",
75+
}),
76+
deleteNote: jest.fn().mockResolvedValue({
77+
title: "subagent-handoff-agent-test-subagent-child-1",
78+
permalink: "agent/subagents/subagent-handoff-agent-test-subagent-child-1",
79+
file_path:
80+
"memory/agent/subagents/subagent-handoff-agent-test-subagent-child-1.md",
81+
}),
5982
}
6083
})
6184

@@ -207,6 +230,102 @@ describe("BasicMemoryContextEngine", () => {
207230
expect(second.systemPromptAddition).toBe(first.systemPromptAddition)
208231
})
209232

233+
it("creates a parent-to-child BM handoff note on subagent spawn", async () => {
234+
const engine = new BasicMemoryContextEngine(
235+
mockClient as unknown as BmClient,
236+
makeConfig(),
237+
)
238+
239+
await engine.bootstrap({
240+
sessionId: "parent-session",
241+
sessionFile: "/tmp/parent-session.jsonl",
242+
})
243+
244+
const preparation = await engine.prepareSubagentSpawn({
245+
parentSessionKey: "parent-session",
246+
childSessionKey: "agent:test:subagent:child-1",
247+
})
248+
249+
expect(preparation).toBeDefined()
250+
expect(mockClient.writeNote).toHaveBeenCalledWith(
251+
"subagent-handoff-agent-test-subagent-child-1",
252+
expect.stringContaining("## Parent Basic Memory Context"),
253+
"agent/subagents",
254+
)
255+
})
256+
257+
it("rolls back the handoff note when subagent spawn fails after preparation", async () => {
258+
const engine = new BasicMemoryContextEngine(
259+
mockClient as unknown as BmClient,
260+
makeConfig(),
261+
)
262+
263+
const preparation = await engine.prepareSubagentSpawn({
264+
parentSessionKey: "parent-session",
265+
childSessionKey: "agent:test:subagent:child-rollback",
266+
})
267+
268+
expect(preparation).toBeDefined()
269+
await preparation?.rollback()
270+
271+
expect(mockClient.deleteNote).toHaveBeenCalledWith(
272+
"agent/subagents/subagent-handoff-agent-test-subagent-child-1",
273+
)
274+
})
275+
276+
it("appends completion details to the handoff note when a child session completes", async () => {
277+
const engine = new BasicMemoryContextEngine(
278+
mockClient as unknown as BmClient,
279+
makeConfig(),
280+
)
281+
282+
await engine.prepareSubagentSpawn({
283+
parentSessionKey: "parent-session",
284+
childSessionKey: "agent:test:subagent:child-complete",
285+
})
286+
287+
await engine.onSubagentEnded({
288+
childSessionKey: "agent:test:subagent:child-complete",
289+
reason: "completed",
290+
})
291+
292+
expect(mockClient.editNote).toHaveBeenCalledWith(
293+
"agent/subagents/subagent-handoff-agent-test-subagent-child-1",
294+
"append",
295+
expect.stringContaining("Reason: completed"),
296+
)
297+
expect(mockClient.editNote).toHaveBeenCalledWith(
298+
"agent/subagents/subagent-handoff-agent-test-subagent-child-1",
299+
"append",
300+
expect.stringContaining("Durable conversation capture continues through the normal afterTurn path."),
301+
)
302+
})
303+
304+
it("handles deleted, released, and swept child endings without errors", async () => {
305+
const reasons = ["deleted", "released", "swept"] as const
306+
307+
for (const reason of reasons) {
308+
const engine = new BasicMemoryContextEngine(
309+
mockClient as unknown as BmClient,
310+
makeConfig(),
311+
)
312+
313+
await engine.prepareSubagentSpawn({
314+
parentSessionKey: "parent-session",
315+
childSessionKey: `agent:test:subagent:${reason}`,
316+
})
317+
318+
await expect(
319+
engine.onSubagentEnded({
320+
childSessionKey: `agent:test:subagent:${reason}`,
321+
reason,
322+
}),
323+
).resolves.toBeUndefined()
324+
}
325+
326+
expect(mockClient.editNote).toHaveBeenCalledTimes(3)
327+
})
328+
210329
it("captures only the current turn after prePromptMessageCount", async () => {
211330
const engine = new BasicMemoryContextEngine(
212331
mockClient as unknown as BmClient,

context-engine/basic-memory-context-engine.ts

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,19 @@ import { log } from "../logger.ts"
1717
const require = createRequire(import.meta.url)
1818
export const MAX_ASSEMBLE_RECALL_CHARS = 1200
1919
const TRUNCATED_RECALL_SUFFIX = "\n\n[Basic Memory recall truncated]"
20+
const SUBAGENT_HANDOFF_FOLDER = "agent/subagents"
21+
const MAX_SUBAGENT_RECALL_CHARS = 800
2022

2123
interface SessionMemoryState {
2224
recallContext: string
2325
}
2426

27+
interface SubagentHandoffState {
28+
noteIdentifier: string
29+
noteTitle: string
30+
parentSessionKey: string
31+
}
32+
2533
function boundRecallContext(context: string): string {
2634
if (context.length <= MAX_ASSEMBLE_RECALL_CHARS) {
2735
return context
@@ -37,6 +45,65 @@ function boundRecallContext(context: string): string {
3745
return `${trimmed}${TRUNCATED_RECALL_SUFFIX}`
3846
}
3947

48+
function slugifySessionKey(sessionKey: string): string {
49+
return sessionKey
50+
.toLowerCase()
51+
.replace(/[^a-z0-9]+/g, "-")
52+
.replace(/^-+|-+$/g, "")
53+
.slice(0, 80)
54+
}
55+
56+
function buildSubagentNoteTitle(childSessionKey: string): string {
57+
return `subagent-handoff-${slugifySessionKey(childSessionKey)}`
58+
}
59+
60+
function buildSubagentHandoffContent(params: {
61+
parentSessionKey: string
62+
childSessionKey: string
63+
recallContext?: string
64+
}): string {
65+
const sections = [
66+
"# Subagent Handoff",
67+
"",
68+
"## Sessions",
69+
`- Parent: ${params.parentSessionKey}`,
70+
`- Child: ${params.childSessionKey}`,
71+
"",
72+
"## Lifecycle",
73+
`- Spawned: ${new Date().toISOString()}`,
74+
]
75+
76+
if (params.recallContext) {
77+
sections.push(
78+
"",
79+
"## Parent Basic Memory Context",
80+
params.recallContext.slice(0, MAX_SUBAGENT_RECALL_CHARS).trimEnd(),
81+
)
82+
}
83+
84+
return sections.join("\n")
85+
}
86+
87+
function buildSubagentCompletionUpdate(params: {
88+
childSessionKey: string
89+
reason: "deleted" | "completed" | "swept" | "released"
90+
}): string {
91+
const statusLine =
92+
params.reason === "completed"
93+
? "Child run completed. Durable conversation capture continues through the normal afterTurn path."
94+
: `Child run ended with reason: ${params.reason}.`
95+
96+
return [
97+
"",
98+
"## Completion",
99+
`- Child: ${params.childSessionKey}`,
100+
`- Ended: ${new Date().toISOString()}`,
101+
`- Reason: ${params.reason}`,
102+
"",
103+
statusLine,
104+
].join("\n")
105+
}
106+
40107
type LegacyContextEngineModule = {
41108
LegacyContextEngine: new () => {
42109
compact(params: {
@@ -76,6 +143,7 @@ export class BasicMemoryContextEngine implements ContextEngine {
76143
} as const
77144

78145
private readonly sessionState = new Map<string, SessionMemoryState>()
146+
private readonly subagentState = new Map<string, SubagentHandoffState>()
79147
private legacyContextEnginePromise:
80148
| Promise<InstanceType<LegacyContextEngineModule["LegacyContextEngine"]>>
81149
| null = null
@@ -179,8 +247,73 @@ export class BasicMemoryContextEngine implements ContextEngine {
179247
return legacy.compact(params)
180248
}
181249

250+
async prepareSubagentSpawn(params: {
251+
parentSessionKey: string
252+
childSessionKey: string
253+
ttlMs?: number
254+
}): Promise<{ rollback: () => Promise<void> } | undefined> {
255+
const parentState = this.sessionState.get(params.parentSessionKey)
256+
const noteTitle = buildSubagentNoteTitle(params.childSessionKey)
257+
258+
try {
259+
const note = await this.client.writeNote(
260+
noteTitle,
261+
buildSubagentHandoffContent({
262+
parentSessionKey: params.parentSessionKey,
263+
childSessionKey: params.childSessionKey,
264+
recallContext: parentState?.recallContext,
265+
}),
266+
SUBAGENT_HANDOFF_FOLDER,
267+
)
268+
269+
this.subagentState.set(params.childSessionKey, {
270+
noteIdentifier: note.permalink,
271+
noteTitle: note.title,
272+
parentSessionKey: params.parentSessionKey,
273+
})
274+
275+
return {
276+
rollback: async () => {
277+
const handoff = this.subagentState.get(params.childSessionKey)
278+
this.subagentState.delete(params.childSessionKey)
279+
if (!handoff) return
280+
281+
try {
282+
await this.client.deleteNote(handoff.noteIdentifier)
283+
} catch (err) {
284+
log.error("context-engine subagent rollback failed", err)
285+
}
286+
},
287+
}
288+
} catch (err) {
289+
log.error("context-engine prepareSubagentSpawn failed", err)
290+
return undefined
291+
}
292+
}
293+
294+
async onSubagentEnded(params: {
295+
childSessionKey: string
296+
reason: "deleted" | "completed" | "swept" | "released"
297+
}): Promise<void> {
298+
const handoff = this.subagentState.get(params.childSessionKey)
299+
if (!handoff) return
300+
301+
this.subagentState.delete(params.childSessionKey)
302+
303+
try {
304+
await this.client.editNote(
305+
handoff.noteIdentifier,
306+
"append",
307+
buildSubagentCompletionUpdate(params),
308+
)
309+
} catch (err) {
310+
log.error("context-engine onSubagentEnded failed", err)
311+
}
312+
}
313+
182314
async dispose(): Promise<void> {
183315
this.sessionState.clear()
316+
this.subagentState.clear()
184317
}
185318

186319
private async getLegacyContextEngine(): Promise<

0 commit comments

Comments
 (0)