From 3c404e82b626098aacf2362a7a53bba25114b93c Mon Sep 17 00:00:00 2001 From: Josh Lehman Date: Tue, 17 Mar 2026 07:25:12 -0700 Subject: [PATCH] ContextEngine: add runtime compaction delegate helper --- CHANGELOG.md | 1 + docs/concepts/compaction.md | 8 +++ docs/concepts/context-engine.md | 32 +++++++++--- docs/concepts/context.md | 4 +- docs/tools/plugin.md | 30 +++++++++++ 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/core.ts | 1 + src/plugin-sdk/index.test.ts | 1 + src/plugin-sdk/index.ts | 1 + 12 files changed, 169 insertions(+), 50 deletions(-) create mode 100644 src/context-engine/delegate.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1144b4fcd6d..7330708e0c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,7 @@ Docs: https://docs.openclaw.ai - Plugins/Chutes: add a bundled Chutes provider with plugin-owned OAuth/API-key auth, dynamic model discovery, and default-on extension wiring. (#41416) Thanks @Veightor. - Plugins/binding: add `onConversationBindingResolved(...)` so plugins can react immediately after bind approvals or denies without blocking channel interaction acknowledgements. (#48678) Thanks @huntharo. - 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. +- 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..09bbab8047b 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`. + ## 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..5fccdc074a1 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` 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 e9f33b00ab5..82e1045b141 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -1815,6 +1815,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"; + +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/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/core.ts b/src/plugin-sdk/core.ts index 3571edf9772..14bde5b7ddc 100644 --- a/src/plugin-sdk/core.ts +++ b/src/plugin-sdk/core.ts @@ -59,6 +59,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";