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
This commit is contained in:
Josh Lehman
2026-03-17 22:54:18 -07:00
committed by GitHub
parent 937f118d8e
commit 7f0f8dd268
15 changed files with 213 additions and 50 deletions

View File

@@ -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

View File

@@ -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.

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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,
},
});
});
});
// ═══════════════════════════════════════════════════════════════════════════

View File

@@ -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<ContextEngine["compact"]>[0],
): Promise<CompactResult> {
// 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<typeof compactEmbeddedPiSessionDirect>[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,
};
}

View File

@@ -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";

View File

@@ -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<CompactResult> {
// 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<typeof compactEmbeddedPiSessionDirect>[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<void> {

View File

@@ -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";

View File

@@ -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 {

View File

@@ -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);

View File

@@ -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";

View File

@@ -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");