mirror of
https://github.com/moltbot/moltbot.git
synced 2026-04-24 23:21:30 +00:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
61
src/context-engine/delegate.ts
Normal file
61
src/context-engine/delegate.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user