Skip to content

Commit 4ad1002

Browse files
committed
refactor: add parent session fork transaction seam
1 parent 756ed60 commit 4ad1002

10 files changed

Lines changed: 529 additions & 135 deletions

src/agents/openclaw-tools.subagents.sessions-spawn.test-harness.ts

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -206,9 +206,26 @@ export async function getSessionsSpawnTool(opts: CreateOpenClawToolsOpts) {
206206
status: "fork",
207207
maxTokens: 100_000,
208208
}),
209-
forkSessionFromParent: async () => ({
210-
sessionId: "forked-session-id",
211-
sessionFile: "/tmp/forked-session.jsonl",
209+
forkSessionEntryFromParent: async () => ({
210+
status: "forked",
211+
fork: {
212+
sessionId: "forked-session-id",
213+
sessionFile: "/tmp/forked-session.jsonl",
214+
},
215+
parentEntry: {
216+
sessionId: "parent-session-id",
217+
updatedAt: Date.now(),
218+
},
219+
sessionEntry: {
220+
sessionId: "forked-session-id",
221+
sessionFile: "/tmp/forked-session.jsonl",
222+
forkedFromParent: true,
223+
updatedAt: Date.now(),
224+
},
225+
decision: {
226+
status: "fork",
227+
maxTokens: 100_000,
228+
},
212229
}),
213230
updateSessionStore: async (_storePath, mutator) => mutator({}),
214231
});

src/agents/subagent-spawn.context.test.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@ type GatewayRequest = { method?: string; params?: Record<string, unknown> };
1212
describe("sessions_spawn context modes", () => {
1313
const storePath = "/tmp/subagent-context-session-store.json";
1414
const callGatewayMock = vi.fn();
15+
const loadSessionStoreMock = vi.fn();
1516
const updateSessionStoreMock = vi.fn();
17+
const forkSessionEntryFromParentMock = vi.fn();
1618
const forkSessionFromParentMock = vi.fn();
1719
const ensureContextEnginesInitializedMock = vi.fn();
1820
const resolveContextEngineMock = vi.fn();
@@ -23,7 +25,9 @@ describe("sessions_spawn context modes", () => {
2325
beforeAll(async () => {
2426
({ spawnSubagentDirect } = await loadSubagentSpawnModuleForTest({
2527
callGatewayMock,
28+
loadSessionStoreMock,
2629
updateSessionStoreMock,
30+
forkSessionEntryFromParentMock,
2731
forkSessionFromParentMock,
2832
ensureContextEnginesInitializedMock,
2933
resolveContextEngineMock,
@@ -33,7 +37,9 @@ describe("sessions_spawn context modes", () => {
3337

3438
beforeEach(() => {
3539
callGatewayMock.mockReset();
40+
loadSessionStoreMock.mockReset();
3641
updateSessionStoreMock.mockReset();
42+
forkSessionEntryFromParentMock.mockReset();
3743
forkSessionFromParentMock.mockReset();
3844
ensureContextEnginesInitializedMock.mockReset();
3945
resolveContextEngineMock.mockReset();
@@ -42,12 +48,78 @@ describe("sessions_spawn context modes", () => {
4248
});
4349

4450
function usePersistentStoreMock(store: SessionStore) {
51+
loadSessionStoreMock.mockReturnValue(store);
4552
updateSessionStoreMock.mockImplementation(async (_storePath: unknown, mutator: unknown) => {
4653
if (typeof mutator !== "function") {
4754
throw new Error("missing session store mutator");
4855
}
4956
return await mutator(store);
5057
});
58+
forkSessionEntryFromParentMock.mockImplementation(
59+
async (params: {
60+
agentId: string;
61+
fallbackEntry?: Record<string, unknown>;
62+
parentStoreKeys?: string[];
63+
sessionKey: string;
64+
sessionsDir?: string;
65+
}) => {
66+
const parentEntry = params.parentStoreKeys
67+
?.map((key) => store[key])
68+
.find((entry): entry is Record<string, unknown> => Boolean(entry));
69+
const maxTokens = 100_000;
70+
const parentTokens = parentEntry?.totalTokens;
71+
if (
72+
typeof parentTokens === "number" &&
73+
Number.isFinite(parentTokens) &&
74+
parentTokens > maxTokens
75+
) {
76+
const sessionEntry = {
77+
...params.fallbackEntry,
78+
...store[params.sessionKey],
79+
};
80+
return {
81+
status: "skipped",
82+
reason: "decision-skip",
83+
parentEntry,
84+
sessionEntry,
85+
decision: {
86+
status: "skip",
87+
reason: "parent-too-large",
88+
maxTokens,
89+
parentTokens,
90+
message: `Parent context is too large to fork (${parentTokens}/${maxTokens} tokens); starting with isolated context instead.`,
91+
},
92+
};
93+
}
94+
const fork = await forkSessionFromParentMock({
95+
parentEntry,
96+
agentId: params.agentId,
97+
sessionsDir: params.sessionsDir,
98+
});
99+
if (!fork) {
100+
return { status: "failed" };
101+
}
102+
const sessionEntry = {
103+
...params.fallbackEntry,
104+
...store[params.sessionKey],
105+
sessionId: fork.sessionId,
106+
sessionFile: fork.sessionFile,
107+
forkedFromParent: true,
108+
};
109+
store[params.sessionKey] = sessionEntry;
110+
return {
111+
status: "forked",
112+
fork,
113+
parentEntry,
114+
sessionEntry,
115+
decision: {
116+
status: "fork",
117+
maxTokens,
118+
...(typeof parentTokens === "number" ? { parentTokens } : {}),
119+
},
120+
};
121+
},
122+
);
51123
}
52124

53125
function requireAcceptedResult(result: Awaited<ReturnType<typeof spawnSubagentDirect>>) {

src/agents/subagent-spawn.runtime.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ export {
55
export { getRuntimeConfig } from "../config/config.js";
66
export { loadSessionStore, mergeSessionEntry, updateSessionStore } from "../config/sessions.js";
77
export {
8+
forkSessionEntryFromParent,
89
forkSessionFromParent,
910
resolveParentForkDecision,
1011
type ParentForkDecision,

src/agents/subagent-spawn.test-helpers.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ export async function loadSubagentSpawnModuleForTest(params: {
125125
loadSessionStoreMock?: MockFn;
126126
ensureContextEnginesInitializedMock?: MockFn;
127127
updateSessionStoreMock?: MockFn;
128+
forkSessionEntryFromParentMock?: MockFn;
128129
forkSessionFromParentMock?: MockFn;
129130
resolveContextEngineMock?: MockFn;
130131
resolveParentForkDecisionMock?: MockFn;
@@ -197,6 +198,27 @@ export async function loadSubagentSpawnModuleForTest(params: {
197198
vi.doMock("./subagent-spawn.runtime.js", () => ({
198199
callGateway: (opts: unknown) => params.callGatewayMock(opts),
199200
buildSubagentSystemPrompt: () => "system-prompt",
201+
forkSessionEntryFromParent:
202+
params.forkSessionEntryFromParentMock ??
203+
(async () => {
204+
const fork = (params.forkSessionFromParentMock
205+
? await params.forkSessionFromParentMock()
206+
: { sessionId: "forked-session-id", sessionFile: "/tmp/forked-session.jsonl" }) as
207+
| { sessionId: string; sessionFile: string }
208+
| null;
209+
if (!fork) {
210+
return { status: "failed" };
211+
}
212+
return {
213+
status: "forked",
214+
fork,
215+
sessionEntry: {
216+
sessionId: fork.sessionId,
217+
sessionFile: fork.sessionFile,
218+
forkedFromParent: true,
219+
},
220+
};
221+
}),
200222
forkSessionFromParent:
201223
params.forkSessionFromParentMock ??
202224
(async () => ({ sessionId: "forked-session-id", sessionFile: "/tmp/forked-session.jsonl" })),

src/agents/subagent-spawn.ts

Lines changed: 43 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ import {
8383
buildSubagentSystemPrompt,
8484
callGateway,
8585
emitSessionLifecycleEvent,
86-
forkSessionFromParent,
86+
forkSessionEntryFromParent,
8787
getGlobalHookRunner,
8888
getSessionBindingService,
8989
getRuntimeConfig,
@@ -128,7 +128,7 @@ function resolveConfiguredAgentIds(cfg: OpenClawConfig): string[] {
128128

129129
type SubagentSpawnDeps = {
130130
callGateway: typeof callGateway;
131-
forkSessionFromParent: typeof forkSessionFromParent;
131+
forkSessionEntryFromParent: typeof forkSessionEntryFromParent;
132132
getGlobalHookRunner: () => SubagentLifecycleHookRunner | null;
133133
getRuntimeConfig: typeof getRuntimeConfig;
134134
ensureContextEnginesInitialized: typeof ensureContextEnginesInitialized;
@@ -139,7 +139,7 @@ type SubagentSpawnDeps = {
139139

140140
const defaultSubagentSpawnDeps: SubagentSpawnDeps = {
141141
callGateway,
142-
forkSessionFromParent,
142+
forkSessionEntryFromParent,
143143
getGlobalHookRunner,
144144
getRuntimeConfig,
145145
ensureContextEnginesInitialized,
@@ -474,52 +474,47 @@ async function prepareSubagentSessionContext(params: {
474474
const sessionsDir = path.dirname(parentTarget.storePath);
475475

476476
try {
477-
const forked = (await updateSubagentSessionStore(childTarget.storePath, async (store) => {
478-
parentEntry = resolveStoreEntryByKeys(store, parentTarget.storeKeys);
479-
childEntry = resolveStoreEntryByKeys(store, childTarget.storeKeys);
480-
481-
if (params.targetAgentId !== params.requesterAgentId) {
482-
throw new Error(
483-
'context="fork" currently requires the same target agent as the requester; use context="isolated" for cross-agent spawns.',
484-
);
485-
}
486-
if (!parentEntry?.sessionId) {
487-
throw new Error(
488-
'context="fork" requested but the requester session transcript is not available.',
489-
);
490-
}
491-
const forkDecision = await subagentSpawnDeps.resolveParentForkDecision({
492-
parentEntry,
493-
storePath: parentTarget.storePath,
494-
});
495-
if (forkDecision.status === "skip") {
496-
forkFallbackNote = forkDecision.message;
497-
return null;
498-
}
477+
if (params.targetAgentId !== params.requesterAgentId) {
478+
throw new Error(
479+
'context="fork" currently requires the same target agent as the requester; use context="isolated" for cross-agent spawns.',
480+
);
481+
}
499482

500-
const fork = await subagentSpawnDeps.forkSessionFromParent({
501-
parentEntry,
502-
agentId: params.requesterAgentId,
503-
sessionsDir,
504-
});
505-
if (!fork) {
506-
throw new Error(
507-
'context="fork" requested but OpenClaw could not fork the requester transcript.',
508-
);
509-
}
510-
pruneLegacyStoreKeys({
511-
store,
512-
canonicalKey: childTarget.canonicalKey,
513-
candidates: childTarget.storeKeys,
514-
});
515-
store[childTarget.canonicalKey] = mergeSessionEntry(store[childTarget.canonicalKey], {
516-
sessionId: fork.sessionId,
517-
sessionFile: fork.sessionFile,
518-
forkedFromParent: true,
519-
});
520-
childEntry = store[childTarget.canonicalKey];
521-
return fork;
522-
})) as { sessionId: string; sessionFile: string } | null;
483+
const forkedResult = await subagentSpawnDeps.forkSessionEntryFromParent({
484+
storePath: childTarget.storePath,
485+
parentSessionKey: parentTarget.canonicalKey,
486+
parentStoreKeys: parentTarget.storeKeys,
487+
sessionKey: childTarget.canonicalKey,
488+
sessionStoreKeys: childTarget.storeKeys,
489+
fallbackEntry: { sessionId: "", updatedAt: Date.now() },
490+
agentId: params.requesterAgentId,
491+
sessionsDir,
492+
});
493+
if (forkedResult.status === "missing-parent") {
494+
throw new Error(
495+
'context="fork" requested but the requester session transcript is not available.',
496+
);
497+
}
498+
if (forkedResult.status === "failed" || forkedResult.status === "missing-entry") {
499+
throw new Error(
500+
'context="fork" requested but OpenClaw could not fork the requester transcript.',
501+
);
502+
}
503+
parentEntry = forkedResult.parentEntry;
504+
childEntry = forkedResult.sessionEntry;
505+
if (forkedResult.status === "skipped") {
506+
forkFallbackNote = forkedResult.decision?.status === "skip" ? forkedResult.decision.message : undefined;
507+
}
508+
if (forkedResult.status === "forked") {
509+
childEntry = forkedResult.sessionEntry;
510+
}
511+
const forked =
512+
forkedResult.status === "forked"
513+
? {
514+
sessionId: forkedResult.fork.sessionId,
515+
sessionFile: forkedResult.fork.sessionFile,
516+
}
517+
: null;
523518

524519
if (params.contextMode === "fork") {
525520
if (!parentEntry || !forked) {

0 commit comments

Comments
 (0)