Skip to content

Commit 29dcdc3

Browse files
stainluobviyus
authored andcommitted
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
1 parent a8b64b7 commit 29dcdc3

6 files changed

Lines changed: 743 additions & 3 deletions

File tree

src/agents/command/attempt-execution.helpers.ts

Lines changed: 174 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ import {
99
startsWithSilentToken,
1010
stripLeadingSilentToken,
1111
} from "../../auto-reply/tokens.js";
12+
import {
13+
type ClaudeCliFallbackSeed,
14+
readClaudeCliFallbackSeed,
15+
} from "../../gateway/cli-session-history.js";
1216

1317
/** Maximum number of JSONL records to inspect before giving up. */
1418
const SESSION_FILE_MAX_RECORDS = 500;
@@ -105,11 +109,21 @@ export function resolveFallbackRetryPrompt(params: {
105109
body: string;
106110
isFallbackRetry: boolean;
107111
sessionHasHistory?: boolean;
112+
/**
113+
* Optional context prelude (e.g., a compacted summary harvested from a
114+
* non-OpenClaw transcript such as Claude Code's local JSONL). Prepended
115+
* before the retry marker so the fallback candidate has prior context
116+
* even when OpenClaw's own session file is empty for the current
117+
* provider — see `buildClaudeCliFallbackContextPrelude` for the
118+
* claude-cli case (#69973).
119+
*/
120+
priorContextPrelude?: string;
108121
}): string {
109122
if (!params.isFallbackRetry) {
110123
return params.body;
111124
}
112-
if (!params.sessionHasHistory) {
125+
const prelude = params.priorContextPrelude?.trim();
126+
if (!params.sessionHasHistory && !prelude) {
113127
return params.body;
114128
}
115129
// Even with persisted session history, fully replacing the body with a
@@ -118,7 +132,165 @@ export function resolveFallbackRetryPrompt(params: {
118132
// instruction from history alone, which is fragile and sometimes
119133
// impossible. Prepend the retry context to the original body instead so
120134
// the fallback model has both the recovery signal AND the task. (#65760)
121-
return `[Retry after the previous model attempt failed or timed out]\n\n${params.body}`;
135+
const retryMarked = `[Retry after the previous model attempt failed or timed out]\n\n${params.body}`;
136+
return prelude ? `${prelude}\n\n${retryMarked}` : retryMarked;
137+
}
138+
139+
const CLAUDE_CLI_FALLBACK_PRELUDE_DEFAULT_CHAR_BUDGET = 8_000;
140+
const CLAUDE_CLI_FALLBACK_PRELUDE_MIN_TURN_CHARS = 64;
141+
142+
type FallbackTurnLikeMessage = Record<string, unknown>;
143+
144+
function extractFallbackTurnText(message: FallbackTurnLikeMessage): string {
145+
const content = message.content;
146+
if (typeof content === "string") {
147+
return content;
148+
}
149+
if (!Array.isArray(content)) {
150+
return "";
151+
}
152+
const parts: string[] = [];
153+
for (const block of content) {
154+
if (typeof block === "string") {
155+
parts.push(block);
156+
continue;
157+
}
158+
if (!block || typeof block !== "object") {
159+
continue;
160+
}
161+
const rec = block as Record<string, unknown>;
162+
if (typeof rec.text === "string") {
163+
parts.push(rec.text);
164+
continue;
165+
}
166+
// Tool calls: render as a compact "(tool: name)" hint so the fallback
167+
// model sees the conversation flow without the full tool argument blob,
168+
// which is rarely useful out of context and chews through char budget.
169+
if (rec.type === "tool_use" && typeof rec.name === "string") {
170+
parts.push(`(tool call: ${rec.name})`);
171+
continue;
172+
}
173+
if (rec.type === "tool_result") {
174+
const inner = typeof rec.content === "string" ? rec.content : undefined;
175+
if (inner) {
176+
parts.push(`(tool result: ${inner})`);
177+
} else {
178+
parts.push("(tool result)");
179+
}
180+
}
181+
}
182+
return parts.join("\n").trim();
183+
}
184+
185+
function formatFallbackTurns(
186+
turns: ReadonlyArray<FallbackTurnLikeMessage>,
187+
remainingBudget: number,
188+
): { text: string; consumed: number } {
189+
if (turns.length === 0 || remainingBudget <= 0) {
190+
return { text: "", consumed: 0 };
191+
}
192+
// Walk newest -> oldest, prepending lines until we exceed the budget.
193+
// Stops at the oldest turn we can include in full so we never deliver a
194+
// truncated mid-turn fragment to the fallback model.
195+
const lines: string[] = [];
196+
let consumed = 0;
197+
for (let i = turns.length - 1; i >= 0; i -= 1) {
198+
const turn = turns[i];
199+
if (!turn || typeof turn !== "object") {
200+
continue;
201+
}
202+
const role = turn.role;
203+
if (role !== "user" && role !== "assistant") {
204+
continue;
205+
}
206+
const text = extractFallbackTurnText(turn);
207+
if (!text) {
208+
continue;
209+
}
210+
const line = `${role}: ${text}`;
211+
if (consumed + line.length + 1 > remainingBudget) {
212+
// Skip this turn rather than chop it; if even the most recent turn
213+
// is too large to include cleanly, stop emitting (the prelude is a
214+
// best-effort sketch, not a transcript).
215+
break;
216+
}
217+
lines.unshift(line);
218+
consumed += line.length + 1;
219+
}
220+
return { text: lines.join("\n"), consumed };
221+
}
222+
223+
/**
224+
* Format a previously-harvested Claude CLI session into a labeled prelude
225+
* suitable for prepending to a fallback candidate's prompt. Behavior matches
226+
* Claude Code's own resume strategy after compaction: prefer the explicit
227+
* summary, then append the most recent turns up to a char budget.
228+
*
229+
* Returns an empty string when neither a summary nor any usable turn fits in
230+
* the budget; callers can treat that as "no context to seed".
231+
*/
232+
export function formatClaudeCliFallbackPrelude(
233+
seed: ClaudeCliFallbackSeed,
234+
options?: { charBudget?: number },
235+
): string {
236+
const charBudget = Math.max(
237+
CLAUDE_CLI_FALLBACK_PRELUDE_MIN_TURN_CHARS,
238+
options?.charBudget ?? CLAUDE_CLI_FALLBACK_PRELUDE_DEFAULT_CHAR_BUDGET,
239+
);
240+
const sections: string[] = ["## Prior session context (from claude-cli)"];
241+
let remaining = charBudget - sections[0]!.length;
242+
if (seed.summaryText) {
243+
const summarySection = `\nSummary of earlier conversation:\n${seed.summaryText}`;
244+
if (summarySection.length <= remaining) {
245+
sections.push(summarySection);
246+
remaining -= summarySection.length;
247+
} else {
248+
// Truncate the summary at a word boundary if it's huge; clearly mark
249+
// the truncation so the fallback model treats the prelude as a hint,
250+
// not exhaustive state.
251+
const slice = seed.summaryText.slice(0, Math.max(0, remaining - 64));
252+
const lastBreak = slice.lastIndexOf(" ");
253+
const trimmed = lastBreak > 0 ? slice.slice(0, lastBreak).trimEnd() : slice.trimEnd();
254+
sections.push(`\nSummary of earlier conversation (truncated):\n${trimmed} …`);
255+
remaining = 0;
256+
}
257+
}
258+
if (remaining > CLAUDE_CLI_FALLBACK_PRELUDE_MIN_TURN_CHARS && seed.recentTurns.length > 0) {
259+
const { text } = formatFallbackTurns(
260+
seed.recentTurns as ReadonlyArray<FallbackTurnLikeMessage>,
261+
remaining - 32,
262+
);
263+
if (text) {
264+
sections.push(`\nRecent turns:\n${text}`);
265+
}
266+
}
267+
// No summary AND no fittable turns => nothing to seed beyond the heading,
268+
// which would just confuse the model. Drop the prelude entirely.
269+
if (sections.length === 1) {
270+
return "";
271+
}
272+
return sections.join("\n");
273+
}
274+
275+
/**
276+
* Read the Claude CLI session pointed to by `cliSessionId` and format a
277+
* fallback prelude. Returns `""` when no session file is found or when the
278+
* harvested seed has no usable content.
279+
*/
280+
export function buildClaudeCliFallbackContextPrelude(params: {
281+
cliSessionId: string | undefined;
282+
homeDir?: string;
283+
charBudget?: number;
284+
}): string {
285+
const sessionId = params.cliSessionId?.trim();
286+
if (!sessionId) {
287+
return "";
288+
}
289+
const seed = readClaudeCliFallbackSeed({ cliSessionId: sessionId, homeDir: params.homeDir });
290+
if (!seed) {
291+
return "";
292+
}
293+
return formatClaudeCliFallbackPrelude(seed, { charBudget: params.charBudget });
122294
}
123295

124296
export function createAcpVisibleTextAccumulator() {

0 commit comments

Comments
 (0)