mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-10 01:50:41 +00:00
fix(agents): seed claude-cli fallback prompts with prior-session context (#69973)
When a claude-cli attempt failed with a fallbackable error (e.g. a 402 billing limit), the next candidate -- typically a non-CLI provider -- ran with no prior conversation context. Claude Code keeps its own JSONL session under ~/.claude/projects/, but the fallback runner only sees what OpenClaw assembles from its own transcript, which is empty for claude-cli sessions. The fallback model therefore behaved as if the conversation just started, even though Claude later resumed fine. Resolution mirrors what Claude Code itself does on resume after compaction: prefer the explicit `/compact` summary, then append the most recent post-boundary turns up to a char budget. Concretely: - `readClaudeCliFallbackSeed` (gateway): walks the Claude JSONL with awareness of `type: "summary"` and `type: "system", subtype: "compact_boundary"` entries. Pre-boundary turns are dropped (they are represented by the summary); post-boundary turns become the recent-window. Multiple compactions are handled by preferring the latest summary. Path safety reuses the existing `resolveClaudeCliSessionFilePath` validation. - `formatClaudeCliFallbackPrelude` / `buildClaudeCliFallbackContext\ Prelude` (agents helpers): format the harvested seed into a labeled prelude. Tool blocks are coalesced to compact "(tool call: name)" / "(tool result: …)" hints to keep the prompt budget honest. Newest turns are kept first when truncating; the summary is clearly labeled "(truncated)" if it overflows. - `resolveFallbackRetryPrompt`: gains an optional `priorContextPrelude` that prepends before the existing retry marker. Empty/whitespace preludes are ignored; first-attempt prompts are unchanged. - `runAgentAttempt`: builds the prelude when `isFallbackRetry === true` AND the new candidate is non-claude-cli AND a Claude-cli session binding is present. Same-provider fallbacks (claude-cli to claude-cli) are unaffected because Claude's own --resume still works. Verified the new tests (12 in cli-session-history, 12 added to attempt-execution) catch the regression: removing the prelude prepend in resolveFallbackRetryPrompt makes both new prelude cases fail, restoring the original cold-start behavior. References: - https://code.claude.com/docs/en/how-claude-code-works - "Inside Claude Code: The Session File Format" https://databunny.medium.com/inside-claude-code-the-session-file-format-and-how-to-inspect-it-b9998e66d56b
This commit is contained in:
@@ -9,6 +9,10 @@ import {
|
||||
startsWithSilentToken,
|
||||
stripLeadingSilentToken,
|
||||
} from "../../auto-reply/tokens.js";
|
||||
import {
|
||||
type ClaudeCliFallbackSeed,
|
||||
readClaudeCliFallbackSeed,
|
||||
} from "../../gateway/cli-session-history.js";
|
||||
|
||||
/** Maximum number of JSONL records to inspect before giving up. */
|
||||
const SESSION_FILE_MAX_RECORDS = 500;
|
||||
@@ -105,11 +109,21 @@ export function resolveFallbackRetryPrompt(params: {
|
||||
body: string;
|
||||
isFallbackRetry: boolean;
|
||||
sessionHasHistory?: boolean;
|
||||
/**
|
||||
* Optional context prelude (e.g., a compacted summary harvested from a
|
||||
* non-OpenClaw transcript such as Claude Code's local JSONL). Prepended
|
||||
* before the retry marker so the fallback candidate has prior context
|
||||
* even when OpenClaw's own session file is empty for the current
|
||||
* provider — see `buildClaudeCliFallbackContextPrelude` for the
|
||||
* claude-cli case (#69973).
|
||||
*/
|
||||
priorContextPrelude?: string;
|
||||
}): string {
|
||||
if (!params.isFallbackRetry) {
|
||||
return params.body;
|
||||
}
|
||||
if (!params.sessionHasHistory) {
|
||||
const prelude = params.priorContextPrelude?.trim();
|
||||
if (!params.sessionHasHistory && !prelude) {
|
||||
return params.body;
|
||||
}
|
||||
// Even with persisted session history, fully replacing the body with a
|
||||
@@ -118,7 +132,165 @@ export function resolveFallbackRetryPrompt(params: {
|
||||
// instruction from history alone, which is fragile and sometimes
|
||||
// impossible. Prepend the retry context to the original body instead so
|
||||
// the fallback model has both the recovery signal AND the task. (#65760)
|
||||
return `[Retry after the previous model attempt failed or timed out]\n\n${params.body}`;
|
||||
const retryMarked = `[Retry after the previous model attempt failed or timed out]\n\n${params.body}`;
|
||||
return prelude ? `${prelude}\n\n${retryMarked}` : retryMarked;
|
||||
}
|
||||
|
||||
const CLAUDE_CLI_FALLBACK_PRELUDE_DEFAULT_CHAR_BUDGET = 8_000;
|
||||
const CLAUDE_CLI_FALLBACK_PRELUDE_MIN_TURN_CHARS = 64;
|
||||
|
||||
type FallbackTurnLikeMessage = Record<string, unknown>;
|
||||
|
||||
function extractFallbackTurnText(message: FallbackTurnLikeMessage): string {
|
||||
const content = message.content;
|
||||
if (typeof content === "string") {
|
||||
return content;
|
||||
}
|
||||
if (!Array.isArray(content)) {
|
||||
return "";
|
||||
}
|
||||
const parts: string[] = [];
|
||||
for (const block of content) {
|
||||
if (typeof block === "string") {
|
||||
parts.push(block);
|
||||
continue;
|
||||
}
|
||||
if (!block || typeof block !== "object") {
|
||||
continue;
|
||||
}
|
||||
const rec = block as Record<string, unknown>;
|
||||
if (typeof rec.text === "string") {
|
||||
parts.push(rec.text);
|
||||
continue;
|
||||
}
|
||||
// Tool calls: render as a compact "(tool: name)" hint so the fallback
|
||||
// model sees the conversation flow without the full tool argument blob,
|
||||
// which is rarely useful out of context and chews through char budget.
|
||||
if (rec.type === "tool_use" && typeof rec.name === "string") {
|
||||
parts.push(`(tool call: ${rec.name})`);
|
||||
continue;
|
||||
}
|
||||
if (rec.type === "tool_result") {
|
||||
const inner = typeof rec.content === "string" ? rec.content : undefined;
|
||||
if (inner) {
|
||||
parts.push(`(tool result: ${inner})`);
|
||||
} else {
|
||||
parts.push("(tool result)");
|
||||
}
|
||||
}
|
||||
}
|
||||
return parts.join("\n").trim();
|
||||
}
|
||||
|
||||
function formatFallbackTurns(
|
||||
turns: ReadonlyArray<FallbackTurnLikeMessage>,
|
||||
remainingBudget: number,
|
||||
): { text: string; consumed: number } {
|
||||
if (turns.length === 0 || remainingBudget <= 0) {
|
||||
return { text: "", consumed: 0 };
|
||||
}
|
||||
// Walk newest -> oldest, prepending lines until we exceed the budget.
|
||||
// Stops at the oldest turn we can include in full so we never deliver a
|
||||
// truncated mid-turn fragment to the fallback model.
|
||||
const lines: string[] = [];
|
||||
let consumed = 0;
|
||||
for (let i = turns.length - 1; i >= 0; i -= 1) {
|
||||
const turn = turns[i];
|
||||
if (!turn || typeof turn !== "object") {
|
||||
continue;
|
||||
}
|
||||
const role = turn.role;
|
||||
if (role !== "user" && role !== "assistant") {
|
||||
continue;
|
||||
}
|
||||
const text = extractFallbackTurnText(turn);
|
||||
if (!text) {
|
||||
continue;
|
||||
}
|
||||
const line = `${role}: ${text}`;
|
||||
if (consumed + line.length + 1 > remainingBudget) {
|
||||
// Skip this turn rather than chop it; if even the most recent turn
|
||||
// is too large to include cleanly, stop emitting (the prelude is a
|
||||
// best-effort sketch, not a transcript).
|
||||
break;
|
||||
}
|
||||
lines.unshift(line);
|
||||
consumed += line.length + 1;
|
||||
}
|
||||
return { text: lines.join("\n"), consumed };
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a previously-harvested Claude CLI session into a labeled prelude
|
||||
* suitable for prepending to a fallback candidate's prompt. Behavior matches
|
||||
* Claude Code's own resume strategy after compaction: prefer the explicit
|
||||
* summary, then append the most recent turns up to a char budget.
|
||||
*
|
||||
* Returns an empty string when neither a summary nor any usable turn fits in
|
||||
* the budget; callers can treat that as "no context to seed".
|
||||
*/
|
||||
export function formatClaudeCliFallbackPrelude(
|
||||
seed: ClaudeCliFallbackSeed,
|
||||
options?: { charBudget?: number },
|
||||
): string {
|
||||
const charBudget = Math.max(
|
||||
CLAUDE_CLI_FALLBACK_PRELUDE_MIN_TURN_CHARS,
|
||||
options?.charBudget ?? CLAUDE_CLI_FALLBACK_PRELUDE_DEFAULT_CHAR_BUDGET,
|
||||
);
|
||||
const sections: string[] = ["## Prior session context (from claude-cli)"];
|
||||
let remaining = charBudget - sections[0]!.length;
|
||||
if (seed.summaryText) {
|
||||
const summarySection = `\nSummary of earlier conversation:\n${seed.summaryText}`;
|
||||
if (summarySection.length <= remaining) {
|
||||
sections.push(summarySection);
|
||||
remaining -= summarySection.length;
|
||||
} else {
|
||||
// Truncate the summary at a word boundary if it's huge; clearly mark
|
||||
// the truncation so the fallback model treats the prelude as a hint,
|
||||
// not exhaustive state.
|
||||
const slice = seed.summaryText.slice(0, Math.max(0, remaining - 64));
|
||||
const lastBreak = slice.lastIndexOf(" ");
|
||||
const trimmed = lastBreak > 0 ? slice.slice(0, lastBreak).trimEnd() : slice.trimEnd();
|
||||
sections.push(`\nSummary of earlier conversation (truncated):\n${trimmed} …`);
|
||||
remaining = 0;
|
||||
}
|
||||
}
|
||||
if (remaining > CLAUDE_CLI_FALLBACK_PRELUDE_MIN_TURN_CHARS && seed.recentTurns.length > 0) {
|
||||
const { text } = formatFallbackTurns(
|
||||
seed.recentTurns as ReadonlyArray<FallbackTurnLikeMessage>,
|
||||
remaining - 32,
|
||||
);
|
||||
if (text) {
|
||||
sections.push(`\nRecent turns:\n${text}`);
|
||||
}
|
||||
}
|
||||
// No summary AND no fittable turns => nothing to seed beyond the heading,
|
||||
// which would just confuse the model. Drop the prelude entirely.
|
||||
if (sections.length === 1) {
|
||||
return "";
|
||||
}
|
||||
return sections.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the Claude CLI session pointed to by `cliSessionId` and format a
|
||||
* fallback prelude. Returns `""` when no session file is found or when the
|
||||
* harvested seed has no usable content.
|
||||
*/
|
||||
export function buildClaudeCliFallbackContextPrelude(params: {
|
||||
cliSessionId: string | undefined;
|
||||
homeDir?: string;
|
||||
charBudget?: number;
|
||||
}): string {
|
||||
const sessionId = params.cliSessionId?.trim();
|
||||
if (!sessionId) {
|
||||
return "";
|
||||
}
|
||||
const seed = readClaudeCliFallbackSeed({ cliSessionId: sessionId, homeDir: params.homeDir });
|
||||
if (!seed) {
|
||||
return "";
|
||||
}
|
||||
return formatClaudeCliFallbackPrelude(seed, { charBudget: params.charBudget });
|
||||
}
|
||||
|
||||
export function createAcpVisibleTextAccumulator() {
|
||||
|
||||
@@ -3,8 +3,10 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildClaudeCliFallbackContextPrelude,
|
||||
claudeCliSessionTranscriptHasContent,
|
||||
createAcpVisibleTextAccumulator,
|
||||
formatClaudeCliFallbackPrelude,
|
||||
resolveFallbackRetryPrompt,
|
||||
sessionFileHasContent,
|
||||
} from "./attempt-execution.helpers.js";
|
||||
@@ -77,6 +79,193 @@ describe("resolveFallbackRetryPrompt", () => {
|
||||
}),
|
||||
).toBe(originalBody);
|
||||
});
|
||||
|
||||
// #69973: even when OpenClaw's own session file is empty (the claude-cli
|
||||
// case where Claude Code maintains its own JSONL), a harvested
|
||||
// priorContextPrelude must still seed the retry prompt so the fallback
|
||||
// candidate has prior context.
|
||||
it("prepends priorContextPrelude before the retry marker on fallback retry", () => {
|
||||
const prelude = "## Prior session context (from claude-cli)\nuser: prior question";
|
||||
const result = resolveFallbackRetryPrompt({
|
||||
body: originalBody,
|
||||
isFallbackRetry: true,
|
||||
sessionHasHistory: true,
|
||||
priorContextPrelude: prelude,
|
||||
});
|
||||
expect(result).toBe(
|
||||
`${prelude}\n\n[Retry after the previous model attempt failed or timed out]\n\n${originalBody}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("emits the retry prompt with prelude even when sessionHasHistory is false (claude-cli case)", () => {
|
||||
const prelude = "## Prior session context (from claude-cli)\nuser: prior question";
|
||||
const result = resolveFallbackRetryPrompt({
|
||||
body: originalBody,
|
||||
isFallbackRetry: true,
|
||||
sessionHasHistory: false,
|
||||
priorContextPrelude: prelude,
|
||||
});
|
||||
expect(result).toBe(
|
||||
`${prelude}\n\n[Retry after the previous model attempt failed or timed out]\n\n${originalBody}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("ignores empty/whitespace priorContextPrelude", () => {
|
||||
expect(
|
||||
resolveFallbackRetryPrompt({
|
||||
body: originalBody,
|
||||
isFallbackRetry: true,
|
||||
sessionHasHistory: false,
|
||||
priorContextPrelude: " \n ",
|
||||
}),
|
||||
).toBe(originalBody);
|
||||
});
|
||||
|
||||
it("does not prepend prelude on non-fallback first attempts", () => {
|
||||
expect(
|
||||
resolveFallbackRetryPrompt({
|
||||
body: originalBody,
|
||||
isFallbackRetry: false,
|
||||
sessionHasHistory: true,
|
||||
priorContextPrelude: "anything",
|
||||
}),
|
||||
).toBe(originalBody);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatClaudeCliFallbackPrelude", () => {
|
||||
it("returns empty string when seed has neither summary nor turns", () => {
|
||||
expect(formatClaudeCliFallbackPrelude({ recentTurns: [] })).toBe("");
|
||||
});
|
||||
|
||||
it("emits summary alone when no turns are available", () => {
|
||||
const out = formatClaudeCliFallbackPrelude({
|
||||
summaryText: "User wants to ship a billing-aware fallback.",
|
||||
recentTurns: [],
|
||||
});
|
||||
expect(out).toContain("## Prior session context (from claude-cli)");
|
||||
expect(out).toContain("Summary of earlier conversation:");
|
||||
expect(out).toContain("User wants to ship a billing-aware fallback.");
|
||||
expect(out).not.toContain("Recent turns:");
|
||||
});
|
||||
|
||||
it("formats user/assistant turns and tags tool blocks with compact hints", () => {
|
||||
const out = formatClaudeCliFallbackPrelude({
|
||||
recentTurns: [
|
||||
{
|
||||
role: "user",
|
||||
content: "Earlier user question",
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "text", text: "Earlier assistant reply" },
|
||||
{ type: "tool_use", name: "Bash" },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "toolu_x",
|
||||
content: "Earlier tool output",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(out).toContain("## Prior session context (from claude-cli)");
|
||||
expect(out).toContain("Recent turns:");
|
||||
expect(out).toContain("user: Earlier user question");
|
||||
expect(out).toContain("assistant: Earlier assistant reply");
|
||||
expect(out).toContain("(tool call: Bash)");
|
||||
expect(out).toContain("(tool result: Earlier tool output)");
|
||||
});
|
||||
|
||||
it("truncates an oversized summary instead of dropping it silently", () => {
|
||||
const huge = "x ".repeat(10_000).trim();
|
||||
const out = formatClaudeCliFallbackPrelude(
|
||||
{ summaryText: huge, recentTurns: [] },
|
||||
{ charBudget: 600 },
|
||||
);
|
||||
expect(out).toContain("Summary of earlier conversation (truncated):");
|
||||
expect(out.length).toBeLessThan(800);
|
||||
// Trailing ellipsis tells the model the summary was clipped — better
|
||||
// than silently emitting a fragment that looks complete.
|
||||
expect(out).toMatch(/…$/);
|
||||
});
|
||||
|
||||
it("drops oldest turns first when the budget cannot fit all of them", () => {
|
||||
const turns = Array.from({ length: 10 }, (_, i) => ({
|
||||
role: "user" as const,
|
||||
content: `turn ${i + 1} ${"x".repeat(80)}`,
|
||||
}));
|
||||
const out = formatClaudeCliFallbackPrelude({ recentTurns: turns }, { charBudget: 350 });
|
||||
// Newest turn (turn 10) must be present; oldest (turn 1) must not be.
|
||||
expect(out).toContain("turn 10");
|
||||
expect(out).not.toContain("turn 1 ");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildClaudeCliFallbackContextPrelude", () => {
|
||||
it("returns empty string when no sessionId is provided", () => {
|
||||
expect(buildClaudeCliFallbackContextPrelude({ cliSessionId: undefined })).toBe("");
|
||||
expect(buildClaudeCliFallbackContextPrelude({ cliSessionId: " " })).toBe("");
|
||||
});
|
||||
|
||||
it("returns empty string when the Claude session file does not exist", async () => {
|
||||
const tmpHome = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-fallback-prelude-"));
|
||||
try {
|
||||
expect(
|
||||
buildClaudeCliFallbackContextPrelude({
|
||||
cliSessionId: "missing-session",
|
||||
homeDir: tmpHome,
|
||||
}),
|
||||
).toBe("");
|
||||
} finally {
|
||||
await fs.rm(tmpHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("reads a real Claude JSONL fixture and emits a labeled prelude end-to-end", async () => {
|
||||
const tmpHome = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-fallback-prelude-"));
|
||||
const sessionId = "e2e-session";
|
||||
const projectsDir = path.join(tmpHome, ".claude", "projects", "demo");
|
||||
try {
|
||||
await fs.mkdir(projectsDir, { recursive: true });
|
||||
const lines = [
|
||||
{
|
||||
type: "user",
|
||||
uuid: "u1",
|
||||
message: { role: "user", content: "prior question about deploys" },
|
||||
},
|
||||
{
|
||||
type: "assistant",
|
||||
uuid: "a1",
|
||||
message: {
|
||||
role: "assistant",
|
||||
model: "claude-sonnet-4-6",
|
||||
content: [{ type: "text", text: "prior answer about blue-green" }],
|
||||
},
|
||||
},
|
||||
];
|
||||
await fs.writeFile(
|
||||
path.join(projectsDir, `${sessionId}.jsonl`),
|
||||
`${lines.map((line) => JSON.stringify(line)).join("\n")}\n`,
|
||||
"utf-8",
|
||||
);
|
||||
const prelude = buildClaudeCliFallbackContextPrelude({
|
||||
cliSessionId: sessionId,
|
||||
homeDir: tmpHome,
|
||||
});
|
||||
expect(prelude).toContain("## Prior session context (from claude-cli)");
|
||||
expect(prelude).toContain("user: prior question about deploys");
|
||||
expect(prelude).toContain("assistant: prior answer about blue-green");
|
||||
} finally {
|
||||
await fs.rm(tmpHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("sessionFileHasContent", () => {
|
||||
|
||||
@@ -23,6 +23,7 @@ import { buildAgentRuntimeAuthPlan } from "../runtime-plan/auth.js";
|
||||
import { buildWorkspaceSkillSnapshot } from "../skills.js";
|
||||
import { buildUsageWithNoCost } from "../stream-message-shared.js";
|
||||
import {
|
||||
buildClaudeCliFallbackContextPrelude,
|
||||
claudeCliSessionTranscriptHasContent,
|
||||
resolveFallbackRetryPrompt,
|
||||
} from "./attempt-execution.helpers.js";
|
||||
@@ -259,10 +260,24 @@ export function runAgentAttempt(params: {
|
||||
allowTransientCooldownProbe?: boolean;
|
||||
sessionHasHistory?: boolean;
|
||||
}) {
|
||||
// #69973: when a fallback fires from claude-cli to a non-CLI candidate
|
||||
// (or a different CLI backend), the next runner cannot see Claude Code's
|
||||
// local JSONL history. Without a seed, the fallback model starts cold —
|
||||
// even though the original Claude session is still alive on disk.
|
||||
// Harvest a compacted context (Claude's own `/compact` summary plus the
|
||||
// most recent post-boundary turns) and prepend it to the retry prompt.
|
||||
// This mirrors what Claude Code itself replays after compaction.
|
||||
const claudeCliFallbackPrelude =
|
||||
params.isFallbackRetry && !isClaudeCliProvider(params.providerOverride)
|
||||
? buildClaudeCliFallbackContextPrelude({
|
||||
cliSessionId: getCliSessionBinding(params.sessionEntry, "claude-cli")?.sessionId,
|
||||
})
|
||||
: "";
|
||||
const effectivePrompt = resolveFallbackRetryPrompt({
|
||||
body: params.body,
|
||||
isFallbackRetry: params.isFallbackRetry,
|
||||
sessionHasHistory: params.sessionHasHistory,
|
||||
priorContextPrelude: claudeCliFallbackPrelude,
|
||||
});
|
||||
const bootstrapPromptWarningSignaturesSeen = resolveBootstrapWarningSignaturesSeen(
|
||||
params.sessionEntry?.systemPromptReport,
|
||||
|
||||
Reference in New Issue
Block a user