Skip to content

Commit 3d7523b

Browse files
anagnorisis2peripeteiaobviyus
authored andcommitted
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.
1 parent afbf895 commit 3d7523b

6 files changed

Lines changed: 256 additions & 0 deletions

File tree

extensions/anthropic/cli-backend.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ export function buildAnthropicCliBackend(): CliBackendPlugin {
2929
bundleMcp: true,
3030
bundleMcpMode: "claude-config-file",
3131
nativeToolMode: "always-on",
32+
ownsNativeCompaction: true,
3233
config: {
3334
command: "claude",
3435
args: [

src/agents/cli-backends.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ function createBackendEntry(params: {
3131
bundleMcpMode?: CliBundleMcpMode;
3232
defaultAuthProfileId?: string;
3333
authEpochMode?: CliBackendAuthEpochMode;
34+
ownsNativeCompaction?: boolean;
3435
prepareExecution?: () => Promise<null>;
3536
resolveExecutionArgs?: CliBackendResolveExecutionArgs;
3637
normalizeConfig?: (
@@ -48,6 +49,7 @@ function createBackendEntry(params: {
4849
...(params.bundleMcpMode ? { bundleMcpMode: params.bundleMcpMode } : {}),
4950
...(params.defaultAuthProfileId ? { defaultAuthProfileId: params.defaultAuthProfileId } : {}),
5051
...(params.authEpochMode ? { authEpochMode: params.authEpochMode } : {}),
52+
...(params.ownsNativeCompaction ? { ownsNativeCompaction: params.ownsNativeCompaction } : {}),
5153
...(params.prepareExecution ? { prepareExecution: params.prepareExecution } : {}),
5254
...(params.resolveExecutionArgs ? { resolveExecutionArgs: params.resolveExecutionArgs } : {}),
5355
...(params.normalizeConfig ? { normalizeConfig: params.normalizeConfig } : {}),
@@ -230,6 +232,7 @@ beforeEach(() => {
230232
id: "claude-cli",
231233
bundleMcp: true,
232234
bundleMcpMode: "claude-config-file",
235+
ownsNativeCompaction: true,
233236
config: {
234237
command: "claude",
235238
args: [
@@ -546,6 +549,11 @@ describe("resolveCliBackendConfig claude-cli defaults", () => {
546549
expect(resolved?.config.resumeArgs).not.toContain("--dangerously-skip-permissions");
547550
});
548551

552+
it("declares ownsNativeCompaction for claude-cli", () => {
553+
const resolved = requireCliBackendConfig("claude-cli");
554+
expect(resolved?.ownsNativeCompaction).toBe(true);
555+
});
556+
549557
it("keeps Claude permission mode unset when OpenClaw exec policy is not YOLO", () => {
550558
const resolved = requireCliBackendConfig("claude-cli", {
551559
tools: { exec: { security: "allowlist", ask: "on-miss" } },

src/agents/cli-backends.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ export type ResolvedCliBackend = {
4646
defaultAuthProfileId?: string;
4747
authEpochMode?: CliBackendAuthEpochMode;
4848
contextEngineHostCapabilities?: readonly ContextEngineHostCapability[];
49+
ownsNativeCompaction?: boolean;
4950
prepareExecution?: CliBackendPlugin["prepareExecution"];
5051
resolveExecutionArgs?: CliBackendPlugin["resolveExecutionArgs"];
5152
nativeToolMode?: CliBackendNativeToolMode;
@@ -79,6 +80,7 @@ type FallbackCliBackendPolicy = {
7980
defaultAuthProfileId?: string;
8081
authEpochMode?: CliBackendAuthEpochMode;
8182
contextEngineHostCapabilities?: readonly ContextEngineHostCapability[];
83+
ownsNativeCompaction?: boolean;
8284
prepareExecution?: CliBackendPlugin["prepareExecution"];
8385
resolveExecutionArgs?: CliBackendPlugin["resolveExecutionArgs"];
8486
nativeToolMode?: CliBackendNativeToolMode;
@@ -119,6 +121,7 @@ function resolveSetupCliBackendPolicy(provider: string): FallbackCliBackendPolic
119121
defaultAuthProfileId: entry.backend.defaultAuthProfileId,
120122
authEpochMode: entry.backend.authEpochMode,
121123
contextEngineHostCapabilities: entry.backend.contextEngineHostCapabilities,
124+
ownsNativeCompaction: entry.backend.ownsNativeCompaction,
122125
prepareExecution: entry.backend.prepareExecution,
123126
resolveExecutionArgs: entry.backend.resolveExecutionArgs,
124127
nativeToolMode: entry.backend.nativeToolMode,
@@ -411,6 +414,7 @@ export function resolveCliBackendConfig(
411414
defaultAuthProfileId: registered.defaultAuthProfileId,
412415
authEpochMode: registered.authEpochMode,
413416
contextEngineHostCapabilities: registered.contextEngineHostCapabilities,
417+
ownsNativeCompaction: registered.ownsNativeCompaction,
414418
prepareExecution: registered.prepareExecution,
415419
resolveExecutionArgs: registered.resolveExecutionArgs,
416420
nativeToolMode: registered.nativeToolMode,
@@ -443,6 +447,7 @@ export function resolveCliBackendConfig(
443447
defaultAuthProfileId: fallbackPolicy.defaultAuthProfileId,
444448
authEpochMode: fallbackPolicy.authEpochMode,
445449
contextEngineHostCapabilities: fallbackPolicy.contextEngineHostCapabilities,
450+
ownsNativeCompaction: fallbackPolicy.ownsNativeCompaction,
446451
prepareExecution: fallbackPolicy.prepareExecution,
447452
resolveExecutionArgs: fallbackPolicy.resolveExecutionArgs,
448453
nativeToolMode: fallbackPolicy.nativeToolMode,
@@ -472,6 +477,7 @@ export function resolveCliBackendConfig(
472477
defaultAuthProfileId: fallbackPolicy?.defaultAuthProfileId,
473478
authEpochMode: fallbackPolicy?.authEpochMode,
474479
contextEngineHostCapabilities: fallbackPolicy?.contextEngineHostCapabilities,
480+
ownsNativeCompaction: fallbackPolicy?.ownsNativeCompaction,
475481
prepareExecution: fallbackPolicy?.prepareExecution,
476482
resolveExecutionArgs: fallbackPolicy?.resolveExecutionArgs,
477483
nativeToolMode: fallbackPolicy?.nativeToolMode,

src/agents/command/cli-compaction.test.ts

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,13 @@ describe("runCliTurnCompactionLifecycle", () => {
7676

7777
beforeEach(async () => {
7878
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cli-compaction-"));
79+
// Default backends to non-owning so the context-engine compaction-path tests
80+
// exercise that path. On current main resolveCliBackendConfig("claude-cli")
81+
// resolves the (now ownsNativeCompaction) backend even in unit tests, which
82+
// would otherwise route every claude-cli compaction test through the #88315
83+
// defer no-op. The ownsNativeCompaction-specific tests override this with an
84+
// owning backend to exercise the defer.
85+
setCliCompactionTestDeps({ resolveCliBackendConfig: () => null });
7986
});
8087

8188
afterEach(async () => {
@@ -1406,4 +1413,212 @@ describe("runCliTurnCompactionLifecycle", () => {
14061413
"claude-session",
14071414
);
14081415
});
1416+
1417+
it("skips compaction when backend declares ownsNativeCompaction and has no harness endpoint", async () => {
1418+
const sessionKey = "agent:main:claude-owns-compaction";
1419+
const sessionId = "session-claude-owns";
1420+
const sessionFile = path.join(tmpDir, "session-claude-owns.jsonl");
1421+
const storePath = path.join(tmpDir, "sessions-claude-owns.json");
1422+
await writeSessionFile({ sessionFile, sessionId });
1423+
1424+
const sessionEntry: SessionEntry = {
1425+
sessionId,
1426+
updatedAt: Date.now(),
1427+
sessionFile,
1428+
contextTokens: 1_000,
1429+
totalTokens: 950,
1430+
totalTokensFresh: true,
1431+
};
1432+
const sessionStore: Record<string, SessionEntry> = { [sessionKey]: sessionEntry };
1433+
await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2), "utf-8");
1434+
1435+
const compactCalls: Array<Parameters<ContextEngine["compact"]>[0]> = [];
1436+
const compactAgentHarnessSession = vi.fn();
1437+
const recordCliCompactionInStore = vi.fn();
1438+
setCliCompactionTestDeps({
1439+
resolveContextEngine: async () => buildContextEngine({ compactCalls }),
1440+
maybeCompactAgentHarnessSession: compactAgentHarnessSession as never,
1441+
resolveCliBackendConfig: () => ({
1442+
id: "claude-cli",
1443+
config: { command: "claude" },
1444+
bundleMcp: true,
1445+
ownsNativeCompaction: true,
1446+
}),
1447+
createPreparedEmbeddedAgentSettingsManager: async () => ({
1448+
getCompactionReserveTokens: () => 200,
1449+
getCompactionKeepRecentTokens: () => 0,
1450+
applyOverrides: () => {},
1451+
}),
1452+
shouldPreemptivelyCompactBeforePrompt: () => ({
1453+
route: "fits",
1454+
shouldCompact: false,
1455+
estimatedPromptTokens: 600,
1456+
promptBudgetBeforeReserve: 800,
1457+
overflowTokens: 0,
1458+
toolResultReducibleChars: 0,
1459+
effectiveReserveTokens: 200,
1460+
}),
1461+
resolveLiveToolResultMaxChars: () => 20_000,
1462+
recordCliCompactionInStore,
1463+
});
1464+
1465+
const updatedEntry = await runCliTurnCompactionLifecycle({
1466+
cfg: {} as OpenClawConfig,
1467+
sessionId,
1468+
sessionKey,
1469+
sessionEntry,
1470+
sessionStore,
1471+
storePath,
1472+
sessionAgentId: "main",
1473+
workspaceDir: tmpDir,
1474+
agentDir: tmpDir,
1475+
provider: "claude-cli",
1476+
model: "opus",
1477+
});
1478+
1479+
expect(compactAgentHarnessSession).not.toHaveBeenCalled();
1480+
expect(compactCalls).toHaveLength(0);
1481+
expect(recordCliCompactionInStore).not.toHaveBeenCalled();
1482+
expect(updatedEntry).toBe(sessionEntry);
1483+
});
1484+
1485+
it("does not skip compaction when backend does not declare ownsNativeCompaction", async () => {
1486+
const sessionKey = "agent:main:generic-no-ownership";
1487+
const sessionId = "session-generic";
1488+
const sessionFile = path.join(tmpDir, "session-generic.jsonl");
1489+
const storePath = path.join(tmpDir, "sessions-generic.json");
1490+
await writeSessionFile({ sessionFile, sessionId });
1491+
1492+
const sessionEntry: SessionEntry = {
1493+
sessionId,
1494+
updatedAt: Date.now(),
1495+
sessionFile,
1496+
contextTokens: 1_000,
1497+
totalTokens: 950,
1498+
totalTokensFresh: true,
1499+
};
1500+
const sessionStore: Record<string, SessionEntry> = { [sessionKey]: sessionEntry };
1501+
await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2), "utf-8");
1502+
1503+
const compactCalls: Array<Parameters<ContextEngine["compact"]>[0]> = [];
1504+
const maintenance = vi.fn(async () => ({ changed: false, bytesFreed: 0, rewrittenEntries: 0 }));
1505+
setCliCompactionTestDeps({
1506+
resolveContextEngine: async () => buildContextEngine({ compactCalls }),
1507+
resolveCliBackendConfig: () => ({
1508+
id: "generic-backend",
1509+
config: { command: "generic" },
1510+
bundleMcp: false,
1511+
}),
1512+
createPreparedEmbeddedAgentSettingsManager: async () => ({
1513+
getCompactionReserveTokens: () => 200,
1514+
getCompactionKeepRecentTokens: () => 0,
1515+
applyOverrides: () => {},
1516+
}),
1517+
shouldPreemptivelyCompactBeforePrompt: () => ({
1518+
route: "fits",
1519+
shouldCompact: false,
1520+
estimatedPromptTokens: 600,
1521+
promptBudgetBeforeReserve: 800,
1522+
overflowTokens: 0,
1523+
toolResultReducibleChars: 0,
1524+
effectiveReserveTokens: 200,
1525+
}),
1526+
resolveLiveToolResultMaxChars: () => 20_000,
1527+
runContextEngineMaintenance: maintenance,
1528+
});
1529+
1530+
await runCliTurnCompactionLifecycle({
1531+
cfg: {} as OpenClawConfig,
1532+
sessionId,
1533+
sessionKey,
1534+
sessionEntry,
1535+
sessionStore,
1536+
storePath,
1537+
sessionAgentId: "main",
1538+
workspaceDir: tmpDir,
1539+
agentDir: tmpDir,
1540+
provider: "generic-backend",
1541+
model: "model",
1542+
});
1543+
1544+
expect(compactCalls).toHaveLength(1);
1545+
});
1546+
1547+
it("still uses native harness path when backend declares ownsNativeCompaction and has agentHarnessId", async () => {
1548+
const sessionKey = "agent:main:codex-with-ownership";
1549+
const sessionId = "session-codex-ownership";
1550+
const sessionFile = path.join(tmpDir, "session-codex-ownership.jsonl");
1551+
const storePath = path.join(tmpDir, "sessions-codex-ownership.json");
1552+
await writeSessionFile({ sessionFile, sessionId });
1553+
1554+
const sessionEntry: SessionEntry = {
1555+
sessionId,
1556+
updatedAt: Date.now(),
1557+
sessionFile,
1558+
contextTokens: 1_000,
1559+
totalTokens: 950,
1560+
totalTokensFresh: true,
1561+
agentHarnessId: "codex",
1562+
};
1563+
const sessionStore: Record<string, SessionEntry> = { [sessionKey]: sessionEntry };
1564+
await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2), "utf-8");
1565+
1566+
const compactCalls: Array<Parameters<ContextEngine["compact"]>[0]> = [];
1567+
const contextEngine = buildContextEngine({ compactCalls });
1568+
const compactAgentHarnessSession = vi.fn(async () => ({
1569+
ok: true,
1570+
compacted: true,
1571+
result: { tokensBefore: 950, tokensAfter: 100 },
1572+
}));
1573+
const recordCliCompactionInStore = vi.fn(async () => ({
1574+
...sessionEntry,
1575+
compactionCount: 1,
1576+
}));
1577+
setCliCompactionTestDeps({
1578+
resolveContextEngine: async () => contextEngine,
1579+
ensureSelectedAgentHarnessPlugin: vi.fn(async () => undefined),
1580+
maybeCompactAgentHarnessSession: compactAgentHarnessSession as never,
1581+
resolveCliBackendConfig: () => ({
1582+
id: "codex",
1583+
config: { command: "codex" },
1584+
bundleMcp: false,
1585+
ownsNativeCompaction: true,
1586+
}),
1587+
createPreparedEmbeddedAgentSettingsManager: async () => ({
1588+
getCompactionReserveTokens: () => 200,
1589+
getCompactionKeepRecentTokens: () => 0,
1590+
applyOverrides: () => {},
1591+
}),
1592+
shouldPreemptivelyCompactBeforePrompt: () => ({
1593+
route: "fits",
1594+
shouldCompact: false,
1595+
estimatedPromptTokens: 600,
1596+
promptBudgetBeforeReserve: 800,
1597+
overflowTokens: 0,
1598+
toolResultReducibleChars: 0,
1599+
effectiveReserveTokens: 200,
1600+
}),
1601+
resolveLiveToolResultMaxChars: () => 20_000,
1602+
applyAgentAutoCompactionGuard: vi.fn(async () => ({ supported: true, disabled: false })),
1603+
recordCliCompactionInStore,
1604+
});
1605+
1606+
await runCliTurnCompactionLifecycle({
1607+
cfg: {} as OpenClawConfig,
1608+
sessionId,
1609+
sessionKey,
1610+
sessionEntry,
1611+
sessionStore,
1612+
storePath,
1613+
sessionAgentId: "main",
1614+
workspaceDir: tmpDir,
1615+
agentDir: tmpDir,
1616+
provider: "openai",
1617+
model: "gpt-5.5",
1618+
});
1619+
1620+
expect(compactAgentHarnessSession).toHaveBeenCalledTimes(1);
1621+
expect(compactCalls).toHaveLength(0);
1622+
expect(recordCliCompactionInStore).toHaveBeenCalledTimes(1);
1623+
});
14091624
});

src/agents/command/cli-compaction.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import { ensureSelectedAgentHarnessPlugin as ensureSelectedAgentHarnessPluginImp
2929
import { maybeCompactAgentHarnessSession as maybeCompactAgentHarnessSessionImpl } from "../harness/selection.js";
3030
import type { AgentMessage } from "../runtime/index.js";
3131
import { SessionManager } from "../sessions/session-manager.js";
32+
import { resolveCliBackendConfig as resolveCliBackendConfigImpl } from "../cli-backends.js";
3233
import {
3334
clearCliSessionInStore as clearCliSessionInStoreImpl,
3435
recordCliCompactionInStore as recordCliCompactionInStoreImpl,
@@ -69,6 +70,7 @@ type CliCompactionDeps = {
6970
ensureSelectedAgentHarnessPlugin: typeof ensureSelectedAgentHarnessPluginImpl;
7071
maybeCompactAgentHarnessSession: typeof maybeCompactAgentHarnessSessionImpl;
7172
clearCliSessionInStore: typeof clearCliSessionInStoreImpl;
73+
resolveCliBackendConfig: typeof resolveCliBackendConfigImpl;
7274
recordCliCompactionInStore: typeof recordCliCompactionInStoreImpl;
7375
};
7476

@@ -117,6 +119,7 @@ const cliCompactionDeps: CliCompactionDeps = {
117119
ensureSelectedAgentHarnessPlugin: ensureSelectedAgentHarnessPluginImpl,
118120
maybeCompactAgentHarnessSession: maybeCompactAgentHarnessSessionImpl,
119121
clearCliSessionInStore: clearCliSessionInStoreImpl,
122+
resolveCliBackendConfig: resolveCliBackendConfigImpl,
120123
recordCliCompactionInStore: recordCliCompactionInStoreImpl,
121124
};
122125

@@ -137,6 +140,7 @@ export function resetCliCompactionTestDeps(): void {
137140
ensureSelectedAgentHarnessPlugin: ensureSelectedAgentHarnessPluginImpl,
138141
maybeCompactAgentHarnessSession: maybeCompactAgentHarnessSessionImpl,
139142
clearCliSessionInStore: clearCliSessionInStoreImpl,
143+
resolveCliBackendConfig: resolveCliBackendConfigImpl,
140144
recordCliCompactionInStore: recordCliCompactionInStoreImpl,
141145
});
142146
}
@@ -525,6 +529,21 @@ export async function runCliTurnCompactionLifecycle(params: {
525529
return params.sessionEntry;
526530
}
527531

532+
// When the backend declares native compaction ownership but has no harness
533+
// compaction endpoint (e.g. claude-cli — Claude Code compacts its own
534+
// transcript internally), skip both native-harness and context-engine
535+
// compaction. The backend will handle it; OpenClaw returns a no-op.
536+
const resolvedBackend = cliCompactionDeps.resolveCliBackendConfig(params.provider, params.cfg);
537+
if (
538+
resolvedBackend?.ownsNativeCompaction &&
539+
!isNativeHarnessCompactionSession(params.sessionEntry, params.provider)
540+
) {
541+
log.info(
542+
`CLI backend "${params.provider}" owns native compaction — deferring to backend`,
543+
);
544+
return params.sessionEntry;
545+
}
546+
528547
let compacted = false;
529548
let nativeCompactionResult: EmbeddedAgentCompactResult | undefined;
530549
let useContextEngineCompaction = true;

src/plugins/cli-backend.types.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,13 @@ export type CliBackendPlugin = {
8282
* driven through the generic CLI runner.
8383
*/
8484
contextEngineHostCapabilities?: readonly ContextEngineHostCapability[];
85+
/**
86+
* When true, the backend manages its own transcript compaction lifecycle
87+
* (e.g. Claude Code's internal auto-compaction). OpenClaw will skip its
88+
* safeguard summarizer and return a no-op from the compaction path instead
89+
* of fighting the backend's own compaction or hard-failing the turn.
90+
*/
91+
ownsNativeCompaction?: boolean;
8592
/**
8693
* Optional live-smoke metadata owned by the backend plugin.
8794
*

0 commit comments

Comments
 (0)