feat(context-engine): report compaction successors as typed session targets (#101182)

This commit is contained in:
Josh Lehman
2026-07-06 22:14:46 -07:00
committed by GitHub
parent 6192b037bb
commit 5d9a2b114f
9 changed files with 167 additions and 27 deletions

View File

@@ -1,2 +1,2 @@
8e2a9a52395b4260db088d846fbe05c0b0efb49206fa269defca962ffd460b56 plugin-sdk-api-baseline.json
f47745180987a636a48bad5c5b68b517894f5fbc5a09aa69a310fe482bc03450 plugin-sdk-api-baseline.jsonl
6e49a90dc8b8f7e935705d0097ceb95f577b59982ee4d8e9fe9c18be49a69a6a plugin-sdk-api-baseline.json
2a6c0ffe663d64b4a7c0d9caba4c51fb95507116357aa073874f0d5014bbb3c4 plugin-sdk-api-baseline.jsonl

View File

@@ -217,8 +217,11 @@ Required members:
</ParamField>
`compact` returns a `CompactResult`. When compaction rotates the active
transcript, `result.sessionId` and `result.sessionFile` identify the successor
session that the next retry or turn must use.
transcript, `result.sessionTarget` (a typed `ContextEngineSessionTarget`
carrying the storage mode, session identity, and transcript artifact path)
identifies the successor session that the next retry or turn must use;
`result.sessionId` mirrors the successor id. `result.sessionFile` is
deprecated - report successors through `sessionTarget` instead.
Optional members:

View File

@@ -195,7 +195,7 @@ export function readPluginSdkSurfaceBudgets(env = process.env) {
),
publicExports: readPluginSdkSurfaceBudgetEnv(
"OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_EXPORTS",
10462,
10463,
env,
),
publicFunctionExports: readPluginSdkSurfaceBudgetEnv(

View File

@@ -8,10 +8,11 @@ import {
resolveContextEngineOwnerPluginId,
} from "../../context-engine/registry.js";
import { buildContextEngineRuntimeSettings } from "../../context-engine/runtime-settings.js";
import type {
ContextEngine,
ContextEngineRuntimeContext,
ContextEngineRuntimeSettings,
import {
resolveCompactionSuccessorTranscript,
type ContextEngine,
type ContextEngineRuntimeContext,
type ContextEngineRuntimeSettings,
} from "../../context-engine/types.js";
import {
createFileBackedCompactionCheckpointStore,
@@ -440,8 +441,9 @@ export async function compactEmbeddedAgentSession(
reason: formatErrorMessage(compactErr),
};
}
const delegatedSessionId = result.result?.sessionId;
const delegatedSessionFile = result.result?.sessionFile;
const delegatedSuccessor = resolveCompactionSuccessorTranscript(result);
const delegatedSessionId = delegatedSuccessor.sessionId;
const delegatedSessionFile = delegatedSuccessor.sessionFile;
const delegatedRotatedTranscript =
(typeof delegatedSessionId === "string" && delegatedSessionId !== params.sessionId) ||
(typeof delegatedSessionFile === "string" && delegatedSessionFile !== params.sessionFile);

View File

@@ -22,6 +22,7 @@ import {
resolveContextEngineOwnerPluginId,
} from "../../context-engine/registry.js";
import { buildContextEngineRuntimeSettings } from "../../context-engine/runtime-settings.js";
import { resolveCompactionSuccessorTranscript } from "../../context-engine/types.js";
import {
assertAgentRunLifecycleGenerationCurrent,
captureAgentRunLifecycleGeneration,
@@ -1873,13 +1874,12 @@ async function runEmbeddedAgentInternal(
compactResult: Awaited<ReturnType<typeof contextEngine.compact>>,
): string | undefined => {
const previousSessionId = activeSessionId;
const nextSessionId = compactResult.result?.sessionId;
const nextSessionFile = compactResult.result?.sessionFile;
adoptActiveSessionId(nextSessionId);
if (nextSessionFile && nextSessionFile !== activeSessionFile) {
activeSessionFile = nextSessionFile;
const successor = resolveCompactionSuccessorTranscript(compactResult);
adoptActiveSessionId(successor.sessionId);
if (successor.sessionFile && successor.sessionFile !== activeSessionFile) {
activeSessionFile = successor.sessionFile;
}
return nextSessionId && nextSessionId !== previousSessionId
return successor.sessionId && successor.sessionId !== previousSessionId
? previousSessionId
: undefined;
};
@@ -1939,7 +1939,9 @@ async function runEmbeddedAgentInternal(
messageCount: -1,
compactedCount: -1,
tokenCount: compactResult.result?.tokensAfter,
sessionFile: compactResult.result?.sessionFile ?? activeSessionFile,
sessionFile:
resolveCompactionSuccessorTranscript(compactResult).sessionFile ??
activeSessionFile,
...(previousSessionId ? { previousSessionId } : {}),
},
resolveActiveHookContext(),

View File

@@ -26,14 +26,15 @@ import type {
ContextEngineFactoryContext,
ContextEngineRegistrationResult,
} from "./registry.js";
import type {
ContextEngine,
ContextEngineInfo,
AssembleResult,
CompactResult,
ContextEngineMaintenanceResult,
BootstrapResult,
IngestResult,
import {
resolveCompactionSuccessorTranscript,
type ContextEngine,
type ContextEngineInfo,
type AssembleResult,
type CompactResult,
type ContextEngineMaintenanceResult,
type BootstrapResult,
type IngestResult,
} from "./types.js";
const { compactEmbeddedAgentSessionDirectMock } = vi.hoisted(() => ({
@@ -599,10 +600,79 @@ describe("Engine contract tests", () => {
tokensBefore: 0,
tokensAfter: 0,
details: undefined,
sessionTarget: {
kind: "file",
sessionId: "s2",
sessionFile: "/tmp/session.json",
},
},
});
});
it("delegateCompactionToRuntime reports the rotated successor as a typed session target", async () => {
compactEmbeddedAgentSessionDirectMock.mockResolvedValue({
ok: true,
compacted: true,
result: {
summary: "compacted",
firstKeptEntryId: "e1",
tokensBefore: 100,
tokensAfter: 10,
sessionId: "rotated-id",
sessionFile: "/tmp/rotated.jsonl",
},
});
const result = await delegateCompactionToRuntime({
sessionId: "s-rotate",
sessionKey: "agent:main:test",
sessionFile: "/tmp/session.json",
runtimeContext: {
workspaceDir: "/tmp/workspace",
agentId: "main",
},
});
expect(result.result?.sessionTarget).toEqual({
kind: "file",
agentId: "main",
sessionId: "rotated-id",
sessionKey: "agent:main:test",
sessionFile: "/tmp/rotated.jsonl",
});
// Deprecated raw fields stay populated for shipped plugin-sdk readers.
expect(result.result?.sessionId).toBe("rotated-id");
expect(result.result?.sessionFile).toBe("/tmp/rotated.jsonl");
});
it("resolveCompactionSuccessorTranscript prefers the typed target over deprecated raw fields", () => {
expect(
resolveCompactionSuccessorTranscript({
ok: true,
compacted: true,
result: {
tokensBefore: 1,
sessionId: "raw-id",
sessionFile: "/tmp/raw.jsonl",
sessionTarget: {
kind: "file",
sessionId: "target-id",
sessionFile: "/tmp/target.jsonl",
},
},
}),
).toEqual({ sessionId: "target-id", sessionFile: "/tmp/target.jsonl" });
// Raw-field fallback covers shipped engines that predate sessionTarget.
expect(
resolveCompactionSuccessorTranscript({
ok: true,
compacted: true,
result: { tokensBefore: 1, sessionId: "raw-id", sessionFile: "/tmp/raw.jsonl" },
}),
).toEqual({ sessionId: "raw-id", sessionFile: "/tmp/raw.jsonl" });
});
it("delegateCompactionToRuntime forwards the caller abortSignal to the runtime (#89868)", async () => {
installCompactRuntimeSpy();
const controller = new AbortController();

View File

@@ -56,6 +56,13 @@ export async function delegateCompactionToRuntime(
typeof runtimeContext.workspaceDir === "string" ? runtimeContext.workspaceDir : process.cwd(),
});
// Post-compaction live session: the runtime reports rotation through the
// result sessionId/sessionFile pair; otherwise the input session stays live.
const successorSessionId = result.result?.sessionId ?? params.sessionId;
const successorSessionFile = result.result?.sessionFile ?? params.sessionFile;
const agentId = runtimeContext.sessionTarget?.agentId ?? runtimeContext.agentId;
const sessionKey = params.sessionKey ?? runtimeContext.sessionKey;
return {
ok: result.ok,
compacted: result.compacted,
@@ -68,7 +75,16 @@ export async function delegateCompactionToRuntime(
tokensAfter: result.result.tokensAfter,
details: result.result.details,
sessionId: result.result.sessionId,
// Deprecated raw path stays populated for shipped plugin-sdk readers
// of the delegate result; sessionTarget is the canonical successor.
sessionFile: result.result.sessionFile,
sessionTarget: {
kind: "file",
...(agentId ? { agentId } : {}),
sessionId: successorSessionId,
...(sessionKey ? { sessionKey } : {}),
sessionFile: successorSessionFile,
},
}
: undefined,
};

View File

@@ -125,6 +125,25 @@ export class ContextEngineRuntimeSettingsUnsupportedError extends Error {
}
}
/**
* Typed session target for compaction results.
*
* Makes the storage mode explicit and carries precise session identity
* instead of a raw file path string. File-backed is the only storage mode
* today; new storage modes extend this union when they ship.
*/
export type ContextEngineSessionTarget = {
kind: "file";
/** Agent that owns the session, when the caller resolved it. */
agentId?: string;
/** Runtime session id for the post-compaction live session. */
sessionId: string;
/** Stable session key used for aliases, policy, and store resolution. */
sessionKey?: string;
/** Live transcript artifact path for the file-backed session. */
sessionFile: string;
};
export type CompactResult = {
ok: boolean;
compacted: boolean;
@@ -137,11 +156,38 @@ export type CompactResult = {
details?: unknown;
/** Session id after compaction, when the runtime rotated transcripts. */
sessionId?: string;
/** Session file after compaction, when the runtime rotated transcripts. */
/** Typed post-compaction live session target; successor when the runtime rotated transcripts. */
sessionTarget?: ContextEngineSessionTarget;
/**
* Raw session file path after compaction.
*
* @deprecated Use `sessionTarget`. Shipped plugin-sdk contract: released
* third-party context engines (v2026.6.x and earlier) report rotated
* transcripts through this field. Remove once typed session targets are
* the only successor contract.
*/
sessionFile?: string;
};
};
/**
* Resolve the post-compaction live transcript identity from a compact result.
*
* Prefers the typed `sessionTarget`. Reading the raw fields is the named
* compat path for shipped third-party engines that predate `sessionTarget`;
* it is removed together with the deprecated `sessionFile` result field.
*/
export function resolveCompactionSuccessorTranscript(result: CompactResult): {
sessionId?: string;
sessionFile?: string;
} {
const target = result.result?.sessionTarget;
return {
sessionId: target?.sessionId ?? result.result?.sessionId,
sessionFile: target?.sessionFile ?? result.result?.sessionFile,
};
}
export type IngestResult = {
/** Whether the message was ingested (false if duplicate or no-op) */
ingested: boolean;

View File

@@ -135,6 +135,7 @@ export type {
ContextEngineRuntimeMode,
ContextEngineRuntimeSettings,
ContextEngineSelectionSource,
ContextEngineSessionTarget,
IngestBatchResult,
IngestResult,
SubagentEndReason,