fix(agents): scope claude-cli fallback seed and pair summary with boundary

Addresses review on #72069:

- Codex P1 ("Gate Claude prelude seeding by source provider"): the
  guard checked the *current* fallback candidate but not the failed
  attempt. A session that still carried a stale
  cliSessionBindings["claude-cli"] from an unrelated past run would
  inject Claude transcript context into a fallback chain that started
  on a different provider (e.g. openai -> openai-codex), leaking
  irrelevant prior conversation. Plumb `originalProvider` (the
  user-requested provider for the chain) through to runAgentAttempt
  and require `isClaudeCliProvider(originalProvider)` before reading
  Claude history.

- Codex P2 ("Prefer latest compact boundary when summary is missing"):
  the resolver always preferred the most recent explicit summary, so
  a later compaction without its own summary entry (rare crash case)
  paired stale summary text with post-latest-boundary turns. Restructure
  readClaudeCliFallbackSeed to queue summaries into pendingSummary and
  flush each boundary's pair atomically. A boundary with no preceding
  summary now correctly falls back to the boundary's own content
  rather than serving an older summary alongside fresh turns.

- Greptile P2 (newest-first break vs sparse coverage): the
  formatFallbackTurns walk intentionally stops on the first oversized
  turn so the prelude stays a contiguous "what was happening just
  before the failure" window. Document the design choice inline so a
  future maintainer doesn't reflexively change it to skip-and-continue.

Tests:
- New gateway cases for the boundary-without-summary edge case and
  for trailing summaries written without a paired boundary.
- existing 33 attempt-execution + 14 cli-session-history tests still
  pass; broader src/agents/command suite stays green (63/63).
This commit is contained in:
stainlu
2026-04-26 22:03:04 +08:00
committed by Ayaan Zaidi
parent 9691399e53
commit 0bfcdcf044
5 changed files with 116 additions and 19 deletions

View File

@@ -189,9 +189,15 @@ function formatFallbackTurns(
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.
// Walk newest -> oldest, prepending lines until one does not fit.
//
// We stop on the FIRST oversized turn instead of skipping it and then
// continuing into older ones. The fallback prelude is a "most recent
// contiguous window" summary — what was happening just before the
// failed attempt — so a non-contiguous slice (newest + something from
// 20 turns ago, gap in the middle) would mislead the fallback model
// about the actual flow. Sparse coverage is worse than fewer turns:
// greptile flagged this as a P2 on #72069; behavior is intentional.
const lines: string[] = [];
let consumed = 0;
for (let i = turns.length - 1; i >= 0; i -= 1) {
@@ -209,9 +215,6 @@ function formatFallbackTurns(
}
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);

View File

@@ -234,6 +234,15 @@ export async function persistCliTurnTranscript(params: {
export function runAgentAttempt(params: {
providerOverride: string;
modelOverride: string;
/**
* The provider the user originally requested for this turn (i.e. the
* primary candidate of the fallback chain). Used to scope claude-cli
* fallback context seeding to chains that actually started on claude-cli;
* a stale `cliSessionBindings["claude-cli"]` from an unrelated past run
* must not contaminate fallbacks that started on another provider
* (Codex review #72069 P1).
*/
originalProvider: string;
cfg: OpenClawConfig;
sessionEntry: SessionEntry | undefined;
sessionId: string;
@@ -267,8 +276,15 @@ export function runAgentAttempt(params: {
// 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.
//
// Gate explicitly on `originalProvider === "claude-cli"`: if the user-
// requested provider for this run was not claude-cli, any claude-cli
// session binding on the entry is stale state from an earlier run and
// must not bleed into this fallback chain.
const claudeCliFallbackPrelude =
params.isFallbackRetry && !isClaudeCliProvider(params.providerOverride)
params.isFallbackRetry &&
isClaudeCliProvider(params.originalProvider) &&
!isClaudeCliProvider(params.providerOverride)
? buildClaudeCliFallbackContextPrelude({
cliSessionId: getCliSessionBinding(params.sessionEntry, "claude-cli")?.sessionId,
})