mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-13 10:12:52 +00:00
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:
committed by
Ayaan Zaidi
parent
afbf895af0
commit
3d7523b618
@@ -29,6 +29,7 @@ export function buildAnthropicCliBackend(): CliBackendPlugin {
|
||||
bundleMcp: true,
|
||||
bundleMcpMode: "claude-config-file",
|
||||
nativeToolMode: "always-on",
|
||||
ownsNativeCompaction: true,
|
||||
config: {
|
||||
command: "claude",
|
||||
args: [
|
||||
|
||||
@@ -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" } },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user