fix(cli-runner): scale Claude CLI reseed history automatically

Remove the proposed public `maxReseedHistoryChars` config surface and scale Claude CLI reseed history automatically from the resolved context tier instead.

Claude CLI 200K-context runs now keep a 64K-character reseed slice, 1M Opus/Sonnet runs use the bounded 256KiB cap, and non-Claude CLI backends keep the existing 12KiB default. This preserves the intended long-context behavior without adding another config option.

Verification:
- `node scripts/run-vitest.mjs src/agents/cli-runner/session-history.test.ts src/agents/cli-runner/prepare.test.ts`
- `node scripts/run-vitest.mjs src/agents/cli-runner/prepare.test.ts -t "automatic Claude CLI cap"`
- `node scripts/run-oxlint.mjs src/agents/cli-runner/prepare.ts src/agents/cli-runner/prepare.test.ts src/agents/cli-runner/session-history.ts src/agents/cli-runner/session-history.test.ts src/config/types.agent-defaults.ts src/config/zod-schema.core.ts`
- `pnpm check:changed` via Testbox `tbx_01kska2twjxb925xft9dj82hvb`
- GitHub PR checks green

Closes #83985
Co-authored-by: Abdel Gomez-Perez <nabdel07@icloud.com>
This commit is contained in:
Abdel Gomez-Perez
2026-05-26 19:41:01 -04:00
committed by GitHub
parent 8592352c24
commit 474b1e0386
5 changed files with 300 additions and 0 deletions

View File

@@ -399,6 +399,22 @@ minutes; set `0` to disable). One-shot embedded runs such as auth probes,
slug generation, and active-memory recall request cleanup at run end so stdio
children and Streamable HTTP/SSE streams do not outlive the run.
## Reseed history cap
When a fresh CLI session is seeded from a prior OpenClaw transcript (for
example after a `session_expired` retry), the rendered
`<conversation_history>` block is capped to keep reseed prompts from
exploding. The default is `12288` characters (about 3000 tokens).
Claude CLI backends automatically use a larger cap derived from the resolved
Claude context tier. Standard 200K-token Claude runs keep a larger transcript
slice, and 1M-token Claude runs keep a larger slice again, while other CLI
backends keep the conservative default.
- The cap only governs the reseed prompt's prior-history block. Live-session
output limits are tuned separately under `reliability.outputLimits`
(see [Sessions](#sessions)).
## Limitations
- **No direct OpenClaw tool calls.** OpenClaw does not inject tool calls into

View File

@@ -14,6 +14,7 @@ import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js";
import { clearMemoryPluginState, registerMemoryPromptSection } from "../../plugins/memory-state.js";
import { testing as cliBackendsTesting } from "../cli-backends.js";
import { hashCliSessionText } from "../cli-session.js";
import { resetContextWindowCacheForTest } from "../context.js";
import { buildActiveImageGenerationTaskPromptContextForSession } from "../image-generation-task-status.js";
import { buildActiveMusicGenerationTaskPromptContextForSession } from "../music-generation-task-status.js";
import { buildActiveVideoGenerationTaskPromptContextForSession } from "../video-generation-task-status.js";
@@ -218,6 +219,7 @@ describe("shouldSkipLocalCliCredentialEpoch", () => {
mockBuildActiveImageGenerationTaskPromptContextForSession.mockReset();
mockBuildActiveVideoGenerationTaskPromptContextForSession.mockReset();
mockBuildActiveMusicGenerationTaskPromptContextForSession.mockReset();
resetContextWindowCacheForTest();
clearMemoryPluginState();
vi.unstubAllEnvs();
});
@@ -1480,4 +1482,222 @@ describe("shouldSkipLocalCliCredentialEpoch", () => {
fs.rmSync(dir, { recursive: true, force: true });
}
});
it("uses a larger automatic reseed history cap for Claude CLI", async () => {
const { dir, sessionFile } = createSessionFile();
try {
cliBackendsTesting.setDepsForTest({
resolvePluginSetupCliBackend: () => undefined,
resolveRuntimeCliBackends: () => [
{
id: "claude-cli",
pluginId: "anthropic",
bundleMcp: false,
config: {
command: "claude",
args: ["--print"],
output: "jsonl",
input: "stdin",
sessionMode: "existing",
},
},
],
});
const summaryMarker = "RESEED_SUMMARY_MARKER_KEEP";
const padding = "x".repeat(40_000);
fs.appendFileSync(
sessionFile,
`${JSON.stringify({
type: "compaction",
summary: `${summaryMarker} ${padding}`,
})}\n`,
"utf-8",
);
const context = await prepareCliRunContext({
sessionId: "session-test",
sessionFile,
workspaceDir: dir,
prompt: "latest ask",
provider: "claude-cli",
model: "claude-haiku-3-5",
timeoutMs: 1_000,
runId: "run-auto-claude-reseed-history-chars",
config: createCliBackendConfig({ systemPromptOverride: null }),
});
expect(context.openClawHistoryPrompt).toBeDefined();
expect(context.openClawHistoryPrompt).toContain(summaryMarker);
expect(context.openClawHistoryPrompt).not.toContain("OpenClaw reseed history truncated");
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
it("uses the automatic Claude CLI cap before mapping canonical models to CLI aliases", async () => {
const { dir, sessionFile } = createSessionFile();
try {
cliBackendsTesting.setDepsForTest({
resolvePluginSetupCliBackend: () => undefined,
resolveRuntimeCliBackends: () => [
{
id: "claude-cli",
pluginId: "anthropic",
bundleMcp: false,
config: {
command: "claude",
args: ["--print"],
output: "jsonl",
input: "stdin",
sessionMode: "existing",
modelAliases: {
"claude-opus-4-7": "opus",
},
},
},
],
});
const summaryMarker = "RESEED_ALIAS_SUMMARY_MARKER_KEEP";
const padding = "x".repeat(90_000);
fs.appendFileSync(
sessionFile,
`${JSON.stringify({
type: "compaction",
summary: `${summaryMarker} ${padding}`,
})}\n`,
"utf-8",
);
const context = await prepareCliRunContext({
sessionId: "session-test",
sessionFile,
workspaceDir: dir,
prompt: "latest ask",
provider: "claude-cli",
model: "claude-opus-4-7",
timeoutMs: 1_000,
runId: "run-auto-claude-alias-reseed-history-chars",
config: createCliBackendConfig({ systemPromptOverride: null }),
});
expect(context.openClawHistoryPrompt).toBeDefined();
expect(context.openClawHistoryPrompt).toContain(summaryMarker);
expect(context.openClawHistoryPrompt).not.toContain("OpenClaw reseed history truncated");
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
it("keeps the default reseed history cap for non-Claude CLI backends", async () => {
const { dir, sessionFile } = createSessionFile();
try {
const summaryMarker = "RESEED_SUMMARY_MARKER_DEFAULT";
const padding = "x".repeat(20_000);
fs.appendFileSync(
sessionFile,
`${JSON.stringify({
type: "compaction",
summary: `${summaryMarker} ${padding}`,
})}\n`,
"utf-8",
);
const context = await prepareCliRunContext({
sessionId: "session-test",
sessionFile,
workspaceDir: dir,
prompt: "latest ask",
provider: "test-cli",
model: "test-model",
timeoutMs: 1_000,
runId: "run-default-reseed-history-chars",
config: createCliBackendConfig({ systemPromptOverride: null }),
});
expect(context.openClawHistoryPrompt).toBeDefined();
expect(context.openClawHistoryPrompt).toContain("OpenClaw reseed history truncated");
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
it("uses the automatic Claude CLI cap through the raw-tail reseed path", async () => {
const { dir, sessionFile } = createSessionFile();
cliBackendsTesting.setDepsForTest({
resolvePluginSetupCliBackend: () => undefined,
resolveRuntimeCliBackends: () => [
{
id: "claude-cli",
pluginId: "anthropic",
bundleMcp: false,
config: {
command: "claude",
args: ["--print"],
output: "jsonl",
input: "stdin",
sessionMode: "existing",
reseedFromRawTranscriptWhenUncompacted: true,
},
},
],
});
setCliRunnerPrepareTestDeps({
claudeCliSessionTranscriptHasContent: vi.fn(async () => true),
});
const recentMarker = "RAW_RESEED_RECENT_MARKER_KEEP";
const padding = "x".repeat(8_000);
appendTranscriptEntry(sessionFile, {
id: "msg-1",
parentId: null,
timestamp: new Date(1).toISOString(),
message: { role: "user", content: `EARLIEST_USER ${padding}`, timestamp: 1 },
});
appendTranscriptEntry(sessionFile, {
id: "msg-2",
parentId: "msg-1",
timestamp: new Date(2).toISOString(),
message: {
role: "assistant",
content: [{ type: "text", text: `${recentMarker} ${padding}` }],
api: "responses",
provider: "test-cli",
model: "test-model",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
timestamp: 2,
},
});
try {
const context = await prepareCliRunContext({
sessionId: "session-test",
sessionFile,
workspaceDir: dir,
prompt: "latest ask",
provider: "claude-cli",
model: "claude-haiku-3-5",
timeoutMs: 1_000,
runId: "run-raw-reseed-cap-override",
cliSessionBinding: { sessionId: "cli-session" },
config: createCliBackendConfig({ systemPromptOverride: null }),
});
expect(context.reusableCliSession).toEqual({ sessionId: "cli-session" });
expect(context.openClawHistoryPrompt).toBeDefined();
expect(context.openClawHistoryPrompt).toContain(recentMarker);
expect(context.openClawHistoryPrompt).toContain("EARLIEST_USER");
expect(context.openClawHistoryPrompt).not.toContain("OpenClaw reseed history truncated");
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
});

View File

@@ -40,6 +40,7 @@ import { resolveCliBackendConfig } from "../cli-backends.js";
import { hashCliSessionText, resolveCliSessionReuse } from "../cli-session.js";
import { claudeCliSessionTranscriptHasContent } from "../command/attempt-execution.helpers.js";
import { resolveContextWindowInfo } from "../context-window-guard.js";
import { resolveContextTokensForModel } from "../context.js";
import { DEFAULT_CONTEXT_TOKENS } from "../defaults.js";
import { resolveHeartbeatPromptForSystemPrompt } from "../heartbeat-system-prompt.js";
import {
@@ -65,6 +66,7 @@ import {
hasCliSessionTranscript,
loadCliSessionHistoryMessages,
loadCliSessionReseedMessages,
resolveAutoCliSessionReseedHistoryChars,
} from "./session-history.js";
import type { CliReusableSession, PreparedCliRunContext, RunCliAgentParams } from "./types.js";
@@ -84,6 +86,23 @@ const prepareDeps = {
claudeCliSessionTranscriptHasContent,
};
const CLAUDE_CLI_CONTEXT_MODEL_ALIASES: Record<string, string> = {
opus: "claude-opus-4-7",
"opus-4.7": "claude-opus-4-7",
"opus-4-7": "claude-opus-4-7",
"opus-4.6": "claude-opus-4-6",
"opus-4-6": "claude-opus-4-6",
sonnet: "claude-sonnet-4-6",
"sonnet-4.6": "claude-sonnet-4-6",
"sonnet-4-6": "claude-sonnet-4-6",
};
function resolveClaudeCliContextModelId(modelId: string): string {
const trimmed = modelId.trim();
const lower = trimmed.toLowerCase();
return CLAUDE_CLI_CONTEXT_MODEL_ALIASES[lower] ?? trimmed;
}
export function setCliRunnerPrepareTestDeps(overrides: Partial<typeof prepareDeps>): void {
Object.assign(prepareDeps, overrides);
}
@@ -170,12 +189,26 @@ export async function prepareCliRunContext(
const modelId = (params.model ?? "default").trim() || "default";
const normalizedModel = normalizeCliModel(modelId, backendResolved.config);
const modelDisplay = `${params.provider}/${modelId}`;
const isClaudeCli = isClaudeCliProvider(params.provider);
const modelContextTokens = isClaudeCli
? resolveContextTokensForModel({
cfg: params.config,
provider: params.provider,
model: resolveClaudeCliContextModelId(modelId),
fallbackContextTokens: 200_000,
allowAsyncLoad: false,
})
: undefined;
const contextWindowInfo = resolveContextWindowInfo({
cfg: params.config,
provider: params.provider,
modelId,
modelContextTokens,
defaultTokens: DEFAULT_CONTEXT_TOKENS,
});
const autoReseedHistoryChars = isClaudeCli
? resolveAutoCliSessionReseedHistoryChars(contextWindowInfo.tokens)
: undefined;
const sessionLabel = params.sessionKey ?? params.sessionId;
const { bootstrapFiles, contextFiles } = await prepareDeps.resolveBootstrapContextForRun({
@@ -470,6 +503,7 @@ export async function prepareCliRunContext(
rawTranscriptReseedReason,
}),
prompt: preparedPrompt,
maxHistoryChars: autoReseedHistoryChars,
})
: undefined;
systemPrompt = appendModelIdentitySystemPrompt({

View File

@@ -9,8 +9,11 @@ import {
loadCliSessionContextEngineMessages,
loadCliSessionHistoryMessages,
loadCliSessionReseedMessages,
MAX_AUTO_CLI_SESSION_RESEED_HISTORY_CHARS,
MAX_CLI_SESSION_HISTORY_FILE_BYTES,
MAX_CLI_SESSION_HISTORY_MESSAGES,
MAX_CLI_SESSION_RESEED_HISTORY_CHARS,
resolveAutoCliSessionReseedHistoryChars,
} from "./session-history.js";
function createSessionTranscript(params: {
@@ -592,6 +595,17 @@ describe("buildCliSessionHistoryPrompt", () => {
expect(prompt).not.toContain("x".repeat(80));
});
it("scales automatic reseed history caps from Claude context tiers", () => {
expect(resolveAutoCliSessionReseedHistoryChars(0)).toBe(MAX_CLI_SESSION_RESEED_HISTORY_CHARS);
expect(resolveAutoCliSessionReseedHistoryChars(32_000)).toBe(
MAX_CLI_SESSION_RESEED_HISTORY_CHARS,
);
expect(resolveAutoCliSessionReseedHistoryChars(200_000)).toBe(64_000);
expect(resolveAutoCliSessionReseedHistoryChars(1_048_576)).toBe(
MAX_AUTO_CLI_SESSION_RESEED_HISTORY_CHARS,
);
});
it("keeps the most recent turns when rendered history exceeds the cap", () => {
// Older turns plus a final marker turn whose content is exactly what a
// head-slice would drop first. Asserting the marker survives in the

View File

@@ -17,6 +17,9 @@ import {
export const MAX_CLI_SESSION_HISTORY_FILE_BYTES = 5 * 1024 * 1024;
export const MAX_CLI_SESSION_HISTORY_MESSAGES = MAX_AGENT_HOOK_HISTORY_MESSAGES;
export const MAX_CLI_SESSION_RESEED_HISTORY_CHARS = 12 * 1024;
export const MAX_AUTO_CLI_SESSION_RESEED_HISTORY_CHARS = 256 * 1024;
const CLI_SESSION_RESEED_HISTORY_CONTEXT_SHARE = 0.08;
const CHARS_PER_TOKEN_ESTIMATE = 4;
type HistoryMessage = {
role?: unknown;
@@ -53,6 +56,19 @@ const RAW_TRANSCRIPT_RESEED_ALLOWED_REASONS = new Set<RawTranscriptReseedReason>
"session-expired",
]);
export function resolveAutoCliSessionReseedHistoryChars(contextWindowTokens: number): number {
if (!Number.isFinite(contextWindowTokens) || contextWindowTokens <= 0) {
return MAX_CLI_SESSION_RESEED_HISTORY_CHARS;
}
const contextShareChars = Math.floor(
contextWindowTokens * CLI_SESSION_RESEED_HISTORY_CONTEXT_SHARE * CHARS_PER_TOKEN_ESTIMATE,
);
return Math.max(
MAX_CLI_SESSION_RESEED_HISTORY_CHARS,
Math.min(MAX_AUTO_CLI_SESSION_RESEED_HISTORY_CHARS, contextShareChars),
);
}
function coerceHistoryText(content: unknown): string {
if (typeof content === "string") {
return content.trim();