mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 05:51:15 +08:00
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:
committed by
GitHub
parent
8592352c24
commit
474b1e0386
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user