From 7f0f8dd26802c7f3a333104844e815f7e84d3e9c Mon Sep 17 00:00:00 2001 From: Josh Lehman Date: Tue, 17 Mar 2026 22:54:18 -0700 Subject: [PATCH] feat: expose context-engine compaction delegate helper (#49061) * ContextEngine: add runtime compaction delegate helper * plugin-sdk: expose compaction delegate through compat * docs: clarify delegated plugin compaction * docs: use scoped compaction delegate import --- CHANGELOG.md | 1 + docs/concepts/compaction.md | 8 +++ docs/concepts/context-engine.md | 32 +++++++++--- docs/concepts/context.md | 4 +- docs/tools/plugin.md | 30 +++++++++++ docs/zh-CN/tools/plugin.md | 29 +++++++++++ src/context-engine/context-engine.test.ts | 35 +++++++++++++ src/context-engine/delegate.ts | 61 +++++++++++++++++++++++ src/context-engine/index.ts | 1 + src/context-engine/legacy.ts | 44 +--------------- src/plugin-sdk/compat.ts | 1 + src/plugin-sdk/core.ts | 1 + src/plugin-sdk/index.test.ts | 1 + src/plugin-sdk/index.ts | 1 + src/plugin-sdk/root-alias.test.ts | 14 ++++++ 15 files changed, 213 insertions(+), 50 deletions(-) create mode 100644 src/context-engine/delegate.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 115481dd284..fa96121ab73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,7 @@ Docs: https://docs.openclaw.ai - CLI/config: expand `config set` with SecretRef and provider builder modes, JSON/batch assignment support, and `--dry-run` validation with structured JSON output. (#49296) Thanks @joshavant. - Control UI/appearance: unify theme border radii across Claw, Knot, and Dash, and add a Roundness slider to the Appearance settings so users can adjust corner radius from sharp to fully rounded. Thanks @BunsDev. - Control UI/chat: add an expand-to-canvas button on assistant chat bubbles and in-app session navigation from Sessions and Cron views. Thanks @BunsDev. +- Plugins/context engines: expose `delegateCompactionToRuntime(...)` on the public plugin SDK, refactor the legacy engine to use the shared helper, and clarify `ownsCompaction` delegation semantics for non-owning engines. (#49061) Thanks @jalehman. ### Fixes diff --git a/docs/concepts/compaction.md b/docs/concepts/compaction.md index 5640fa51a35..550d3b385d4 100644 --- a/docs/concepts/compaction.md +++ b/docs/concepts/compaction.md @@ -108,6 +108,14 @@ summaries, vector retrieval, incremental condensation, etc. When a plugin engine sets `ownsCompaction: true`, OpenClaw delegates all compaction decisions to the engine and does not run built-in auto-compaction. +When `ownsCompaction` is `false` or unset, OpenClaw may still use Pi's +built-in in-attempt auto-compaction, but the active engine's `compact()` method +still handles `/compact` and overflow recovery. There is no automatic fallback +to the legacy engine's compaction path. + +If you are building a non-owning context engine, implement `compact()` by +calling `delegateCompactionToRuntime(...)` from `openclaw/plugin-sdk/core`. + ## Tips - Use `/compact` when sessions feel stale or context is bloated. diff --git a/docs/concepts/context-engine.md b/docs/concepts/context-engine.md index 87d5e87d85b..0b2ec1cd78b 100644 --- a/docs/concepts/context-engine.md +++ b/docs/concepts/context-engine.md @@ -14,7 +14,7 @@ It decides which messages to include, how to summarize older history, and how to manage context across subagent boundaries. OpenClaw ships with a built-in `legacy` engine. Plugins can register -alternative engines that replace the entire context pipeline. +alternative engines that replace the active context-engine lifecycle. ## Quick start @@ -194,13 +194,31 @@ Optional members: ### ownsCompaction -When `info.ownsCompaction` is `true`, the engine manages its own compaction -lifecycle. OpenClaw will not trigger the built-in auto-compaction; instead it -delegates entirely to the engine's `compact()` method. The engine may also -run compaction proactively in `afterTurn()`. +`ownsCompaction` controls whether Pi's built-in in-attempt auto-compaction stays +enabled for the run: -When `false` or unset, OpenClaw's built-in auto-compaction logic runs -alongside the engine. +- `true` — the engine owns compaction behavior. OpenClaw disables Pi's built-in + auto-compaction for that run, and the engine's `compact()` implementation is + responsible for `/compact`, overflow recovery compaction, and any proactive + compaction it wants to do in `afterTurn()`. +- `false` or unset — Pi's built-in auto-compaction may still run during prompt + execution, but the active engine's `compact()` method is still called for + `/compact` and overflow recovery. + +`ownsCompaction: false` does **not** mean OpenClaw automatically falls back to +the legacy engine's compaction path. + +That means there are two valid plugin patterns: + +- **Owning mode** — implement your own compaction algorithm and set + `ownsCompaction: true`. +- **Delegating mode** — set `ownsCompaction: false` and have `compact()` call + `delegateCompactionToRuntime(...)` from `openclaw/plugin-sdk/core` to use + OpenClaw's built-in compaction behavior. + +A no-op `compact()` is unsafe for an active non-owning engine because it +disables the normal `/compact` and overflow-recovery compaction path for that +engine slot. ## Configuration reference diff --git a/docs/concepts/context.md b/docs/concepts/context.md index d5316ea8bf8..356f8b810c3 100644 --- a/docs/concepts/context.md +++ b/docs/concepts/context.md @@ -157,7 +157,9 @@ By default, OpenClaw uses the built-in `legacy` context engine for assembly and compaction. If you install a plugin that provides `kind: "context-engine"` and select it with `plugins.slots.contextEngine`, OpenClaw delegates context assembly, `/compact`, and related subagent context lifecycle hooks to that -engine instead. See [Context Engine](/concepts/context-engine) for the full +engine instead. `ownsCompaction: false` does not auto-fallback to the legacy +engine; the active engine must still implement `compact()` correctly. See +[Context Engine](/concepts/context-engine) for the full pluggable interface, lifecycle hooks, and configuration. ## What `/context` actually reports diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index 0cc98187550..e04c30f6003 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -1810,6 +1810,36 @@ export default function (api) { } ``` +If your engine does **not** own the compaction algorithm, keep `compact()` +implemented and delegate it explicitly: + +```ts +import { delegateCompactionToRuntime } from "openclaw/plugin-sdk/core"; + +export default function (api) { + api.registerContextEngine("my-memory-engine", () => ({ + info: { + id: "my-memory-engine", + name: "My Memory Engine", + ownsCompaction: false, + }, + async ingest() { + return { ingested: true }; + }, + async assemble({ messages }) { + return { messages, estimatedTokens: 0 }; + }, + async compact(params) { + return await delegateCompactionToRuntime(params); + }, + })); +} +``` + +`ownsCompaction: false` does not automatically fall back to legacy compaction. +If your engine is active, its `compact()` method still handles `/compact` and +overflow recovery. + Then enable it in config: ```json5 diff --git a/docs/zh-CN/tools/plugin.md b/docs/zh-CN/tools/plugin.md index a2ade46ffbc..775d94eb751 100644 --- a/docs/zh-CN/tools/plugin.md +++ b/docs/zh-CN/tools/plugin.md @@ -950,6 +950,35 @@ export default function (api) { } ``` +如果你的引擎**并不拥有**压缩算法,仍然要实现 `compact()`,并显式委托给运行时: + +```ts +import { delegateCompactionToRuntime } from "openclaw/plugin-sdk"; + +export default function (api) { + api.registerContextEngine("my-memory-engine", () => ({ + info: { + id: "my-memory-engine", + name: "My Memory Engine", + ownsCompaction: false, + }, + async ingest() { + return { ingested: true }; + }, + async assemble({ messages }) { + return { messages, estimatedTokens: 0 }; + }, + async compact(params) { + return await delegateCompactionToRuntime(params); + }, + })); +} +``` + +`ownsCompaction: false` 不会自动回退到 legacy 压缩路径。 +只要该引擎处于激活状态,它自己的 `compact()` 仍然会处理 `/compact` +和溢出恢复。 + 然后在配置中启用它: ```json5 diff --git a/src/context-engine/context-engine.test.ts b/src/context-engine/context-engine.test.ts index 82c3501343b..cf24bfd7a07 100644 --- a/src/context-engine/context-engine.test.ts +++ b/src/context-engine/context-engine.test.ts @@ -5,6 +5,7 @@ import { compactEmbeddedPiSessionDirect } from "../agents/pi-embedded-runner/com // We dynamically import the registry so we can get a fresh module per test // group when needed. For most groups we use the shared singleton directly. // --------------------------------------------------------------------------- +import { delegateCompactionToRuntime } from "./delegate.js"; import { LegacyContextEngine, registerLegacyContextEngine } from "./legacy.js"; import { registerContextEngine, @@ -255,6 +256,40 @@ describe("Engine contract tests", () => { }), ); }); + + it("delegateCompactionToRuntime reuses the legacy runtime bridge", async () => { + const result = await delegateCompactionToRuntime({ + sessionId: "s2", + sessionFile: "/tmp/session.json", + tokenBudget: 4096, + runtimeContext: { + workspaceDir: "/tmp/workspace", + currentTokenCount: 12345, + }, + }); + + expect(mockedCompactEmbeddedPiSessionDirect).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: "s2", + sessionFile: "/tmp/session.json", + tokenBudget: 4096, + currentTokenCount: 12345, + workspaceDir: "/tmp/workspace", + }), + ); + expect(result).toEqual({ + ok: true, + compacted: false, + reason: "mock compaction", + result: { + summary: "", + firstKeptEntryId: "", + tokensBefore: 0, + tokensAfter: 0, + details: undefined, + }, + }); + }); }); // ═══════════════════════════════════════════════════════════════════════════ diff --git a/src/context-engine/delegate.ts b/src/context-engine/delegate.ts new file mode 100644 index 00000000000..6d03045d795 --- /dev/null +++ b/src/context-engine/delegate.ts @@ -0,0 +1,61 @@ +import type { ContextEngine, CompactResult, ContextEngineRuntimeContext } from "./types.js"; + +/** + * Delegate a context-engine compaction request to OpenClaw's built-in runtime compaction path. + * + * This is the same bridge used by the legacy context engine. Third-party + * engines can call it from their own `compact()` implementations when they do + * not own the compaction algorithm but still need `/compact` and overflow + * recovery to use the stock runtime behavior. + * + * Note: `compactionTarget` is part of the public `compact()` contract, but the + * built-in runtime compaction path does not expose that knob. This helper + * ignores it to preserve legacy behavior; engines that need target-specific + * compaction should implement their own `compact()` algorithm. + */ +export async function delegateCompactionToRuntime( + params: Parameters[0], +): Promise { + // Import through a dedicated runtime boundary so the lazy edge remains effective. + const { compactEmbeddedPiSessionDirect } = + await import("../agents/pi-embedded-runner/compact.runtime.js"); + + // runtimeContext carries the full CompactEmbeddedPiSessionParams fields set + // by runtime callers. We spread them and override the fields that come from + // the public ContextEngine compact() signature directly. + const runtimeContext: ContextEngineRuntimeContext = params.runtimeContext ?? {}; + const currentTokenCount = + params.currentTokenCount ?? + (typeof runtimeContext.currentTokenCount === "number" && + Number.isFinite(runtimeContext.currentTokenCount) && + runtimeContext.currentTokenCount > 0 + ? Math.floor(runtimeContext.currentTokenCount) + : undefined); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- bridge runtimeContext matches CompactEmbeddedPiSessionParams + const result = await compactEmbeddedPiSessionDirect({ + ...runtimeContext, + sessionId: params.sessionId, + sessionFile: params.sessionFile, + tokenBudget: params.tokenBudget, + ...(currentTokenCount !== undefined ? { currentTokenCount } : {}), + force: params.force, + customInstructions: params.customInstructions, + workspaceDir: (runtimeContext.workspaceDir as string) ?? process.cwd(), + } as Parameters[0]); + + return { + ok: result.ok, + compacted: result.compacted, + reason: result.reason, + result: result.result + ? { + summary: result.result.summary, + firstKeptEntryId: result.result.firstKeptEntryId, + tokensBefore: result.result.tokensBefore, + tokensAfter: result.result.tokensAfter, + details: result.result.details, + } + : undefined, + }; +} diff --git a/src/context-engine/index.ts b/src/context-engine/index.ts index fa3193d4030..09cc4c8e94e 100644 --- a/src/context-engine/index.ts +++ b/src/context-engine/index.ts @@ -15,5 +15,6 @@ export { export type { ContextEngineFactory } from "./registry.js"; export { LegacyContextEngine, registerLegacyContextEngine } from "./legacy.js"; +export { delegateCompactionToRuntime } from "./delegate.js"; export { ensureContextEnginesInitialized } from "./init.js"; diff --git a/src/context-engine/legacy.ts b/src/context-engine/legacy.ts index 3080e9aba0b..09659c968fb 100644 --- a/src/context-engine/legacy.ts +++ b/src/context-engine/legacy.ts @@ -1,4 +1,5 @@ import type { AgentMessage } from "@mariozechner/pi-agent-core"; +import { delegateCompactionToRuntime } from "./delegate.js"; import { registerContextEngineForOwner } from "./registry.js"; import type { ContextEngine, @@ -74,48 +75,7 @@ export class LegacyContextEngine implements ContextEngine { customInstructions?: string; runtimeContext?: ContextEngineRuntimeContext; }): Promise { - // Import through a dedicated runtime boundary so the lazy edge remains effective. - const { compactEmbeddedPiSessionDirect } = - await import("../agents/pi-embedded-runner/compact.runtime.js"); - - // runtimeContext carries the full CompactEmbeddedPiSessionParams fields - // set by the caller in run.ts. We spread them and override the fields - // that come from the ContextEngine compact() signature directly. - const runtimeContext = params.runtimeContext ?? {}; - const currentTokenCount = - params.currentTokenCount ?? - (typeof runtimeContext.currentTokenCount === "number" && - Number.isFinite(runtimeContext.currentTokenCount) && - runtimeContext.currentTokenCount > 0 - ? Math.floor(runtimeContext.currentTokenCount) - : undefined); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- bridge runtimeContext matches CompactEmbeddedPiSessionParams - const result = await compactEmbeddedPiSessionDirect({ - ...runtimeContext, - sessionId: params.sessionId, - sessionFile: params.sessionFile, - tokenBudget: params.tokenBudget, - ...(currentTokenCount !== undefined ? { currentTokenCount } : {}), - force: params.force, - customInstructions: params.customInstructions, - workspaceDir: (runtimeContext.workspaceDir as string) ?? process.cwd(), - } as Parameters[0]); - - return { - ok: result.ok, - compacted: result.compacted, - reason: result.reason, - result: result.result - ? { - summary: result.result.summary, - firstKeptEntryId: result.result.firstKeptEntryId, - tokensBefore: result.result.tokensBefore, - tokensAfter: result.result.tokensAfter, - details: result.result.details, - } - : undefined, - }; + return await delegateCompactionToRuntime(params); } async dispose(): Promise { diff --git a/src/plugin-sdk/compat.ts b/src/plugin-sdk/compat.ts index 9892bbc8fc7..83a2a21e75e 100644 --- a/src/plugin-sdk/compat.ts +++ b/src/plugin-sdk/compat.ts @@ -19,6 +19,7 @@ if (shouldWarnCompatImport) { export { emptyPluginConfigSchema } from "../plugins/config-schema.js"; export { resolveControlCommandGate } from "../channels/command-gating.js"; +export { delegateCompactionToRuntime } from "../context-engine/delegate.js"; export { createAccountStatusSink } from "./channel-lifecycle.js"; export { createPluginRuntimeStore } from "./runtime-store.js"; diff --git a/src/plugin-sdk/core.ts b/src/plugin-sdk/core.ts index 232989ebbfc..ba49614389d 100644 --- a/src/plugin-sdk/core.ts +++ b/src/plugin-sdk/core.ts @@ -70,6 +70,7 @@ export type { OpenClawPluginApi } from "../plugins/types.js"; export type { PluginRuntime } from "../plugins/runtime/types.js"; export { emptyPluginConfigSchema } from "../plugins/config-schema.js"; +export { delegateCompactionToRuntime } from "../context-engine/delegate.js"; export { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; export { buildChannelConfigSchema } from "../channels/plugins/config-schema.js"; export { diff --git a/src/plugin-sdk/index.test.ts b/src/plugin-sdk/index.test.ts index 07d4dde6d98..a744113a8cf 100644 --- a/src/plugin-sdk/index.test.ts +++ b/src/plugin-sdk/index.test.ts @@ -64,6 +64,7 @@ describe("plugin-sdk exports", () => { it("keeps the root runtime surface intentionally small", () => { expect(typeof sdk.emptyPluginConfigSchema).toBe("function"); + expect(typeof sdk.delegateCompactionToRuntime).toBe("function"); expect(Object.prototype.hasOwnProperty.call(sdk, "resolveControlCommandGate")).toBe(false); expect(Object.prototype.hasOwnProperty.call(sdk, "buildAgentSessionKey")).toBe(false); expect(Object.prototype.hasOwnProperty.call(sdk, "isDangerousNameMatchingEnabled")).toBe(false); diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index a683f5437ca..5bb67920734 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -67,3 +67,4 @@ export type { ContextEngineFactory } from "../context-engine/registry.js"; export { emptyPluginConfigSchema } from "../plugins/config-schema.js"; export { registerContextEngine } from "../context-engine/registry.js"; +export { delegateCompactionToRuntime } from "../context-engine/delegate.js"; diff --git a/src/plugin-sdk/root-alias.test.ts b/src/plugin-sdk/root-alias.test.ts index 3c30dbee6be..6767ca773e3 100644 --- a/src/plugin-sdk/root-alias.test.ts +++ b/src/plugin-sdk/root-alias.test.ts @@ -127,6 +127,20 @@ describe("plugin-sdk root alias", () => { expect(Object.getOwnPropertyDescriptor(lazyRootSdk, "slowHelper")).toBeDefined(); }); + it("forwards delegateCompactionToRuntime through the compat-backed root alias", () => { + const delegateCompactionToRuntime = () => "delegated"; + const lazyModule = loadRootAliasWithStubs({ + monolithicExports: { + delegateCompactionToRuntime, + }, + }); + const lazyRootSdk = lazyModule.moduleExports; + + expect(typeof lazyRootSdk.delegateCompactionToRuntime).toBe("function"); + expect(lazyRootSdk.delegateCompactionToRuntime).toBe(delegateCompactionToRuntime); + expect("delegateCompactionToRuntime" in lazyRootSdk).toBe(true); + }); + it("loads legacy root exports through the merged root wrapper", { timeout: 240_000 }, () => { expect(typeof rootSdk.resolveControlCommandGate).toBe("function"); expect(typeof rootSdk.default).toBe("object");