feat(agents): generalized native compaction ownership for CLI backends

Add `ownsNativeCompaction` capability to CliBackendPlugin so backends
that manage their own transcript compaction (e.g. Claude Code) can
declare it once and OpenClaw defers instead of fighting or failing.

Today only Codex declares compaction ownership (via the embedded runner
path + agentHarnessId). Claude-cli never reaches that path because it
runs as a CLI subprocess with no harness id set, so the safeguard
summarizer fires and hard-fails the turn.

This PR:
- Adds `ownsNativeCompaction?: boolean` to the backend plugin type
- Propagates it through all 4 backend resolution paths
- In `runCliTurnCompactionLifecycle`, when a backend declares ownership
  but has no harness endpoint, returns a no-op instead of falling
  through to the safeguard
- Sets the flag on claude-cli (first adopter)

Codex's existing native-harness path is unchanged: when
`isNativeHarnessCompactionSession` matches, the harness compaction
endpoint is still called as before.

Generalizes the partial fix in #87785 (codex-scoped) to a capability
any backend can opt into.
This commit is contained in:
Cameron Beeley
2026-05-30 12:41:41 +01:00
committed by Ayaan Zaidi
parent afbf895af0
commit 3d7523b618
6 changed files with 256 additions and 0 deletions

View File

@@ -29,6 +29,7 @@ export function buildAnthropicCliBackend(): CliBackendPlugin {
bundleMcp: true,
bundleMcpMode: "claude-config-file",
nativeToolMode: "always-on",
ownsNativeCompaction: true,
config: {
command: "claude",
args: [

View File

@@ -31,6 +31,7 @@ function createBackendEntry(params: {
bundleMcpMode?: CliBundleMcpMode;
defaultAuthProfileId?: string;
authEpochMode?: CliBackendAuthEpochMode;
ownsNativeCompaction?: boolean;
prepareExecution?: () => Promise<null>;
resolveExecutionArgs?: CliBackendResolveExecutionArgs;
normalizeConfig?: (
@@ -48,6 +49,7 @@ function createBackendEntry(params: {
...(params.bundleMcpMode ? { bundleMcpMode: params.bundleMcpMode } : {}),
...(params.defaultAuthProfileId ? { defaultAuthProfileId: params.defaultAuthProfileId } : {}),
...(params.authEpochMode ? { authEpochMode: params.authEpochMode } : {}),
...(params.ownsNativeCompaction ? { ownsNativeCompaction: params.ownsNativeCompaction } : {}),
...(params.prepareExecution ? { prepareExecution: params.prepareExecution } : {}),
...(params.resolveExecutionArgs ? { resolveExecutionArgs: params.resolveExecutionArgs } : {}),
...(params.normalizeConfig ? { normalizeConfig: params.normalizeConfig } : {}),
@@ -230,6 +232,7 @@ beforeEach(() => {
id: "claude-cli",
bundleMcp: true,
bundleMcpMode: "claude-config-file",
ownsNativeCompaction: true,
config: {
command: "claude",
args: [
@@ -546,6 +549,11 @@ describe("resolveCliBackendConfig claude-cli defaults", () => {
expect(resolved?.config.resumeArgs).not.toContain("--dangerously-skip-permissions");
});
it("declares ownsNativeCompaction for claude-cli", () => {
const resolved = requireCliBackendConfig("claude-cli");
expect(resolved?.ownsNativeCompaction).toBe(true);
});
it("keeps Claude permission mode unset when OpenClaw exec policy is not YOLO", () => {
const resolved = requireCliBackendConfig("claude-cli", {
tools: { exec: { security: "allowlist", ask: "on-miss" } },

View File

@@ -46,6 +46,7 @@ export type ResolvedCliBackend = {
defaultAuthProfileId?: string;
authEpochMode?: CliBackendAuthEpochMode;
contextEngineHostCapabilities?: readonly ContextEngineHostCapability[];
ownsNativeCompaction?: boolean;
prepareExecution?: CliBackendPlugin["prepareExecution"];
resolveExecutionArgs?: CliBackendPlugin["resolveExecutionArgs"];
nativeToolMode?: CliBackendNativeToolMode;
@@ -79,6 +80,7 @@ type FallbackCliBackendPolicy = {
defaultAuthProfileId?: string;
authEpochMode?: CliBackendAuthEpochMode;
contextEngineHostCapabilities?: readonly ContextEngineHostCapability[];
ownsNativeCompaction?: boolean;
prepareExecution?: CliBackendPlugin["prepareExecution"];
resolveExecutionArgs?: CliBackendPlugin["resolveExecutionArgs"];
nativeToolMode?: CliBackendNativeToolMode;
@@ -119,6 +121,7 @@ function resolveSetupCliBackendPolicy(provider: string): FallbackCliBackendPolic
defaultAuthProfileId: entry.backend.defaultAuthProfileId,
authEpochMode: entry.backend.authEpochMode,
contextEngineHostCapabilities: entry.backend.contextEngineHostCapabilities,
ownsNativeCompaction: entry.backend.ownsNativeCompaction,
prepareExecution: entry.backend.prepareExecution,
resolveExecutionArgs: entry.backend.resolveExecutionArgs,
nativeToolMode: entry.backend.nativeToolMode,
@@ -411,6 +414,7 @@ export function resolveCliBackendConfig(
defaultAuthProfileId: registered.defaultAuthProfileId,
authEpochMode: registered.authEpochMode,
contextEngineHostCapabilities: registered.contextEngineHostCapabilities,
ownsNativeCompaction: registered.ownsNativeCompaction,
prepareExecution: registered.prepareExecution,
resolveExecutionArgs: registered.resolveExecutionArgs,
nativeToolMode: registered.nativeToolMode,
@@ -443,6 +447,7 @@ export function resolveCliBackendConfig(
defaultAuthProfileId: fallbackPolicy.defaultAuthProfileId,
authEpochMode: fallbackPolicy.authEpochMode,
contextEngineHostCapabilities: fallbackPolicy.contextEngineHostCapabilities,
ownsNativeCompaction: fallbackPolicy.ownsNativeCompaction,
prepareExecution: fallbackPolicy.prepareExecution,
resolveExecutionArgs: fallbackPolicy.resolveExecutionArgs,
nativeToolMode: fallbackPolicy.nativeToolMode,
@@ -472,6 +477,7 @@ export function resolveCliBackendConfig(
defaultAuthProfileId: fallbackPolicy?.defaultAuthProfileId,
authEpochMode: fallbackPolicy?.authEpochMode,
contextEngineHostCapabilities: fallbackPolicy?.contextEngineHostCapabilities,
ownsNativeCompaction: fallbackPolicy?.ownsNativeCompaction,
prepareExecution: fallbackPolicy?.prepareExecution,
resolveExecutionArgs: fallbackPolicy?.resolveExecutionArgs,
nativeToolMode: fallbackPolicy?.nativeToolMode,

View File

@@ -76,6 +76,13 @@ describe("runCliTurnCompactionLifecycle", () => {
beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cli-compaction-"));
// Default backends to non-owning so the context-engine compaction-path tests
// exercise that path. On current main resolveCliBackendConfig("claude-cli")
// resolves the (now ownsNativeCompaction) backend even in unit tests, which
// would otherwise route every claude-cli compaction test through the #88315
// defer no-op. The ownsNativeCompaction-specific tests override this with an
// owning backend to exercise the defer.
setCliCompactionTestDeps({ resolveCliBackendConfig: () => null });
});
afterEach(async () => {
@@ -1406,4 +1413,212 @@ describe("runCliTurnCompactionLifecycle", () => {
"claude-session",
);
});
it("skips compaction when backend declares ownsNativeCompaction and has no harness endpoint", async () => {
const sessionKey = "agent:main:claude-owns-compaction";
const sessionId = "session-claude-owns";
const sessionFile = path.join(tmpDir, "session-claude-owns.jsonl");
const storePath = path.join(tmpDir, "sessions-claude-owns.json");
await writeSessionFile({ sessionFile, sessionId });
const sessionEntry: SessionEntry = {
sessionId,
updatedAt: Date.now(),
sessionFile,
contextTokens: 1_000,
totalTokens: 950,
totalTokensFresh: true,
};
const sessionStore: Record<string, SessionEntry> = { [sessionKey]: sessionEntry };
await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2), "utf-8");
const compactCalls: Array<Parameters<ContextEngine["compact"]>[0]> = [];
const compactAgentHarnessSession = vi.fn();
const recordCliCompactionInStore = vi.fn();
setCliCompactionTestDeps({
resolveContextEngine: async () => buildContextEngine({ compactCalls }),
maybeCompactAgentHarnessSession: compactAgentHarnessSession as never,
resolveCliBackendConfig: () => ({
id: "claude-cli",
config: { command: "claude" },
bundleMcp: true,
ownsNativeCompaction: true,
}),
createPreparedEmbeddedAgentSettingsManager: async () => ({
getCompactionReserveTokens: () => 200,
getCompactionKeepRecentTokens: () => 0,
applyOverrides: () => {},
}),
shouldPreemptivelyCompactBeforePrompt: () => ({
route: "fits",
shouldCompact: false,
estimatedPromptTokens: 600,
promptBudgetBeforeReserve: 800,
overflowTokens: 0,
toolResultReducibleChars: 0,
effectiveReserveTokens: 200,
}),
resolveLiveToolResultMaxChars: () => 20_000,
recordCliCompactionInStore,
});
const updatedEntry = await runCliTurnCompactionLifecycle({
cfg: {} as OpenClawConfig,
sessionId,
sessionKey,
sessionEntry,
sessionStore,
storePath,
sessionAgentId: "main",
workspaceDir: tmpDir,
agentDir: tmpDir,
provider: "claude-cli",
model: "opus",
});
expect(compactAgentHarnessSession).not.toHaveBeenCalled();
expect(compactCalls).toHaveLength(0);
expect(recordCliCompactionInStore).not.toHaveBeenCalled();
expect(updatedEntry).toBe(sessionEntry);
});
it("does not skip compaction when backend does not declare ownsNativeCompaction", async () => {
const sessionKey = "agent:main:generic-no-ownership";
const sessionId = "session-generic";
const sessionFile = path.join(tmpDir, "session-generic.jsonl");
const storePath = path.join(tmpDir, "sessions-generic.json");
await writeSessionFile({ sessionFile, sessionId });
const sessionEntry: SessionEntry = {
sessionId,
updatedAt: Date.now(),
sessionFile,
contextTokens: 1_000,
totalTokens: 950,
totalTokensFresh: true,
};
const sessionStore: Record<string, SessionEntry> = { [sessionKey]: sessionEntry };
await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2), "utf-8");
const compactCalls: Array<Parameters<ContextEngine["compact"]>[0]> = [];
const maintenance = vi.fn(async () => ({ changed: false, bytesFreed: 0, rewrittenEntries: 0 }));
setCliCompactionTestDeps({
resolveContextEngine: async () => buildContextEngine({ compactCalls }),
resolveCliBackendConfig: () => ({
id: "generic-backend",
config: { command: "generic" },
bundleMcp: false,
}),
createPreparedEmbeddedAgentSettingsManager: async () => ({
getCompactionReserveTokens: () => 200,
getCompactionKeepRecentTokens: () => 0,
applyOverrides: () => {},
}),
shouldPreemptivelyCompactBeforePrompt: () => ({
route: "fits",
shouldCompact: false,
estimatedPromptTokens: 600,
promptBudgetBeforeReserve: 800,
overflowTokens: 0,
toolResultReducibleChars: 0,
effectiveReserveTokens: 200,
}),
resolveLiveToolResultMaxChars: () => 20_000,
runContextEngineMaintenance: maintenance,
});
await runCliTurnCompactionLifecycle({
cfg: {} as OpenClawConfig,
sessionId,
sessionKey,
sessionEntry,
sessionStore,
storePath,
sessionAgentId: "main",
workspaceDir: tmpDir,
agentDir: tmpDir,
provider: "generic-backend",
model: "model",
});
expect(compactCalls).toHaveLength(1);
});
it("still uses native harness path when backend declares ownsNativeCompaction and has agentHarnessId", async () => {
const sessionKey = "agent:main:codex-with-ownership";
const sessionId = "session-codex-ownership";
const sessionFile = path.join(tmpDir, "session-codex-ownership.jsonl");
const storePath = path.join(tmpDir, "sessions-codex-ownership.json");
await writeSessionFile({ sessionFile, sessionId });
const sessionEntry: SessionEntry = {
sessionId,
updatedAt: Date.now(),
sessionFile,
contextTokens: 1_000,
totalTokens: 950,
totalTokensFresh: true,
agentHarnessId: "codex",
};
const sessionStore: Record<string, SessionEntry> = { [sessionKey]: sessionEntry };
await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2), "utf-8");
const compactCalls: Array<Parameters<ContextEngine["compact"]>[0]> = [];
const contextEngine = buildContextEngine({ compactCalls });
const compactAgentHarnessSession = vi.fn(async () => ({
ok: true,
compacted: true,
result: { tokensBefore: 950, tokensAfter: 100 },
}));
const recordCliCompactionInStore = vi.fn(async () => ({
...sessionEntry,
compactionCount: 1,
}));
setCliCompactionTestDeps({
resolveContextEngine: async () => contextEngine,
ensureSelectedAgentHarnessPlugin: vi.fn(async () => undefined),
maybeCompactAgentHarnessSession: compactAgentHarnessSession as never,
resolveCliBackendConfig: () => ({
id: "codex",
config: { command: "codex" },
bundleMcp: false,
ownsNativeCompaction: true,
}),
createPreparedEmbeddedAgentSettingsManager: async () => ({
getCompactionReserveTokens: () => 200,
getCompactionKeepRecentTokens: () => 0,
applyOverrides: () => {},
}),
shouldPreemptivelyCompactBeforePrompt: () => ({
route: "fits",
shouldCompact: false,
estimatedPromptTokens: 600,
promptBudgetBeforeReserve: 800,
overflowTokens: 0,
toolResultReducibleChars: 0,
effectiveReserveTokens: 200,
}),
resolveLiveToolResultMaxChars: () => 20_000,
applyAgentAutoCompactionGuard: vi.fn(async () => ({ supported: true, disabled: false })),
recordCliCompactionInStore,
});
await runCliTurnCompactionLifecycle({
cfg: {} as OpenClawConfig,
sessionId,
sessionKey,
sessionEntry,
sessionStore,
storePath,
sessionAgentId: "main",
workspaceDir: tmpDir,
agentDir: tmpDir,
provider: "openai",
model: "gpt-5.5",
});
expect(compactAgentHarnessSession).toHaveBeenCalledTimes(1);
expect(compactCalls).toHaveLength(0);
expect(recordCliCompactionInStore).toHaveBeenCalledTimes(1);
});
});

View File

@@ -29,6 +29,7 @@ import { ensureSelectedAgentHarnessPlugin as ensureSelectedAgentHarnessPluginImp
import { maybeCompactAgentHarnessSession as maybeCompactAgentHarnessSessionImpl } from "../harness/selection.js";
import type { AgentMessage } from "../runtime/index.js";
import { SessionManager } from "../sessions/session-manager.js";
import { resolveCliBackendConfig as resolveCliBackendConfigImpl } from "../cli-backends.js";
import {
clearCliSessionInStore as clearCliSessionInStoreImpl,
recordCliCompactionInStore as recordCliCompactionInStoreImpl,
@@ -69,6 +70,7 @@ type CliCompactionDeps = {
ensureSelectedAgentHarnessPlugin: typeof ensureSelectedAgentHarnessPluginImpl;
maybeCompactAgentHarnessSession: typeof maybeCompactAgentHarnessSessionImpl;
clearCliSessionInStore: typeof clearCliSessionInStoreImpl;
resolveCliBackendConfig: typeof resolveCliBackendConfigImpl;
recordCliCompactionInStore: typeof recordCliCompactionInStoreImpl;
};
@@ -117,6 +119,7 @@ const cliCompactionDeps: CliCompactionDeps = {
ensureSelectedAgentHarnessPlugin: ensureSelectedAgentHarnessPluginImpl,
maybeCompactAgentHarnessSession: maybeCompactAgentHarnessSessionImpl,
clearCliSessionInStore: clearCliSessionInStoreImpl,
resolveCliBackendConfig: resolveCliBackendConfigImpl,
recordCliCompactionInStore: recordCliCompactionInStoreImpl,
};
@@ -137,6 +140,7 @@ export function resetCliCompactionTestDeps(): void {
ensureSelectedAgentHarnessPlugin: ensureSelectedAgentHarnessPluginImpl,
maybeCompactAgentHarnessSession: maybeCompactAgentHarnessSessionImpl,
clearCliSessionInStore: clearCliSessionInStoreImpl,
resolveCliBackendConfig: resolveCliBackendConfigImpl,
recordCliCompactionInStore: recordCliCompactionInStoreImpl,
});
}
@@ -525,6 +529,21 @@ export async function runCliTurnCompactionLifecycle(params: {
return params.sessionEntry;
}
// When the backend declares native compaction ownership but has no harness
// compaction endpoint (e.g. claude-cli — Claude Code compacts its own
// transcript internally), skip both native-harness and context-engine
// compaction. The backend will handle it; OpenClaw returns a no-op.
const resolvedBackend = cliCompactionDeps.resolveCliBackendConfig(params.provider, params.cfg);
if (
resolvedBackend?.ownsNativeCompaction &&
!isNativeHarnessCompactionSession(params.sessionEntry, params.provider)
) {
log.info(
`CLI backend "${params.provider}" owns native compaction — deferring to backend`,
);
return params.sessionEntry;
}
let compacted = false;
let nativeCompactionResult: EmbeddedAgentCompactResult | undefined;
let useContextEngineCompaction = true;

View File

@@ -82,6 +82,13 @@ export type CliBackendPlugin = {
* driven through the generic CLI runner.
*/
contextEngineHostCapabilities?: readonly ContextEngineHostCapability[];
/**
* When true, the backend manages its own transcript compaction lifecycle
* (e.g. Claude Code's internal auto-compaction). OpenClaw will skip its
* safeguard summarizer and return a no-op from the compaction path instead
* of fighting the backend's own compaction or hard-failing the turn.
*/
ownsNativeCompaction?: boolean;
/**
* Optional live-smoke metadata owned by the backend plugin.
*