From 5d9a2b114f758aedbf5d346d9ef80bfe1ecda41c Mon Sep 17 00:00:00 2001 From: Josh Lehman Date: Mon, 6 Jul 2026 22:14:46 -0700 Subject: [PATCH] feat(context-engine): report compaction successors as typed session targets (#101182) --- .../.generated/plugin-sdk-api-baseline.sha256 | 4 +- docs/concepts/context-engine.md | 7 +- scripts/plugin-sdk-surface-report.mjs | 2 +- .../embedded-agent-runner/compact.queued.ts | 14 +-- src/agents/embedded-agent-runner/run.ts | 16 ++-- src/context-engine/context-engine.test.ts | 86 +++++++++++++++++-- src/context-engine/delegate.ts | 16 ++++ src/context-engine/types.ts | 48 ++++++++++- src/plugin-sdk/index.ts | 1 + 9 files changed, 167 insertions(+), 27 deletions(-) diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index c993d0d356fd..8c79aa51e5e1 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -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 diff --git a/docs/concepts/context-engine.md b/docs/concepts/context-engine.md index 6ba1fddae205..c866dc26a419 100644 --- a/docs/concepts/context-engine.md +++ b/docs/concepts/context-engine.md @@ -217,8 +217,11 @@ Required members: `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: diff --git a/scripts/plugin-sdk-surface-report.mjs b/scripts/plugin-sdk-surface-report.mjs index 3a70d0e8bda0..29a5bf65b9de 100644 --- a/scripts/plugin-sdk-surface-report.mjs +++ b/scripts/plugin-sdk-surface-report.mjs @@ -195,7 +195,7 @@ export function readPluginSdkSurfaceBudgets(env = process.env) { ), publicExports: readPluginSdkSurfaceBudgetEnv( "OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_EXPORTS", - 10462, + 10463, env, ), publicFunctionExports: readPluginSdkSurfaceBudgetEnv( diff --git a/src/agents/embedded-agent-runner/compact.queued.ts b/src/agents/embedded-agent-runner/compact.queued.ts index 9e13a27fe437..fb469c727ca5 100644 --- a/src/agents/embedded-agent-runner/compact.queued.ts +++ b/src/agents/embedded-agent-runner/compact.queued.ts @@ -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); diff --git a/src/agents/embedded-agent-runner/run.ts b/src/agents/embedded-agent-runner/run.ts index 5c7f88fa86ce..d755290d6f1e 100644 --- a/src/agents/embedded-agent-runner/run.ts +++ b/src/agents/embedded-agent-runner/run.ts @@ -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>, ): 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(), diff --git a/src/context-engine/context-engine.test.ts b/src/context-engine/context-engine.test.ts index 029c3231a9c7..3bbbd2ecc80f 100644 --- a/src/context-engine/context-engine.test.ts +++ b/src/context-engine/context-engine.test.ts @@ -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(); diff --git a/src/context-engine/delegate.ts b/src/context-engine/delegate.ts index 2d94e601ef7b..c885a4f0c74f 100644 --- a/src/context-engine/delegate.ts +++ b/src/context-engine/delegate.ts @@ -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, }; diff --git a/src/context-engine/types.ts b/src/context-engine/types.ts index 2794d620c823..0a8492d5d17b 100644 --- a/src/context-engine/types.ts +++ b/src/context-engine/types.ts @@ -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; diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index 9d18bd3e20ab..d1f4219cc805 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -135,6 +135,7 @@ export type { ContextEngineRuntimeMode, ContextEngineRuntimeSettings, ContextEngineSelectionSource, + ContextEngineSessionTarget, IngestBatchResult, IngestResult, SubagentEndReason,