Skip to content

Commit 5d9a2b1

Browse files
authored
feat(context-engine): report compaction successors as typed session targets (#101182)
1 parent 6192b03 commit 5d9a2b1

9 files changed

Lines changed: 167 additions & 27 deletions

File tree

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
8e2a9a52395b4260db088d846fbe05c0b0efb49206fa269defca962ffd460b56 plugin-sdk-api-baseline.json
2-
f47745180987a636a48bad5c5b68b517894f5fbc5a09aa69a310fe482bc03450 plugin-sdk-api-baseline.jsonl
1+
6e49a90dc8b8f7e935705d0097ceb95f577b59982ee4d8e9fe9c18be49a69a6a plugin-sdk-api-baseline.json
2+
2a6c0ffe663d64b4a7c0d9caba4c51fb95507116357aa073874f0d5014bbb3c4 plugin-sdk-api-baseline.jsonl

docs/concepts/context-engine.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -217,8 +217,11 @@ Required members:
217217
</ParamField>
218218

219219
`compact` returns a `CompactResult`. When compaction rotates the active
220-
transcript, `result.sessionId` and `result.sessionFile` identify the successor
221-
session that the next retry or turn must use.
220+
transcript, `result.sessionTarget` (a typed `ContextEngineSessionTarget`
221+
carrying the storage mode, session identity, and transcript artifact path)
222+
identifies the successor session that the next retry or turn must use;
223+
`result.sessionId` mirrors the successor id. `result.sessionFile` is
224+
deprecated - report successors through `sessionTarget` instead.
222225

223226
Optional members:
224227

scripts/plugin-sdk-surface-report.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,7 @@ export function readPluginSdkSurfaceBudgets(env = process.env) {
195195
),
196196
publicExports: readPluginSdkSurfaceBudgetEnv(
197197
"OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_EXPORTS",
198-
10462,
198+
10463,
199199
env,
200200
),
201201
publicFunctionExports: readPluginSdkSurfaceBudgetEnv(

src/agents/embedded-agent-runner/compact.queued.ts

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,11 @@ import {
88
resolveContextEngineOwnerPluginId,
99
} from "../../context-engine/registry.js";
1010
import { buildContextEngineRuntimeSettings } from "../../context-engine/runtime-settings.js";
11-
import type {
12-
ContextEngine,
13-
ContextEngineRuntimeContext,
14-
ContextEngineRuntimeSettings,
11+
import {
12+
resolveCompactionSuccessorTranscript,
13+
type ContextEngine,
14+
type ContextEngineRuntimeContext,
15+
type ContextEngineRuntimeSettings,
1516
} from "../../context-engine/types.js";
1617
import {
1718
createFileBackedCompactionCheckpointStore,
@@ -440,8 +441,9 @@ export async function compactEmbeddedAgentSession(
440441
reason: formatErrorMessage(compactErr),
441442
};
442443
}
443-
const delegatedSessionId = result.result?.sessionId;
444-
const delegatedSessionFile = result.result?.sessionFile;
444+
const delegatedSuccessor = resolveCompactionSuccessorTranscript(result);
445+
const delegatedSessionId = delegatedSuccessor.sessionId;
446+
const delegatedSessionFile = delegatedSuccessor.sessionFile;
445447
const delegatedRotatedTranscript =
446448
(typeof delegatedSessionId === "string" && delegatedSessionId !== params.sessionId) ||
447449
(typeof delegatedSessionFile === "string" && delegatedSessionFile !== params.sessionFile);

src/agents/embedded-agent-runner/run.ts

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
resolveContextEngineOwnerPluginId,
2323
} from "../../context-engine/registry.js";
2424
import { buildContextEngineRuntimeSettings } from "../../context-engine/runtime-settings.js";
25+
import { resolveCompactionSuccessorTranscript } from "../../context-engine/types.js";
2526
import {
2627
assertAgentRunLifecycleGenerationCurrent,
2728
captureAgentRunLifecycleGeneration,
@@ -1873,13 +1874,12 @@ async function runEmbeddedAgentInternal(
18731874
compactResult: Awaited<ReturnType<typeof contextEngine.compact>>,
18741875
): string | undefined => {
18751876
const previousSessionId = activeSessionId;
1876-
const nextSessionId = compactResult.result?.sessionId;
1877-
const nextSessionFile = compactResult.result?.sessionFile;
1878-
adoptActiveSessionId(nextSessionId);
1879-
if (nextSessionFile && nextSessionFile !== activeSessionFile) {
1880-
activeSessionFile = nextSessionFile;
1877+
const successor = resolveCompactionSuccessorTranscript(compactResult);
1878+
adoptActiveSessionId(successor.sessionId);
1879+
if (successor.sessionFile && successor.sessionFile !== activeSessionFile) {
1880+
activeSessionFile = successor.sessionFile;
18811881
}
1882-
return nextSessionId && nextSessionId !== previousSessionId
1882+
return successor.sessionId && successor.sessionId !== previousSessionId
18831883
? previousSessionId
18841884
: undefined;
18851885
};
@@ -1939,7 +1939,9 @@ async function runEmbeddedAgentInternal(
19391939
messageCount: -1,
19401940
compactedCount: -1,
19411941
tokenCount: compactResult.result?.tokensAfter,
1942-
sessionFile: compactResult.result?.sessionFile ?? activeSessionFile,
1942+
sessionFile:
1943+
resolveCompactionSuccessorTranscript(compactResult).sessionFile ??
1944+
activeSessionFile,
19431945
...(previousSessionId ? { previousSessionId } : {}),
19441946
},
19451947
resolveActiveHookContext(),

src/context-engine/context-engine.test.ts

Lines changed: 78 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -26,14 +26,15 @@ import type {
2626
ContextEngineFactoryContext,
2727
ContextEngineRegistrationResult,
2828
} from "./registry.js";
29-
import type {
30-
ContextEngine,
31-
ContextEngineInfo,
32-
AssembleResult,
33-
CompactResult,
34-
ContextEngineMaintenanceResult,
35-
BootstrapResult,
36-
IngestResult,
29+
import {
30+
resolveCompactionSuccessorTranscript,
31+
type ContextEngine,
32+
type ContextEngineInfo,
33+
type AssembleResult,
34+
type CompactResult,
35+
type ContextEngineMaintenanceResult,
36+
type BootstrapResult,
37+
type IngestResult,
3738
} from "./types.js";
3839

3940
const { compactEmbeddedAgentSessionDirectMock } = vi.hoisted(() => ({
@@ -599,8 +600,77 @@ describe("Engine contract tests", () => {
599600
tokensBefore: 0,
600601
tokensAfter: 0,
601602
details: undefined,
603+
sessionTarget: {
604+
kind: "file",
605+
sessionId: "s2",
606+
sessionFile: "/tmp/session.json",
607+
},
608+
},
609+
});
610+
});
611+
612+
it("delegateCompactionToRuntime reports the rotated successor as a typed session target", async () => {
613+
compactEmbeddedAgentSessionDirectMock.mockResolvedValue({
614+
ok: true,
615+
compacted: true,
616+
result: {
617+
summary: "compacted",
618+
firstKeptEntryId: "e1",
619+
tokensBefore: 100,
620+
tokensAfter: 10,
621+
sessionId: "rotated-id",
622+
sessionFile: "/tmp/rotated.jsonl",
602623
},
603624
});
625+
626+
const result = await delegateCompactionToRuntime({
627+
sessionId: "s-rotate",
628+
sessionKey: "agent:main:test",
629+
sessionFile: "/tmp/session.json",
630+
runtimeContext: {
631+
workspaceDir: "/tmp/workspace",
632+
agentId: "main",
633+
},
634+
});
635+
636+
expect(result.result?.sessionTarget).toEqual({
637+
kind: "file",
638+
agentId: "main",
639+
sessionId: "rotated-id",
640+
sessionKey: "agent:main:test",
641+
sessionFile: "/tmp/rotated.jsonl",
642+
});
643+
// Deprecated raw fields stay populated for shipped plugin-sdk readers.
644+
expect(result.result?.sessionId).toBe("rotated-id");
645+
expect(result.result?.sessionFile).toBe("/tmp/rotated.jsonl");
646+
});
647+
648+
it("resolveCompactionSuccessorTranscript prefers the typed target over deprecated raw fields", () => {
649+
expect(
650+
resolveCompactionSuccessorTranscript({
651+
ok: true,
652+
compacted: true,
653+
result: {
654+
tokensBefore: 1,
655+
sessionId: "raw-id",
656+
sessionFile: "/tmp/raw.jsonl",
657+
sessionTarget: {
658+
kind: "file",
659+
sessionId: "target-id",
660+
sessionFile: "/tmp/target.jsonl",
661+
},
662+
},
663+
}),
664+
).toEqual({ sessionId: "target-id", sessionFile: "/tmp/target.jsonl" });
665+
666+
// Raw-field fallback covers shipped engines that predate sessionTarget.
667+
expect(
668+
resolveCompactionSuccessorTranscript({
669+
ok: true,
670+
compacted: true,
671+
result: { tokensBefore: 1, sessionId: "raw-id", sessionFile: "/tmp/raw.jsonl" },
672+
}),
673+
).toEqual({ sessionId: "raw-id", sessionFile: "/tmp/raw.jsonl" });
604674
});
605675

606676
it("delegateCompactionToRuntime forwards the caller abortSignal to the runtime (#89868)", async () => {

src/context-engine/delegate.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,13 @@ export async function delegateCompactionToRuntime(
5656
typeof runtimeContext.workspaceDir === "string" ? runtimeContext.workspaceDir : process.cwd(),
5757
});
5858

59+
// Post-compaction live session: the runtime reports rotation through the
60+
// result sessionId/sessionFile pair; otherwise the input session stays live.
61+
const successorSessionId = result.result?.sessionId ?? params.sessionId;
62+
const successorSessionFile = result.result?.sessionFile ?? params.sessionFile;
63+
const agentId = runtimeContext.sessionTarget?.agentId ?? runtimeContext.agentId;
64+
const sessionKey = params.sessionKey ?? runtimeContext.sessionKey;
65+
5966
return {
6067
ok: result.ok,
6168
compacted: result.compacted,
@@ -68,7 +75,16 @@ export async function delegateCompactionToRuntime(
6875
tokensAfter: result.result.tokensAfter,
6976
details: result.result.details,
7077
sessionId: result.result.sessionId,
78+
// Deprecated raw path stays populated for shipped plugin-sdk readers
79+
// of the delegate result; sessionTarget is the canonical successor.
7180
sessionFile: result.result.sessionFile,
81+
sessionTarget: {
82+
kind: "file",
83+
...(agentId ? { agentId } : {}),
84+
sessionId: successorSessionId,
85+
...(sessionKey ? { sessionKey } : {}),
86+
sessionFile: successorSessionFile,
87+
},
7288
}
7389
: undefined,
7490
};

src/context-engine/types.ts

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,25 @@ export class ContextEngineRuntimeSettingsUnsupportedError extends Error {
125125
}
126126
}
127127

128+
/**
129+
* Typed session target for compaction results.
130+
*
131+
* Makes the storage mode explicit and carries precise session identity
132+
* instead of a raw file path string. File-backed is the only storage mode
133+
* today; new storage modes extend this union when they ship.
134+
*/
135+
export type ContextEngineSessionTarget = {
136+
kind: "file";
137+
/** Agent that owns the session, when the caller resolved it. */
138+
agentId?: string;
139+
/** Runtime session id for the post-compaction live session. */
140+
sessionId: string;
141+
/** Stable session key used for aliases, policy, and store resolution. */
142+
sessionKey?: string;
143+
/** Live transcript artifact path for the file-backed session. */
144+
sessionFile: string;
145+
};
146+
128147
export type CompactResult = {
129148
ok: boolean;
130149
compacted: boolean;
@@ -137,11 +156,38 @@ export type CompactResult = {
137156
details?: unknown;
138157
/** Session id after compaction, when the runtime rotated transcripts. */
139158
sessionId?: string;
140-
/** Session file after compaction, when the runtime rotated transcripts. */
159+
/** Typed post-compaction live session target; successor when the runtime rotated transcripts. */
160+
sessionTarget?: ContextEngineSessionTarget;
161+
/**
162+
* Raw session file path after compaction.
163+
*
164+
* @deprecated Use `sessionTarget`. Shipped plugin-sdk contract: released
165+
* third-party context engines (v2026.6.x and earlier) report rotated
166+
* transcripts through this field. Remove once typed session targets are
167+
* the only successor contract.
168+
*/
141169
sessionFile?: string;
142170
};
143171
};
144172

173+
/**
174+
* Resolve the post-compaction live transcript identity from a compact result.
175+
*
176+
* Prefers the typed `sessionTarget`. Reading the raw fields is the named
177+
* compat path for shipped third-party engines that predate `sessionTarget`;
178+
* it is removed together with the deprecated `sessionFile` result field.
179+
*/
180+
export function resolveCompactionSuccessorTranscript(result: CompactResult): {
181+
sessionId?: string;
182+
sessionFile?: string;
183+
} {
184+
const target = result.result?.sessionTarget;
185+
return {
186+
sessionId: target?.sessionId ?? result.result?.sessionId,
187+
sessionFile: target?.sessionFile ?? result.result?.sessionFile,
188+
};
189+
}
190+
145191
export type IngestResult = {
146192
/** Whether the message was ingested (false if duplicate or no-op) */
147193
ingested: boolean;

src/plugin-sdk/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,7 @@ export type {
135135
ContextEngineRuntimeMode,
136136
ContextEngineRuntimeSettings,
137137
ContextEngineSelectionSource,
138+
ContextEngineSessionTarget,
138139
IngestBatchResult,
139140
IngestResult,
140141
SubagentEndReason,

0 commit comments

Comments
 (0)