From 3d7523b618078bb3fd3cc309da6355d79d458c06 Mon Sep 17 00:00:00 2001 From: Cameron Beeley Date: Sat, 30 May 2026 12:41:41 +0100 Subject: [PATCH] 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. --- extensions/anthropic/cli-backend.ts | 1 + src/agents/cli-backends.test.ts | 8 + src/agents/cli-backends.ts | 6 + src/agents/command/cli-compaction.test.ts | 215 ++++++++++++++++++++++ src/agents/command/cli-compaction.ts | 19 ++ src/plugins/cli-backend.types.ts | 7 + 6 files changed, 256 insertions(+) diff --git a/extensions/anthropic/cli-backend.ts b/extensions/anthropic/cli-backend.ts index df5610d7666..7b2e402feaf 100644 --- a/extensions/anthropic/cli-backend.ts +++ b/extensions/anthropic/cli-backend.ts @@ -29,6 +29,7 @@ export function buildAnthropicCliBackend(): CliBackendPlugin { bundleMcp: true, bundleMcpMode: "claude-config-file", nativeToolMode: "always-on", + ownsNativeCompaction: true, config: { command: "claude", args: [ diff --git a/src/agents/cli-backends.test.ts b/src/agents/cli-backends.test.ts index 9419319e626..fa2f3e8f4b9 100644 --- a/src/agents/cli-backends.test.ts +++ b/src/agents/cli-backends.test.ts @@ -31,6 +31,7 @@ function createBackendEntry(params: { bundleMcpMode?: CliBundleMcpMode; defaultAuthProfileId?: string; authEpochMode?: CliBackendAuthEpochMode; + ownsNativeCompaction?: boolean; prepareExecution?: () => Promise; 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" } }, diff --git a/src/agents/cli-backends.ts b/src/agents/cli-backends.ts index 44da751e317..a59aed5bdf8 100644 --- a/src/agents/cli-backends.ts +++ b/src/agents/cli-backends.ts @@ -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, diff --git a/src/agents/command/cli-compaction.test.ts b/src/agents/command/cli-compaction.test.ts index cedd43d76e2..d00a29ae5af 100644 --- a/src/agents/command/cli-compaction.test.ts +++ b/src/agents/command/cli-compaction.test.ts @@ -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 = { [sessionKey]: sessionEntry }; + await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2), "utf-8"); + + const compactCalls: Array[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 = { [sessionKey]: sessionEntry }; + await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2), "utf-8"); + + const compactCalls: Array[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 = { [sessionKey]: sessionEntry }; + await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2), "utf-8"); + + const compactCalls: Array[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); + }); }); diff --git a/src/agents/command/cli-compaction.ts b/src/agents/command/cli-compaction.ts index bb86cc6d3c9..ce5cbce0d50 100644 --- a/src/agents/command/cli-compaction.ts +++ b/src/agents/command/cli-compaction.ts @@ -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; diff --git a/src/plugins/cli-backend.types.ts b/src/plugins/cli-backend.types.ts index f8175cf03dd..780a30530d4 100644 --- a/src/plugins/cli-backend.types.ts +++ b/src/plugins/cli-backend.types.ts @@ -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. *