Skip to content

Commit 52324c6

Browse files
committed
fix(condense): pair stranded tool_use blocks on the send path
1 parent 064a0d8 commit 52324c6

2 files changed

Lines changed: 69 additions & 7 deletions

File tree

src/core/condense/index.ts

Lines changed: 50 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,18 @@ import { maybeRemoveImageBlocks } from "../../api/transform/image-cleaning"
1010
import { findLast } from "../../shared/array"
1111
import { supportPrompt } from "../../shared/support-prompt"
1212
import { RooIgnoreController } from "../ignore/RooIgnoreController"
13+
import { MissingToolResultError } from "../task/validateToolResultIds"
1314
import { generateFoldedFileContext } from "./foldedFileContext"
1415

16+
export const SYNTHETIC_TOOL_RESULT_REASONS = {
17+
condense: "Context condensation triggered. Tool execution deferred.",
18+
historyShaping:
19+
"Tool result was filtered from history before this request (truncation/condensation). Continuing without it.",
20+
} as const
21+
22+
export type SyntheticToolResultReason =
23+
(typeof SYNTHETIC_TOOL_RESULT_REASONS)[keyof typeof SYNTHETIC_TOOL_RESULT_REASONS]
24+
1525
export type { FoldedFileContextResult, FoldedFileContextOptions } from "./foldedFileContext"
1626

1727
/**
@@ -124,14 +134,28 @@ The goal is for work to continue seamlessly after condensation - as if it never
124134

125135
/**
126136
* Injects synthetic tool_results for orphan tool_calls that don't have matching results.
127-
* This is necessary because OpenAI's Responses API rejects conversations with orphan tool_calls.
128-
* This can happen when the user triggers condense after receiving a tool_call (like attempt_completion)
129-
* but before responding to it.
137+
* This is necessary because OpenAI's Responses API rejects conversations with orphan tool_calls,
138+
* and Anthropic's Messages API rejects requests with unpaired tool_use blocks.
139+
*
140+
* This can happen when:
141+
* - The user triggers condense after receiving a tool_call but before responding to it
142+
* (original use case; pass reason "condense").
143+
* - History shaping (truncation/condensation filters in `getEffectiveApiHistory`) drops the
144+
* user message that carried a tool_result while leaving the assistant tool_use behind
145+
* (issue #190; pass reason "historyShaping").
146+
*
147+
* Emits MissingToolResultError telemetry on each fired injection so we can confirm in
148+
* production whether this guard is doing work and which source dominates.
130149
*
131150
* @param messages - The conversation messages to process
151+
* @param reason - The synthetic tool_result body used to pair orphans. Defaults to the
152+
* condense reason to preserve historical behavior.
132153
* @returns The messages with synthetic tool_results appended if needed
133154
*/
134-
export function injectSyntheticToolResults(messages: ApiMessage[]): ApiMessage[] {
155+
export function injectSyntheticToolResults(
156+
messages: ApiMessage[],
157+
reason: SyntheticToolResultReason = SYNTHETIC_TOOL_RESULT_REASONS.condense,
158+
): ApiMessage[] {
135159
// Find all tool_call IDs in assistant messages
136160
const toolCallIds = new Set<string>()
137161
// Find all tool_result IDs in user messages
@@ -161,11 +185,32 @@ export function injectSyntheticToolResults(messages: ApiMessage[]): ApiMessage[]
161185
return messages
162186
}
163187

188+
// Mirror the validateToolResultIds.ts telemetry shape so PostHog dashboards keyed off
189+
// MissingToolResultError already aggregate this. The `reason` tag lets us split sources
190+
// once data is in.
191+
if (TelemetryService.hasInstance()) {
192+
TelemetryService.instance.captureException(
193+
new MissingToolResultError(
194+
`injectSyntheticToolResults paired ${orphanIds.length} orphan tool_use block(s). reason=${reason}`,
195+
orphanIds,
196+
[...toolResultIds],
197+
),
198+
{
199+
reason,
200+
missingToolUseIds: orphanIds,
201+
existingToolResultIds: [...toolResultIds],
202+
toolUseCount: toolCallIds.size,
203+
toolResultCount: toolResultIds.size,
204+
source: "injectSyntheticToolResults",
205+
},
206+
)
207+
}
208+
164209
// Inject synthetic tool_results as a new user message
165210
const syntheticResults: Anthropic.Messages.ToolResultBlockParam[] = orphanIds.map((id) => ({
166211
type: "tool_result" as const,
167212
tool_use_id: id,
168-
content: "Context condensation triggered. Tool execution deferred.",
213+
content: reason,
169214
}))
170215

171216
const syntheticMessage: ApiMessage = {

src/core/task/Task.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,13 @@ import {
126126
checkpointDiff,
127127
} from "../checkpoints"
128128
import { processUserContentMentions } from "../mentions/processUserContentMentions"
129-
import { getMessagesSinceLastSummary, summarizeConversation, getEffectiveApiHistory } from "../condense"
129+
import {
130+
getMessagesSinceLastSummary,
131+
summarizeConversation,
132+
getEffectiveApiHistory,
133+
injectSyntheticToolResults,
134+
SYNTHETIC_TOOL_RESULT_REASONS,
135+
} from "../condense"
130136
import { MessageQueueService } from "../message-queue/MessageQueueService"
131137
import { AutoApprovalHandler, checkAutoApproval } from "../auto-approval"
132138
import { MessageManager } from "../message-manager"
@@ -4077,7 +4083,18 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
40774083
// This allows non-destructive condensing where messages are tagged but not deleted,
40784084
// enabling accurate rewind operations while still sending condensed history to the API.
40794085
const effectiveHistory = getEffectiveApiHistory(this.apiConversationHistory)
4080-
const messagesSinceLastSummary = getMessagesSinceLastSummary(effectiveHistory)
4086+
// Pair any assistant tool_use blocks whose tool_result was filtered away by history
4087+
// shaping (truncation or condensation). Without this, the next /v1/messages request
4088+
// can carry a tool_use without a matching tool_result in the following user message —
4089+
// Anthropic rejects that with "tool_use ids were found without tool_result blocks
4090+
// immediately after" (see issue #190). Applied only on the send path; the validator
4091+
// peeks in Task.ts:945 and apiConversationHistory.ts:110 must keep seeing the raw
4092+
// effective history so their lastEffective.role checks remain meaningful.
4093+
const paddedEffectiveHistory = injectSyntheticToolResults(
4094+
effectiveHistory,
4095+
SYNTHETIC_TOOL_RESULT_REASONS.historyShaping,
4096+
)
4097+
const messagesSinceLastSummary = getMessagesSinceLastSummary(paddedEffectiveHistory)
40814098
// For API only: merge consecutive user messages (excludes summary messages per
40824099
// mergeConsecutiveApiMessages implementation) without mutating stored history.
40834100
const mergedForApi = mergeConsecutiveApiMessages(messagesSinceLastSummary, { roles: ["user"] })

0 commit comments

Comments
 (0)