From d15b7a9ebe53cd280a964cbce89409af96f69475 Mon Sep 17 00:00:00 2001 From: yetuge Date: Sat, 12 Sep 2026 18:26:36 +0800 Subject: [PATCH 1/4] fix(context): never report a zero-progress truncation as success Short histories (e.g. an assistant tool_use followed by one oversized user tool_result) round the 50% message calculation down to zero removable messages. manageContext still returned a success-shaped truncation result (fresh truncationId, messagesRemoved: 0, unchanged messages), so the task emitted a sliding_window_truncation event and the next request retried into the same over-budget failure forever. - treat messagesRemoved === 0 as zero progress and degrade in place: shrink the largest eligible textual tool_result blocks, preserving their tool_use_id and block shape so the tool_use/tool_result pair is never orphaned; recount and report success only when the recalculated model-facing token count decreases - when nothing can be removed or shrunk further, return a controlled error/errorDetails result instead of emitting another fake truncation event - share one model-facing recount helper between the truncation and degradation paths so both report against the same accounting Fixes #1254 --- .../fix-context-truncation-zero-progress.md | 9 + .../__tests__/context-management.spec.ts | 95 +++++++ src/core/context-management/index.ts | 249 ++++++++++++++++-- 3 files changed, 325 insertions(+), 28 deletions(-) create mode 100644 .changeset/fix-context-truncation-zero-progress.md diff --git a/.changeset/fix-context-truncation-zero-progress.md b/.changeset/fix-context-truncation-zero-progress.md new file mode 100644 index 0000000000..dd4b1ce321 --- /dev/null +++ b/.changeset/fix-context-truncation-zero-progress.md @@ -0,0 +1,9 @@ +--- +"zoo-code": patch +--- + +Make fallback context truncation recovery monotonic instead of reporting zero-progress successes (#1254). + +For short histories (e.g. an assistant `tool_use` followed by one oversized user `tool_result`), the fraction-based message calculation rounded down to zero removable messages, but `manageContext` still returned a success-shaped truncation result: a fresh `truncationId` with `messagesRemoved: 0` and an unchanged history. The task then emitted a "context truncated" event while the oversized history stayed as-is, and every subsequent request retried into the same over-budget failure indefinitely. + +Zero-progress rounds now degrade in place: the largest eligible textual `tool_result` blocks are shrunk (keeping their `tool_use_id` and block shape, so the `tool_use`/`tool_result` pair is never orphaned), and recovery only reports success when the recalculated model-facing token count actually decreases. When protected content leaves nothing to remove or shrink, `manageContext` returns a controlled `error`/`errorDetails` result instead of emitting another fake truncation event. diff --git a/src/core/context-management/__tests__/context-management.spec.ts b/src/core/context-management/__tests__/context-management.spec.ts index ce7db70fa7..b133ca699b 100644 --- a/src/core/context-management/__tests__/context-management.spec.ts +++ b/src/core/context-management/__tests__/context-management.spec.ts @@ -2044,4 +2044,99 @@ describe("Context Management", () => { summarizeSpy.mockRestore() }) }) + + /** + * Tests for the zero-progress fallback recovery in manageContext (issue #1254): + * short histories where the fraction-based message calculation removes zero + * messages must never report a successful truncation that leaves the oversized + * context unchanged. + */ + describe("manageContext fallback recovery for zero-progress truncation", () => { + const buildToolPairHistory = ( + toolResultContent: Anthropic.Messages.ToolResultBlockParam["content"], + ): ApiMessage[] => [ + { role: "user", content: "Initial task", ts: 1000 }, + { + role: "assistant", + content: [{ type: "tool_use", id: "toolu_01", name: "fetch_report", input: {} }], + ts: 1100, + }, + { + role: "user", + content: [{ type: "tool_result", tool_use_id: "toolu_01", content: toolResultContent }], + ts: 1200, + }, + ] + + it("recovers by shrinking an oversized tool_result when message-level truncation removes zero messages", async () => { + // ~4 MB tool result (realistic mixed content, like a large MCP/CLI output), + // far above the window budget once counted. + const oversizedText = + 'JSON_LOG_LINE {"level":"info","msg":"processed 128 records","path":"/data/exports"}\n'.repeat( + 4_000_000 / 74, + ) + const messages = buildToolPairHistory(oversizedText) + + const result = await manageContext({ + messages, + totalTokens: 0, + contextWindow: 100000, + maxTokens: 30000, + apiHandler: mockApiHandler, + autoCondenseContext: false, + autoCondenseContextPercent: 100, + systemPrompt: "System prompt", + taskId, + profileThresholds: {}, + currentProfileId: "default", + }) + + expect(result.error).toBeUndefined() + expect(result.messagesRemoved).toBe(0) + expect(result.messages).not.toBe(messages) // the degraded copy must be persisted by the caller + expect(result.newContextTokensAfterTruncation).toBeDefined() + expect(result.newContextTokensAfterTruncation!).toBeLessThan(result.prevContextTokens) + + // The oversized tool_use/tool_result pair stays intact: same message count, + // same tool_use_id, block shape preserved, only the payload is smaller. + expect(result.messages).toHaveLength(3) + const toolResult = ( + result.messages[2].content as Anthropic.Messages.ContentBlockParam[] + )[0] as Anthropic.ToolResultBlockParam + expect(toolResult.tool_use_id).toBe("toolu_01") + const shrunkText = + typeof toolResult.content === "string" + ? toolResult.content + : (toolResult.content as Anthropic.TextBlockParam[]).map((item) => item.text).join("") + expect(shrunkText.length).toBeLessThan(oversizedText.length) + expect(shrunkText).toContain("[Tool result truncated") + }, 60000) + + it("returns a controlled error when nothing can be removed and no tool result can shrink", async () => { + const messages: ApiMessage[] = [ + { role: "user", content: "First message", ts: 1000 }, + { role: "assistant", content: "Second message", ts: 1100 }, + { role: "user", content: "Third message", ts: 1200 }, + ] + + const result = await manageContext({ + messages, + totalTokens: 90000, + contextWindow: 100000, + maxTokens: 30000, + apiHandler: mockApiHandler, + autoCondenseContext: false, + autoCondenseContextPercent: 100, + systemPrompt: "System prompt", + taskId, + profileThresholds: {}, + currentProfileId: "default", + }) + + expect(result.error).toBeDefined() + expect(result.errorDetails).toBeDefined() + expect(result.truncationId).toBeUndefined() + expect(result.messages).toBe(messages) // unchanged history, no fake truncation event + }) + }) }) diff --git a/src/core/context-management/index.ts b/src/core/context-management/index.ts index 82cb4fdf2e..a4e97f28e0 100644 --- a/src/core/context-management/index.ts +++ b/src/core/context-management/index.ts @@ -160,6 +160,160 @@ export function truncateConversation(messages: ApiMessage[], fracToRemove: numbe } } +/** + * Minimum characters retained when degrading a tool_result: below this the model can no + * longer tell what the tool was operating on, so the block stops being eligible. + */ +const TOOL_RESULT_SHRINK_FLOOR_CHARS = 200 + +/** + * A textual tool_result block eligible for degradation, located by index so the edit can + * be applied without mutating the input history. `textIndex` is the position of the text + * inside the tool_result's content array, or -1 when the content is a plain string. + */ +type ShrinkingToolResult = { + messageIndex: number + blockIndex: number + textIndex: number + text: string + tokens: number +} + +/** + * Collects the textual tool_result blocks that can still be shrunk, with their token + * estimates. Only blocks above the floor are returned; images and other non-textual + * content are never touched. + */ +async function findShrinkableToolResults( + messages: ApiMessage[], + apiHandler: ApiHandler, +): Promise { + const results: ShrinkingToolResult[] = [] + for (const [messageIndex, message] of messages.entries()) { + if (message.truncationParent || message.isTruncationMarker) continue + if (!Array.isArray(message.content)) continue + for (const [blockIndex, block] of message.content.entries()) { + if (block.type !== "tool_result") continue + const toolResult = block as Anthropic.ToolResultBlockParam + const items = + typeof toolResult.content === "string" + ? [{ textIndex: -1, text: toolResult.content }] + : (toolResult.content ?? []) + .map((item, textIndex) => ({ textIndex, item })) + .filter(({ item }) => item.type === "text") + .map(({ textIndex, item }) => ({ + textIndex, + text: (item as Anthropic.TextBlockParam).text, + })) + for (const { textIndex, text } of items) { + if (text.length <= TOOL_RESULT_SHRINK_FLOOR_CHARS) continue + results.push({ + messageIndex, + blockIndex, + textIndex, + text, + tokens: await estimateTokenCount([{ type: "text", text }], apiHandler), + }) + } + } + } + return results +} + +/** + * Applies tool_result shrink edits functionally: the input history is never mutated, so + * the caller's reference comparison (`messages !== apiConversationHistory`) keeps working. + */ +function applyToolResultEdits( + messages: ApiMessage[], + edits: Array<{ messageIndex: number; blockIndex: number; textIndex: number; newText: string }>, +): ApiMessage[] { + const result = [...messages] + const byMessage = new Map() + for (const edit of edits) { + const group = byMessage.get(edit.messageIndex) ?? [] + group.push(edit) + byMessage.set(edit.messageIndex, group) + } + for (const [messageIndex, messageEdits] of byMessage) { + const message = result[messageIndex] + if (!Array.isArray(message.content)) continue + const content = [...message.content] + const byBlock = new Map() + for (const edit of messageEdits) { + const group = byBlock.get(edit.blockIndex) ?? [] + group.push(edit) + byBlock.set(edit.blockIndex, group) + } + for (const [blockIndex, blockEdits] of byBlock) { + const block = content[blockIndex] as Anthropic.ToolResultBlockParam + if (typeof block.content === "string") { + content[blockIndex] = { ...block, content: blockEdits[0]!.newText } + } else { + const blockContent = [...(block.content ?? [])] + for (const edit of blockEdits) { + blockContent[edit.textIndex] = { + ...(blockContent[edit.textIndex] as Anthropic.TextBlockParam), + text: edit.newText, + } + } + content[blockIndex] = { ...block, content: blockContent } + } + } + result[messageIndex] = { ...message, content } + } + return result +} + +/** + * Frees context budget by shrinking the largest textual tool_result blocks in place: the + * tool_use_id and block shape are preserved, so the tool_use/tool_result pair is never + * orphaned. Returns the updated messages, or null when nothing eligible can shrink. + * + * Used when message-level truncation removes zero messages (short histories such as an + * assistant tool_use followed by one oversized user tool_result): without this, the only + * remaining recovery is reporting a successful truncation that removed nothing. + */ +async function shrinkOversizedToolResults({ + messages, + tokensToFree, + apiHandler, +}: { + messages: ApiMessage[] + tokensToFree: number + apiHandler: ApiHandler +}): Promise { + if (tokensToFree <= 0) return null + const candidates = await findShrinkableToolResults(messages, apiHandler) + if (candidates.length === 0) return null + + // Largest first: the biggest result frees the most tokens while losing the least + // information, and smaller results stay available to a later recovery round. + candidates.sort((a, b) => b.tokens - a.tokens) + + const edits: Array<{ messageIndex: number; blockIndex: number; textIndex: number; newText: string }> = [] + let remaining = tokensToFree + for (const candidate of candidates) { + if (remaining <= 0) break + // Chars-per-token measured on the block itself, so the shrink target lands close + // to the intended token reduction whatever the content's tokenizer density is. + const charsPerToken = candidate.text.length / Math.max(candidate.tokens, 1) + const keepTokens = Math.max(candidate.tokens - remaining, 1) + const keepChars = Math.max(TOOL_RESULT_SHRINK_FLOOR_CHARS, Math.floor(keepTokens * charsPerToken)) + if (keepChars >= candidate.text.length) continue + const removed = candidate.text.length - keepChars + edits.push({ + messageIndex: candidate.messageIndex, + blockIndex: candidate.blockIndex, + textIndex: candidate.textIndex, + newText: `${candidate.text.slice(0, keepChars)}\n[Tool result truncated: ${removed} characters removed to fit the context budget]`, + }) + remaining -= Math.floor(removed / charsPerToken) + } + if (edits.length === 0) return null + return applyToolResultEdits(messages, edits) +} + /** * Options for checking if context management will likely run. * A subset of ContextManagementOptions with only the fields needed for threshold calculation. @@ -383,43 +537,82 @@ export async function manageContext({ // Fall back to sliding window truncation if needed if (prevContextTokens > allowedTokens) { - const truncationResult = truncateConversation(messages, 0.5, taskId) + // Model-facing token count: the system prompt plus every message that is not hidden + // by a truncation marker. Shared by the truncation and degradation paths below so + // both report against the same accounting. + const countModelFacingTokens = async (msgs: ApiMessage[]): Promise => { + let total = await estimateTokenCount([{ type: "text", text: systemPrompt }], apiHandler) + for (const msg of msgs) { + if (msg.truncationParent || msg.isTruncationMarker) continue + const content = msg.content + if (Array.isArray(content)) { + total += await estimateTokenCount(content, apiHandler) + } else if (typeof content === "string") { + total += await estimateTokenCount([{ type: "text", text: content }], apiHandler) + } + } + return total + } - // Calculate new context tokens after truncation by counting non-truncated messages - // Messages with truncationParent are hidden, so we count only those without it - const effectiveMessages = truncationResult.messages.filter( - (msg) => !msg.truncationParent && !msg.isTruncationMarker, - ) + const truncationResult = truncateConversation(messages, 0.5, taskId) + const newContextTokensAfterTruncation = await countModelFacingTokens(truncationResult.messages) + + // Recovery only counts as successful when the recalculated context actually decreased: + // for short histories the fraction-based message calculation can round down to zero + // removable messages, and reporting that as a successful truncation retriggers the same + // over-budget request forever. + if (truncationResult.messagesRemoved > 0 && newContextTokensAfterTruncation < prevContextTokens) { + // Include system prompt tokens so this value matches what we send to the API. + // Note: `prevContextTokens` is computed locally here (totalTokens + lastMessageTokens). + return { + messages: truncationResult.messages, + prevContextTokens, + summary: "", + cost, + error, + errorDetails, + truncationId: truncationResult.truncationId, + messagesRemoved: truncationResult.messagesRemoved, + newContextTokensAfterTruncation, + } + } - // Include system prompt tokens so this value matches what we send to the API. - // Note: `prevContextTokens` is computed locally here (totalTokens + lastMessageTokens). - let newContextTokensAfterTruncation = await estimateTokenCount( - [{ type: "text", text: systemPrompt }], + // Zero message-level progress: the history is too short to remove a valid + // turn/tool pair (e.g. an assistant tool_use followed by one oversized user + // tool_result). Degrade in place instead — shrink the largest textual tool_result + // blocks, keeping their tool_use_id and result shape so the pair stays intact. + const degradedMessages = await shrinkOversizedToolResults({ + messages, + tokensToFree: prevContextTokens - allowedTokens, apiHandler, - ) - - for (const msg of effectiveMessages) { - const content = msg.content - if (Array.isArray(content)) { - newContextTokensAfterTruncation += await estimateTokenCount(content, apiHandler) - } else if (typeof content === "string") { - newContextTokensAfterTruncation += await estimateTokenCount( - [{ type: "text", text: content }], - apiHandler, - ) + }) + + if (degradedMessages) { + const newContextTokensAfterDegradation = await countModelFacingTokens(degradedMessages) + if (newContextTokensAfterDegradation < prevContextTokens) { + return { + messages: degradedMessages, + prevContextTokens, + summary: "", + cost, + error, + errorDetails, + truncationId: truncationResult.truncationId, + messagesRemoved: 0, + newContextTokensAfterTruncation: newContextTokensAfterDegradation, + } } } + // Protected content leaves nothing to remove or shrink: report a controlled failure + // instead of emitting a successful truncation event that removed zero messages. return { - messages: truncationResult.messages, - prevContextTokens, + messages, summary: "", cost, - error, - errorDetails, - truncationId: truncationResult.truncationId, - messagesRemoved: truncationResult.messagesRemoved, - newContextTokensAfterTruncation, + prevContextTokens, + error: `Context window recovery failed: the conversation (${Math.round(prevContextTokens)} tokens) exceeds the available budget (${Math.round(allowedTokens)} tokens) and no messages can be removed or tool results shrunk further. Reduce the size of individual tool outputs or start a new task.`, + errorDetails: `Fallback truncation removed 0 messages and no eligible textual tool_result could be shrunk below its floor.`, } } // No truncation or condensation needed From cdc509c66cdeb8535e65ece313277a89e258f1c1 Mon Sep 17 00:00:00 2001 From: yetuge <2219677952@qq.com> Date: Sun, 13 Sep 2026 09:45:57 +0800 Subject: [PATCH 2/4] fix(context): address review findings on fallback truncation recovery Follow-up to the zero-progress recovery added for #1254: - findShrinkableToolResults now only considers messages the API actually receives (getEffectiveApiHistory), so a tool_result hidden by a condensation summary can no longer be degraded: shrinking it lowered the token estimate while the request stayed unchanged, which is the same false progress this recovery exists to prevent - reserve the truncation notice's own characters before slicing and skip any candidate whose rewritten body would not be strictly shorter, so a tool_result already at the 200-character floor is left untouched instead of growing while the notice claims characters were removed - token bookkeeping uses the actual text.length - newText.length - tests: array-form tool_result coverage, exact controlled-error contract, condensed-history and shrink-floor regression cases - remove the changeset: AGENTS.md reserves changesets for maintainers --- .../fix-context-truncation-zero-progress.md | 9 - .../__tests__/context-management.spec.ts | 169 +++++++++++++++++- src/core/context-management/index.ts | 44 ++++- 3 files changed, 205 insertions(+), 17 deletions(-) delete mode 100644 .changeset/fix-context-truncation-zero-progress.md diff --git a/.changeset/fix-context-truncation-zero-progress.md b/.changeset/fix-context-truncation-zero-progress.md deleted file mode 100644 index dd4b1ce321..0000000000 --- a/.changeset/fix-context-truncation-zero-progress.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -"zoo-code": patch ---- - -Make fallback context truncation recovery monotonic instead of reporting zero-progress successes (#1254). - -For short histories (e.g. an assistant `tool_use` followed by one oversized user `tool_result`), the fraction-based message calculation rounded down to zero removable messages, but `manageContext` still returned a success-shaped truncation result: a fresh `truncationId` with `messagesRemoved: 0` and an unchanged history. The task then emitted a "context truncated" event while the oversized history stayed as-is, and every subsequent request retried into the same over-budget failure indefinitely. - -Zero-progress rounds now degrade in place: the largest eligible textual `tool_result` blocks are shrunk (keeping their `tool_use_id` and block shape, so the `tool_use`/`tool_result` pair is never orphaned), and recovery only reports success when the recalculated model-facing token count actually decreases. When protected content leaves nothing to remove or shrink, `manageContext` returns a controlled `error`/`errorDetails` result instead of emitting another fake truncation event. diff --git a/src/core/context-management/__tests__/context-management.spec.ts b/src/core/context-management/__tests__/context-management.spec.ts index b133ca699b..685fe7748b 100644 --- a/src/core/context-management/__tests__/context-management.spec.ts +++ b/src/core/context-management/__tests__/context-management.spec.ts @@ -2112,6 +2112,169 @@ describe("Context Management", () => { expect(shrunkText).toContain("[Tool result truncated") }, 60000) + it("shrinks only the oversized text item of an array-form tool_result", async () => { + const oversizedText = + 'JSON_LOG_LINE {"level":"info","msg":"processed 128 records","path":"/data/exports"}\n'.repeat(2000) + const headerItem: Anthropic.Messages.TextBlockParam = { type: "text", text: "Report header: 128 rows" } + const imageItem: Anthropic.Messages.ImageBlockParam = { + type: "image", + source: { type: "base64", media_type: "image/png", data: "aW1hZ2U=" }, + } + const payloadItem: Anthropic.Messages.TextBlockParam = { type: "text", text: oversizedText } + const footerItem: Anthropic.Messages.TextBlockParam = { type: "text", text: "END OF REPORT" } + const toolResultContent: Anthropic.Messages.ToolResultBlockParam["content"] = [ + headerItem, + imageItem, + payloadItem, + footerItem, + ] + const messages = buildToolPairHistory(toolResultContent) + + const result = await manageContext({ + messages, + totalTokens: 90000, + contextWindow: 100000, + maxTokens: 30000, + apiHandler: mockApiHandler, + autoCondenseContext: false, + autoCondenseContextPercent: 100, + systemPrompt: "System prompt", + taskId, + profileThresholds: {}, + currentProfileId: "default", + }) + + expect(result.error).toBeUndefined() + const toolResult = ( + result.messages[2].content as Anthropic.Messages.ContentBlockParam[] + )[0] as Anthropic.ToolResultBlockParam + expect(toolResult.tool_use_id).toBe("toolu_01") + + const items = toolResult.content as Anthropic.Messages.ContentBlockParam[] + // Order, block types and the ineligible items survive the edit untouched: only the + // oversized text item is degraded. + expect(items).toHaveLength(4) + expect(items[0]).toEqual(headerItem) + expect(items[1]).toEqual(imageItem) + expect(items[3]).toEqual(footerItem) + const degradedItem = items[2] as Anthropic.Messages.TextBlockParam + expect(degradedItem.type).toBe("text") + expect(degradedItem.text).toContain("[Tool result truncated") + expect(degradedItem.text.length).toBeLessThan(oversizedText.length) + + // The caller's history is never mutated. + const originalBlock = ( + messages[2].content as Anthropic.Messages.ContentBlockParam[] + )[0] as Anthropic.ToolResultBlockParam + expect(originalBlock.content).toEqual(toolResultContent) + }, 60000) + + it("does not treat a tool_result hidden by a condensation summary as a shrink candidate", async () => { + const oversizedText = + 'JSON_LOG_LINE {"level":"info","msg":"processed 128 records","path":"/data/exports"}\n'.repeat(2000) + const condenseId = "condense-1" + // Fresh-start condense: every earlier message is tagged with condenseParent and the + // summary is the only message `getEffectiveApiHistory` keeps. + const messages: ApiMessage[] = [ + { role: "user", content: "Initial task", ts: 1000, condenseParent: condenseId }, + { + role: "assistant", + content: [{ type: "tool_use", id: "toolu_01", name: "fetch_report", input: {} }], + ts: 1100, + condenseParent: condenseId, + }, + { + role: "user", + content: [{ type: "tool_result", tool_use_id: "toolu_01", content: oversizedText }], + ts: 1200, + condenseParent: condenseId, + }, + { + role: "user", + content: "## Conversation Summary\nEarlier work was condensed.", + ts: 1300, + isSummary: true, + condenseId, + }, + ] + + const result = await manageContext({ + messages, + totalTokens: 90000, + contextWindow: 100000, + maxTokens: 30000, + apiHandler: mockApiHandler, + autoCondenseContext: false, + autoCondenseContextPercent: 100, + systemPrompt: "System prompt", + taskId, + profileThresholds: {}, + currentProfileId: "default", + }) + + // Degrading the condensed-away result would lower the token estimate without changing + // the request the API receives, so it must not be reported as recovery. + expect(result.error).toContain("Context window recovery failed") + expect(result.truncationId).toBeUndefined() + expect(result.messages).toBe(messages) + }, 60000) + + it("leaves a tool_result that is already at the shrink floor untouched", async () => { + // One result large enough to absorb the whole budget deficit, one sitting just above + // the 200-character floor (long enough to be a candidate, too short to absorb the + // notice the degradation appends). + const oversizedText = + 'JSON_LOG_LINE {"level":"info","msg":"processed 128 records","path":"/data/exports"}\n'.repeat(300) + const floorText = "small tool output\n".repeat(15) + expect(floorText.length).toBeGreaterThan(200) + + const messages: ApiMessage[] = [ + { role: "user", content: "Initial task", ts: 1000 }, + { + role: "assistant", + content: [ + { type: "tool_use", id: "toolu_big", name: "fetch_report", input: {} }, + { type: "tool_use", id: "toolu_small", name: "fetch_summary", input: {} }, + ], + ts: 1100, + }, + { + role: "user", + content: [ + { type: "tool_result", tool_use_id: "toolu_big", content: oversizedText }, + { type: "tool_result", tool_use_id: "toolu_small", content: floorText }, + ], + ts: 1200, + }, + ] + + const result = await manageContext({ + messages, + totalTokens: 90000, + contextWindow: 100000, + maxTokens: 30000, + apiHandler: mockApiHandler, + autoCondenseContext: false, + autoCondenseContextPercent: 100, + systemPrompt: "System prompt", + taskId, + profileThresholds: {}, + currentProfileId: "default", + }) + + expect(result.error).toBeUndefined() // the oversized result alone covers the deficit + const blocks = result.messages[2].content as Anthropic.Messages.ContentBlockParam[] + const degraded = blocks[0] as Anthropic.ToolResultBlockParam + expect(degraded.tool_use_id).toBe("toolu_big") + expect(degraded.content as string).toContain("[Tool result truncated") + + // Rewriting the short block would append a notice longer than the characters it frees, + // growing the block while reporting a removal that never happened. + const untouched = blocks[1] as Anthropic.ToolResultBlockParam + expect(untouched.tool_use_id).toBe("toolu_small") + expect(untouched.content).toBe(floorText) + }, 60000) + it("returns a controlled error when nothing can be removed and no tool result can shrink", async () => { const messages: ApiMessage[] = [ { role: "user", content: "First message", ts: 1000 }, @@ -2133,8 +2296,10 @@ describe("Context Management", () => { currentProfileId: "default", }) - expect(result.error).toBeDefined() - expect(result.errorDetails).toBeDefined() + // The controlled error must name the actual failure, not just be defined: a stale or + // unrelated error would satisfy a `toBeDefined()` assertion. + expect(result.error).toContain("Context window recovery failed") + expect(result.errorDetails).toContain("removed 0 messages and no eligible textual tool_result") expect(result.truncationId).toBeUndefined() expect(result.messages).toBe(messages) // unchanged history, no fake truncation event }) diff --git a/src/core/context-management/index.ts b/src/core/context-management/index.ts index a4e97f28e0..c1a86b0b2c 100644 --- a/src/core/context-management/index.ts +++ b/src/core/context-management/index.ts @@ -4,7 +4,13 @@ import crypto from "crypto" import { TelemetryService } from "@roo-code/telemetry" import { ApiHandler, ApiHandlerCreateMessageMetadata } from "../../api" -import { MAX_CONDENSE_THRESHOLD, MIN_CONDENSE_THRESHOLD, summarizeConversation, SummarizeResponse } from "../condense" +import { + getEffectiveApiHistory, + MAX_CONDENSE_THRESHOLD, + MIN_CONDENSE_THRESHOLD, + summarizeConversation, + SummarizeResponse, +} from "../condense" import { ApiMessage } from "../task-persistence/apiMessages" import { ANTHROPIC_DEFAULT_MAX_TOKENS } from "@roo-code/types" import { RooIgnoreController } from "../ignore/RooIgnoreController" @@ -166,6 +172,13 @@ export function truncateConversation(messages: ApiMessage[], fracToRemove: numbe */ const TOOL_RESULT_SHRINK_FLOOR_CHARS = 200 +/** + * Notice appended to a degraded tool_result. Built through one helper so the characters it + * costs can be reserved before deciding how much of the payload to keep. + */ +const buildTruncationNotice = (removedChars: number): string => + `\n[Tool result truncated: ${removedChars} characters removed to fit the context budget]` + /** * A textual tool_result block eligible for degradation, located by index so the edit can * be applied without mutating the input history. `textIndex` is the position of the text @@ -183,13 +196,22 @@ type ShrinkingToolResult = { * Collects the textual tool_result blocks that can still be shrunk, with their token * estimates. Only blocks above the floor are returned; images and other non-textual * content are never touched. + * + * Candidates are restricted to the messages the API actually receives: `Task` passes the + * full persisted history to `manageContext`, but condense/truncation hide messages from + * `getEffectiveApiHistory`. Shrinking a hidden message would lower the token estimate + * without changing the request, which is the same false progress this recovery exists to + * prevent. Returned indexes stay indexes into the persisted history so the edits below + * target the right messages. */ async function findShrinkableToolResults( messages: ApiMessage[], apiHandler: ApiHandler, ): Promise { const results: ShrinkingToolResult[] = [] + const apiVisibleMessages = new Set(getEffectiveApiHistory(messages)) for (const [messageIndex, message] of messages.entries()) { + if (!apiVisibleMessages.has(message)) continue if (message.truncationParent || message.isTruncationMarker) continue if (!Array.isArray(message.content)) continue for (const [blockIndex, block] of message.content.entries()) { @@ -299,16 +321,26 @@ async function shrinkOversizedToolResults({ // to the intended token reduction whatever the content's tokenizer density is. const charsPerToken = candidate.text.length / Math.max(candidate.tokens, 1) const keepTokens = Math.max(candidate.tokens - remaining, 1) - const keepChars = Math.max(TOOL_RESULT_SHRINK_FLOOR_CHARS, Math.floor(keepTokens * charsPerToken)) - if (keepChars >= candidate.text.length) continue - const removed = candidate.text.length - keepChars + const targetChars = Math.max(TOOL_RESULT_SHRINK_FLOOR_CHARS, Math.floor(keepTokens * charsPerToken)) + if (targetChars >= candidate.text.length) continue + // The notice costs characters of its own, so reserve room for it: for a small + // tokensToFree the slice target sits within a notice's length of the original, and the + // appended notice would then grow the block while still reporting a removal. + const noticeReserve = buildTruncationNotice(candidate.text.length).length + const keepChars = Math.max(TOOL_RESULT_SHRINK_FLOOR_CHARS, targetChars - noticeReserve) + const newText = `${candidate.text.slice(0, keepChars)}${buildTruncationNotice( + candidate.text.length - keepChars, + )}` + // A block that is already at the floor cannot absorb the notice. Leave it untouched + // instead of rewriting it into a longer body and reporting success on a no-op edit. + if (newText.length >= candidate.text.length) continue edits.push({ messageIndex: candidate.messageIndex, blockIndex: candidate.blockIndex, textIndex: candidate.textIndex, - newText: `${candidate.text.slice(0, keepChars)}\n[Tool result truncated: ${removed} characters removed to fit the context budget]`, + newText, }) - remaining -= Math.floor(removed / charsPerToken) + remaining -= Math.floor((candidate.text.length - newText.length) / charsPerToken) } if (edits.length === 0) return null return applyToolResultEdits(messages, edits) From ffe1778a0a803d233010f48cc352e8d931233616 Mon Sep 17 00:00:00 2001 From: yetuge <2219677952@qq.com> Date: Sun, 13 Sep 2026 12:45:34 +0800 Subject: [PATCH 3/4] fix(context): match shrink candidates by block identity to survive condense clones MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the CodeRabbit review of #1617 (inline finding at findShrinkableToolResults): getEffectiveApiHistory returns a clone of a kept user message when it filters an orphan tool_result out of it, so that message never matched a persisted message by reference and the whole message was skipped — including its other, API-visible oversized tool_result. Recovery then reported a controlled error while the over-budget request still carried the unshrunk result. - collect API-visible blocks by reference instead of messages: the orphan filter keeps the surviving block objects, so block identity maps API-visible content back to its persisted location exactly — the filtered orphan block stays invisible (no token-estimate-only progress) and the surviving blocks in the same message stay shrinkable - regression test: one kept message with an orphan and an oversized valid tool_result; the oversized result must degrade, the orphan block must survive byte-identical --- .../__tests__/context-management.spec.ts | 62 +++++++++++++++++++ src/core/context-management/index.ts | 16 +++-- 2 files changed, 74 insertions(+), 4 deletions(-) diff --git a/src/core/context-management/__tests__/context-management.spec.ts b/src/core/context-management/__tests__/context-management.spec.ts index 685fe7748b..e16cb8bda6 100644 --- a/src/core/context-management/__tests__/context-management.spec.ts +++ b/src/core/context-management/__tests__/context-management.spec.ts @@ -2219,6 +2219,68 @@ describe("Context Management", () => { expect(result.messages).toBe(messages) }, 60000) + it("still shrinks the API-visible tool_result of a message that also carries an orphan tool_result", async () => { + // After a condense, `getEffectiveApiHistory` filters the orphan tool_result out of the + // kept user message and returns a CLONE of it. The recovery must map the surviving + // (API-visible) blocks back to the persisted history so the oversized result stays + // shrinkable — while the orphan block itself must remain untouched. + const oversizedText = + 'JSON_LOG_LINE {"level":"info","msg":"processed 128 records","path":"/data/exports"}\n'.repeat(2000) + const orphanContent = "stale output whose tool_use was condensed away" + const messages: ApiMessage[] = [ + { + role: "user", + content: "## Conversation Summary\nEarlier work was condensed.", + ts: 1200, + isSummary: true, + condenseId: "condense-1", + }, + { + role: "assistant", + content: [{ type: "tool_use", id: "toolu_big", name: "fetch_report", input: {} }], + ts: 1300, + }, + { + role: "user", + content: [ + { type: "tool_result", tool_use_id: "toolu_old", content: orphanContent }, + { type: "tool_result", tool_use_id: "toolu_big", content: oversizedText }, + ], + ts: 1400, + }, + ] + + const result = await manageContext({ + messages, + totalTokens: 90000, + contextWindow: 100000, + maxTokens: 30000, + apiHandler: mockApiHandler, + autoCondenseContext: false, + autoCondenseContextPercent: 100, + systemPrompt: "System prompt", + taskId, + profileThresholds: {}, + currentProfileId: "default", + }) + + expect(result.error).toBeUndefined() // the oversized result is API-visible and shrinkable + expect(result.messagesRemoved).toBe(0) + expect(result.messages).not.toBe(messages) // the degraded copy must be persisted by the caller + + const blocks = result.messages[2].content as Anthropic.Messages.ContentBlockParam[] + const degraded = blocks[1] as Anthropic.ToolResultBlockParam + expect(degraded.tool_use_id).toBe("toolu_big") + expect(degraded.content as string).toContain("[Tool result truncated") + expect((degraded.content as string).length).toBeLessThan(oversizedText.length) + + // The orphan block is not API-visible: degrading it would lower the token estimate + // without changing the request, so it must survive the recovery byte-identical. + const orphan = blocks[0] as Anthropic.ToolResultBlockParam + expect(orphan.tool_use_id).toBe("toolu_old") + expect(orphan.content).toBe(orphanContent) + }, 60000) + it("leaves a tool_result that is already at the shrink floor untouched", async () => { // One result large enough to absorb the whole budget deficit, one sitting just above // the 200-character floor (long enough to be a candidate, too short to absorb the diff --git a/src/core/context-management/index.ts b/src/core/context-management/index.ts index c1a86b0b2c..415f97fe90 100644 --- a/src/core/context-management/index.ts +++ b/src/core/context-management/index.ts @@ -197,9 +197,9 @@ type ShrinkingToolResult = { * estimates. Only blocks above the floor are returned; images and other non-textual * content are never touched. * - * Candidates are restricted to the messages the API actually receives: `Task` passes the + * Candidates are restricted to the content the API actually receives: `Task` passes the * full persisted history to `manageContext`, but condense/truncation hide messages from - * `getEffectiveApiHistory`. Shrinking a hidden message would lower the token estimate + * `getEffectiveApiHistory`. Shrinking hidden content would lower the token estimate * without changing the request, which is the same false progress this recovery exists to * prevent. Returned indexes stay indexes into the persisted history so the edits below * target the right messages. @@ -209,12 +209,20 @@ async function findShrinkableToolResults( apiHandler: ApiHandler, ): Promise { const results: ShrinkingToolResult[] = [] - const apiVisibleMessages = new Set(getEffectiveApiHistory(messages)) + // Block-level identity, not message-level: `getEffectiveApiHistory` returns a clone of a + // user message when it filters an orphan tool_result out of it, so that message can never + // match a persisted message by reference. The clone's content array still holds the SAME + // block objects as the persisted history, so matching blocks by reference maps API-visible + // content back to its persisted location — the filtered orphan block stays invisible, and + // the surviving blocks in the same message remain shrinkable. + const apiVisibleBlocks = new Set( + getEffectiveApiHistory(messages).flatMap((message) => (Array.isArray(message.content) ? message.content : [])), + ) for (const [messageIndex, message] of messages.entries()) { - if (!apiVisibleMessages.has(message)) continue if (message.truncationParent || message.isTruncationMarker) continue if (!Array.isArray(message.content)) continue for (const [blockIndex, block] of message.content.entries()) { + if (!apiVisibleBlocks.has(block)) continue if (block.type !== "tool_result") continue const toolResult = block as Anthropic.ToolResultBlockParam const items = From 63bc8734f3a71add64bc767bbe29c9207300f157 Mon Sep 17 00:00:00 2001 From: yetuge <2219677952@qq.com> Date: Sun, 13 Sep 2026 15:32:50 +0800 Subject: [PATCH 4/4] fix(context): derive recovery accounting and shrink candidates from the effective API history countModelFacingTokens walked the persisted history and skipped messages by truncationParent/isTruncationMarker metadata, so with a condense summary the recount still charged hidden pre-summary content, and orphan tool_result blocks filtered out of the effective history were counted against the recovery result. A degradation that only moved API-visible tokens could be rejected as zero progress and reported as a controlled failure. Both recounts now iterate getEffectiveApiHistory(msgs): the fresh-start summary slice, truncation-tagged messages (only while their marker exists), orphan-block filtering and the markers themselves are all reflected, matching what the next request actually carries. findShrinkableToolResults no longer vetoes candidates by message metadata: an oversized tool_result in a message whose truncationParent was rewound away (marker deleted) is API-visible again and must stay shrinkable. apiVisibleBlocks alone decides visibility. Regressions: orphaned-truncationParent candidate selection, and a persisted orphan tool_result block masking a successful degradation. Both fail on the previous code and pass now. --- .../__tests__/context-management.spec.ts | 114 ++++++++++++++++++ src/core/context-management/index.ts | 18 +-- 2 files changed, 125 insertions(+), 7 deletions(-) diff --git a/src/core/context-management/__tests__/context-management.spec.ts b/src/core/context-management/__tests__/context-management.spec.ts index e16cb8bda6..5dcccc34ef 100644 --- a/src/core/context-management/__tests__/context-management.spec.ts +++ b/src/core/context-management/__tests__/context-management.spec.ts @@ -2281,6 +2281,120 @@ describe("Context Management", () => { expect(orphan.content).toBe(orphanContent) }, 60000) + it("still shrinks an oversized tool_result whose message carries an orphaned truncationParent", async () => { + // Rewinding past a truncation deletes the marker, and `getEffectiveApiHistory` + // includes the tagged messages again (orphaned parents are API-visible). Candidate + // selection must follow that visibility instead of the stale metadata, or an + // oversized API-visible tool_result has no shrink candidate at all. + const oversizedText = + 'JSON_LOG_LINE {"level":"info","msg":"processed 128 records","path":"/data/exports"}\n'.repeat(2000) + const orphanedParent = "rewound-truncation-id" + const messages: ApiMessage[] = [ + { role: "user", content: "Initial task", ts: 1000, truncationParent: orphanedParent }, + { + role: "assistant", + content: [{ type: "tool_use", id: "toolu_01", name: "fetch_report", input: {} }], + ts: 1100, + truncationParent: orphanedParent, + }, + { + role: "user", + content: [{ type: "tool_result", tool_use_id: "toolu_01", content: oversizedText }], + ts: 1200, + truncationParent: orphanedParent, + }, + ] + + const result = await manageContext({ + messages, + totalTokens: 90000, + contextWindow: 100000, + maxTokens: 30000, + apiHandler: mockApiHandler, + autoCondenseContext: false, + autoCondenseContextPercent: 100, + systemPrompt: "System prompt", + taskId, + profileThresholds: {}, + currentProfileId: "default", + }) + + expect(result.error).toBeUndefined() // the oversized result is API-visible again and shrinkable + expect(result.messagesRemoved).toBe(0) + expect(result.messages).not.toBe(messages) // the degraded copy must be persisted by the caller + + const toolResult = ( + result.messages[2].content as Anthropic.Messages.ContentBlockParam[] + )[0] as Anthropic.ToolResultBlockParam + expect(toolResult.tool_use_id).toBe("toolu_01") + expect(toolResult.content as string).toContain("[Tool result truncated") + expect((toolResult.content as string).length).toBeLessThan(oversizedText.length) + }, 60000) + + it("does not let a persisted orphan tool_result block mask a successful degradation", async () => { + // `getEffectiveApiHistory` filters the orphan block out of the kept user message, but + // the persisted history still carries it. Recovery accounting over persisted messages + // keeps charging the orphan against the result, so a degradation that only moves + // API-visible content gets rejected as zero progress and reports a controlled error. + const oversizedText = + 'JSON_LOG_LINE {"level":"info","msg":"processed 128 records","path":"/data/exports"}\n'.repeat(2000) + const orphanText = 'STALE_LOG_LINE {"level":"warn","msg":"orphaned output","path":"/data/old"}\n'.repeat( + 3000, + ) + const messages: ApiMessage[] = [ + { + role: "user", + content: "## Conversation Summary\nEarlier work was condensed.", + ts: 1200, + isSummary: true, + condenseId: "condense-1", + }, + { + role: "assistant", + content: [{ type: "tool_use", id: "toolu_big", name: "fetch_report", input: {} }], + ts: 1300, + }, + { + role: "user", + content: [ + { type: "tool_result", tool_use_id: "toolu_old", content: orphanText }, + { type: "tool_result", tool_use_id: "toolu_big", content: oversizedText }, + ], + ts: 1400, + }, + { role: "user", content: "Continue the task.", ts: 1500 }, + ] + + const result = await manageContext({ + messages, + totalTokens: 65000, + contextWindow: 100000, + maxTokens: 30000, + apiHandler: mockApiHandler, + autoCondenseContext: false, + autoCondenseContextPercent: 100, + systemPrompt: "System prompt", + taskId, + profileThresholds: {}, + currentProfileId: "default", + }) + + expect(result.error).toBeUndefined() // degrading API-visible content is real progress + expect(result.messagesRemoved).toBe(0) + expect(result.messages).not.toBe(messages) + + const blocks = result.messages[2].content as Anthropic.Messages.ContentBlockParam[] + const degraded = blocks[1] as Anthropic.ToolResultBlockParam + expect(degraded.tool_use_id).toBe("toolu_big") + expect(degraded.content as string).toContain("[Tool result truncated") + expect((degraded.content as string).length).toBeLessThan(oversizedText.length) + + // The orphan block is not API-visible: it must survive the recovery byte-identical. + const orphan = blocks[0] as Anthropic.ToolResultBlockParam + expect(orphan.tool_use_id).toBe("toolu_old") + expect(orphan.content).toBe(orphanText) + }, 60000) + it("leaves a tool_result that is already at the shrink floor untouched", async () => { // One result large enough to absorb the whole budget deficit, one sitting just above // the 200-character floor (long enough to be a candidate, too short to absorb the diff --git a/src/core/context-management/index.ts b/src/core/context-management/index.ts index 415f97fe90..c14c9a8dbe 100644 --- a/src/core/context-management/index.ts +++ b/src/core/context-management/index.ts @@ -202,7 +202,9 @@ type ShrinkingToolResult = { * `getEffectiveApiHistory`. Shrinking hidden content would lower the token estimate * without changing the request, which is the same false progress this recovery exists to * prevent. Returned indexes stay indexes into the persisted history so the edits below - * target the right messages. + * target the right messages. Visibility is decided per block by `apiVisibleBlocks` alone — + * message-level metadata (e.g. a `truncationParent` whose marker was rewound away) must + * not veto blocks that are API-visible again. */ async function findShrinkableToolResults( messages: ApiMessage[], @@ -219,7 +221,6 @@ async function findShrinkableToolResults( getEffectiveApiHistory(messages).flatMap((message) => (Array.isArray(message.content) ? message.content : [])), ) for (const [messageIndex, message] of messages.entries()) { - if (message.truncationParent || message.isTruncationMarker) continue if (!Array.isArray(message.content)) continue for (const [blockIndex, block] of message.content.entries()) { if (!apiVisibleBlocks.has(block)) continue @@ -577,13 +578,16 @@ export async function manageContext({ // Fall back to sliding window truncation if needed if (prevContextTokens > allowedTokens) { - // Model-facing token count: the system prompt plus every message that is not hidden - // by a truncation marker. Shared by the truncation and degradation paths below so - // both report against the same accounting. + // Model-facing token count: the system prompt plus everything `getEffectiveApiHistory` + // keeps — the summary's fresh-start slice (pre-summary content and condenseParent-tagged + // messages are hidden), truncation-tagged messages (only while their marker exists), + // orphan tool_result blocks, and truncation markers themselves. Counting the persisted + // history instead would compare the recovery result against tokens the API never sees. + // Shared by the truncation and degradation paths below so both report against the same + // accounting. const countModelFacingTokens = async (msgs: ApiMessage[]): Promise => { let total = await estimateTokenCount([{ type: "text", text: systemPrompt }], apiHandler) - for (const msg of msgs) { - if (msg.truncationParent || msg.isTruncationMarker) continue + for (const msg of getEffectiveApiHistory(msgs)) { const content = msg.content if (Array.isArray(content)) { total += await estimateTokenCount(content, apiHandler)