Skip to content

Commit 91a4164

Browse files
k1ytZoo (VP)
authored andcommitted
fix(error-interception): address CodeRabbit review findings
Apply all 11 CodeRabbit review findings from PR Zoo-Code-Org#1009: MAJOR fixes: - Use module-scoped interceptor singleton instead of per-block creation so per-task WeakMap counters and circuit breakers persist across blocks - Move pendingNativeProtocolGuide from undeclared cline property onto TaskErrorState with get/set/clear; consume in every tool_result path - Reset PARAM_TYPE_MISMATCH state when structural fingerprint changes to prevent stale circuit state from affecting different tools - Enforce requiresToolContext in ErrorClassifier both matching passes; skip tool-bound patterns when signal lacks toolName/toolCallId MINOR fixes: - Gate validateCwdParameter to execute_command tool only - Preserve original error message alongside guided payload in validation - Path-scoped cycle detection in StructuralValidator (delete after children) - Preserve non-text blocks (images) in array result transformation - Match JSON-RPC -32602 as both string and number - Use TextEncoder for UTF-8 byte counting in MessageTransformer - Update non-ASCII test to exercise multibyte truncation with byteLimit
1 parent fcfa201 commit 91a4164

9 files changed

Lines changed: 190 additions & 36 deletions

File tree

src/core/assistant-message/presentAssistantMessage.ts

Lines changed: 51 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,14 @@ import { codebaseSearchTool } from "../tools/CodebaseSearchTool"
4848
import { formatResponse } from "../prompts/responses"
4949
import { sanitizeToolUseId } from "../../utils/tool-id"
5050

51+
/**
52+
* Module-scoped interceptor singleton. A single shared instance keeps the
53+
* per-task WeakMap alive across content blocks within the same Task, so
54+
* occurrence counters and circuit breakers persist between tool blocks.
55+
* Recreating one per block would reset all per-task counters to empty.
56+
*/
57+
const toolErrorInterceptor = createToolErrorInterceptor()
58+
5159
/**
5260
* Processes and presents assistant message content to the user interface.
5361
*
@@ -114,7 +122,7 @@ export async function presentAssistantMessage(cline: Task) {
114122
// These are converted to the same execution path as use_mcp_tool but preserve
115123
// their original name in API history
116124
const mcpBlock = block as McpToolUse
117-
const interceptor = createToolErrorInterceptor()
125+
const interceptor = toolErrorInterceptor
118126

119127
if (cline.didRejectTool) {
120128
// For native protocol, we must send a tool_result for every tool_use to avoid API errors
@@ -124,10 +132,13 @@ export async function presentAssistantMessage(cline: Task) {
124132
: `MCP tool ${mcpBlock.name} was interrupted and not executed due to user rejecting a previous tool.`
125133

126134
if (toolCallId) {
135+
// Consume any pending native protocol guide so it cannot leak
136+
// into later turns when this early tool_result path is taken.
137+
const rejectedMcpGuide = getTaskErrorState(cline).consumePendingNativeProtocolGuide()
127138
cline.pushToolResultToUserContent({
128139
type: "tool_result",
129140
tool_use_id: sanitizeToolUseId(toolCallId),
130-
content: errorMessage,
141+
content: rejectedMcpGuide ? `${errorMessage}\n\n${rejectedMcpGuide}` : errorMessage,
131142
is_error: true,
132143
})
133144
}
@@ -175,6 +186,12 @@ export async function presentAssistantMessage(cline: Task) {
175186
}
176187

177188
if (toolCallId) {
189+
// Merge any pending XML_NATIVE_DUAL_PROTOCOL guide into this
190+
// tool_result and clear it so it cannot leak into later turns.
191+
const mcpPendingGuide = getTaskErrorState(cline).consumePendingNativeProtocolGuide()
192+
if (mcpPendingGuide) {
193+
resultContent = `${resultContent}\n\n${mcpPendingGuide}`
194+
}
178195
cline.pushToolResultToUserContent({
179196
type: "tool_result",
180197
tool_use_id: sanitizeToolUseId(toolCallId),
@@ -346,9 +363,10 @@ export async function presentAssistantMessage(cline: Task) {
346363
"INVALID_TOOL_PROTOCOL",
347364
"INVALID_TOOL_PROTOCOL|XML_NATIVE_DUAL_PROTOCOL|text-block",
348365
)
349-
;(cline as any).pendingNativeProtocolGuide =
366+
taskErrorState.setPendingNativeProtocolGuide(
350367
`[XML_NATIVE_DUAL_PROTOCOL occurrence=${occurrence}] XML tool calls are not supported. ` +
351-
`Use native tool_use only. The XML markup was removed from the visible text; only the native tool call was executed.`
368+
`Use native tool_use only. The XML markup was removed from the visible text; only the native tool call was executed.`,
369+
)
352370
}
353371
}
354372
}
@@ -359,7 +377,7 @@ export async function presentAssistantMessage(cline: Task) {
359377
case "tool_use": {
360378
// Native tool calling is the only supported tool calling mechanism.
361379
// A tool_use block without an id is invalid and cannot be executed.
362-
const interceptor = createToolErrorInterceptor()
380+
const interceptor = toolErrorInterceptor
363381
const toolCallId = (block as any).id as string | undefined
364382
if (!toolCallId) {
365383
const errorMessage =
@@ -464,10 +482,13 @@ export async function presentAssistantMessage(cline: Task) {
464482
? `Skipping tool ${toolDescription()} due to user rejecting a previous tool.`
465483
: `Tool ${toolDescription()} was interrupted and not executed due to user rejecting a previous tool.`
466484

485+
// Consume any pending native protocol guide so it cannot leak
486+
// into later turns when this early tool_result path is taken.
487+
const rejectedGuide = getTaskErrorState(cline).consumePendingNativeProtocolGuide()
467488
cline.pushToolResultToUserContent({
468489
type: "tool_result",
469490
tool_use_id: sanitizeToolUseId(toolCallId),
470-
content: errorMessage,
491+
content: rejectedGuide ? `${errorMessage}\n\n${rejectedGuide}` : errorMessage,
471492
is_error: true,
472493
})
473494

@@ -511,13 +532,15 @@ export async function presentAssistantMessage(cline: Task) {
511532

512533
// Push tool_result directly without setting didAlreadyUseTool so streaming can
513534
// continue gracefully.
535+
const missingArgsGuide = getTaskErrorState(cline).consumePendingNativeProtocolGuide()
536+
const missingArgsBase = guided ?? formatResponse.toolError(errorMessage)
514537
cline.pushToolResultToUserContent({
515538
type: "tool_result",
516539
tool_use_id: sanitizeToolUseId(toolCallId),
517-
content: guided ?? formatResponse.toolError(errorMessage),
540+
content: missingArgsGuide ? `${missingArgsBase}\n\n${missingArgsGuide}` : missingArgsBase,
518541
is_error: true,
519542
})
520-
543+
521544
break
522545
}
523546
}
@@ -550,10 +573,9 @@ export async function presentAssistantMessage(cline: Task) {
550573
// Merge any pending XML_NATIVE_DUAL_PROTOCOL guide into this native
551574
// tool's result. The native result remains primary; the warning is
552575
// appended once and then cleared so it cannot leak into later turns.
553-
const pendingGuide = (cline as any).pendingNativeProtocolGuide as string | undefined
576+
const pendingGuide = getTaskErrorState(cline).consumePendingNativeProtocolGuide()
554577
if (pendingGuide) {
555578
resultContent = `${resultContent}\n\n${pendingGuide}`
556-
;(cline as any).pendingNativeProtocolGuide = undefined
557579
}
558580

559581
// Merge approval feedback into tool result (GitHub #10465)
@@ -679,14 +701,22 @@ export async function presentAssistantMessage(cline: Task) {
679701
if (!block.partial && block.nativeArgs) {
680702
const taskErrorState = getTaskErrorState(cline)
681703
const structuralSignals = [
682-
validateCwdParameter(block.nativeArgs as Record<string, unknown>, String(block.name)),
704+
...(block.name === "execute_command"
705+
? [validateCwdParameter(block.nativeArgs as Record<string, unknown>, String(block.name))]
706+
: []),
683707
validateNestedParams(block.nativeArgs as Record<string, unknown>, String(block.name)),
684708
].filter((s): s is NonNullable<typeof s> => s != null)
685709

686710
if (structuralSignals.length > 0) {
687711
const signal = structuralSignals[0]
688712
const variant = (signal.metadata?.variant as string | undefined) ?? "STRUCTURAL_MISUSE"
689713
const fingerprint = `PARAM_TYPE_MISMATCH|${variant}|${String(block.name)}|${(signal.metadata?.parameter as string | undefined) ?? ""}`
714+
// If the structural failure shape changed (different tool, variant, or
715+
// parameter), reset the circuit so the new shape gets fresh guidance
716+
// instead of inheriting MODEL_STUCK_LOOP from an unrelated pattern.
717+
if (taskErrorState.getFingerprint("PARAM_TYPE_MISMATCH") !== fingerprint) {
718+
taskErrorState.reset("PARAM_TYPE_MISMATCH")
719+
}
690720
taskErrorState.setFingerprint("PARAM_TYPE_MISMATCH", fingerprint)
691721
const occurrence = taskErrorState.incrementOccurrence("PARAM_TYPE_MISMATCH")
692722
const circuitOpen = taskErrorState.isOpen("PARAM_TYPE_MISMATCH")
@@ -716,10 +746,12 @@ export async function presentAssistantMessage(cline: Task) {
716746
metadata: { ...signal.metadata, structuralPreflight: true, occurrence, circuitOpen },
717747
})
718748

749+
const structuralGuide = taskErrorState.consumePendingNativeProtocolGuide()
750+
const structuralBase = guided ?? formatResponse.toolError(errorMessage)
719751
cline.pushToolResultToUserContent({
720752
type: "tool_result",
721753
tool_use_id: sanitizeToolUseId(toolCallId),
722-
content: guided ?? formatResponse.toolError(errorMessage),
754+
content: structuralGuide ? `${structuralBase}\n\n${structuralGuide}` : structuralBase,
723755
is_error: true,
724756
})
725757

@@ -789,13 +821,15 @@ export async function presentAssistantMessage(cline: Task) {
789821
metadata: validationMetadata,
790822
})
791823
// Push tool_result directly without setting didAlreadyUseTool
824+
const validationGuide = getTaskErrorState(cline).consumePendingNativeProtocolGuide()
825+
const validationBase = guided ? `${guided}\n\n${errorMessage}` : errorMessage
792826
cline.pushToolResultToUserContent({
793827
type: "tool_result",
794828
tool_use_id: sanitizeToolUseId(toolCallId),
795-
content: guided ?? errorMessage,
829+
content: validationGuide ? `${validationBase}\n\n${validationGuide}` : validationBase,
796830
is_error: true,
797831
})
798-
832+
799833
break
800834
}
801835
}
@@ -1119,10 +1153,12 @@ export async function presentAssistantMessage(cline: Task) {
11191153
toolName: block.name,
11201154
metadata: { typeMismatch: true },
11211155
})
1156+
const unknownToolGuide = getTaskErrorState(cline).consumePendingNativeProtocolGuide()
1157+
const unknownToolBase = guided ?? formatResponse.toolError(errorMessage)
11221158
cline.pushToolResultToUserContent({
11231159
type: "tool_result",
11241160
tool_use_id: sanitizeToolUseId(toolCallId),
1125-
content: guided ?? formatResponse.toolError(errorMessage),
1161+
content: unknownToolGuide ? `${unknownToolBase}\n\n${unknownToolGuide}` : unknownToolBase,
11261162
is_error: true,
11271163
})
11281164
break

src/core/tools/error-interception/ErrorClassifier.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,15 @@ function isSafeFactKey(key: string): boolean {
5757
return !SENSITIVE_KEYS.has(key)
5858
}
5959

60+
function hasToolContext(signal: InterceptionSignal): boolean {
61+
return signal.toolName !== undefined || signal.toolCallId !== undefined
62+
}
63+
64+
function isEligible(pattern: ErrorPattern, signal: InterceptionSignal): boolean {
65+
if (pattern.category === "UNCLASSIFIED") return false
66+
return !pattern.requiresToolContext || hasToolContext(signal)
67+
}
68+
6069
function sanitizeFacts(signal: InterceptionSignal, pattern: ErrorPattern): Readonly<Record<string, unknown>> {
6170
const facts: Record<string, unknown> = {}
6271

@@ -87,7 +96,7 @@ function sanitizeFacts(signal: InterceptionSignal, pattern: ErrorPattern): Reado
8796
export function classifyError(signal: InterceptionSignal, _options?: ClassifyOptions): ErrorClassification {
8897
// First pass: exact/structural matchers only.
8998
for (const pattern of ERROR_PATTERNS) {
90-
if (pattern.category === "UNCLASSIFIED") continue
99+
if (!isEligible(pattern, signal)) continue
91100
if (pattern.matches(signal)) {
92101
return {
93102
category: pattern.category,
@@ -102,7 +111,7 @@ export function classifyError(signal: InterceptionSignal, _options?: ClassifyOpt
102111
// Second pass: heuristic fallback matchers, excluding the UNCLASSIFIED
103112
// catch-all at the end of the list.
104113
for (const pattern of ERROR_PATTERNS) {
105-
if (pattern.category === "UNCLASSIFIED") continue
114+
if (!isEligible(pattern, signal)) continue
106115
if (pattern.fallback?.(signal)) {
107116
return {
108117
category: pattern.category,

src/core/tools/error-interception/MessageTransformer.ts

Lines changed: 2 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -8,22 +8,7 @@ import {
88
import type { ErrorCategory, ErrorClassification, ErrorSource, GuidancePayload, TransformOptions } from "./types"
99

1010
function countUtf8Bytes(text: string): number {
11-
let count = 0
12-
for (let i = 0; i < text.length; i++) {
13-
const code = text.charCodeAt(i)
14-
if (code <= 0x7f) {
15-
count += 1
16-
} else if (code <= 0x7ff) {
17-
count += 2
18-
} else if (code >= 0xd800 && code <= 0xdbff) {
19-
// Surrogate pair: count 4 bytes for the pair.
20-
count += 4
21-
i++
22-
} else {
23-
count += 3
24-
}
25-
}
26-
return count
11+
return new TextEncoder().encode(text).length
2712
}
2813

2914
function clampNextItems(next: string[]): string[] {
@@ -34,7 +19,7 @@ function clampNextItems(next: string[]): string[] {
3419
if (candidate.length > NEXT_ITEM_CHAR_LIMIT) {
3520
candidate = candidate.slice(0, NEXT_ITEM_CHAR_LIMIT)
3621
}
37-
candidate = candidate.replace(/[\udc00-\udfff]/g, "")
22+
candidate = candidate.replace(/[\ud800-\udbff](?![\udc00-\udfff])|(?<![\ud800-\udbff])[\udc00-\udfff]/g, "")
3823
if (candidate.length === 0) continue
3924
clamped.push(candidate)
4025
}

src/core/tools/error-interception/StructuralValidator.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,7 @@ function visitNested(
219219
return nested
220220
}
221221
}
222+
state.seen.delete(value)
222223
return { found: false }
223224
}
224225

@@ -233,6 +234,7 @@ function visitNested(
233234
return nested
234235
}
235236
}
237+
state.seen.delete(value)
236238
return { found: false }
237239
}
238240

src/core/tools/error-interception/TaskErrorState.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,13 @@ interface CategoryState {
3434
export class TaskErrorState {
3535
private readonly perCategory = new Map<string, CategoryState>()
3636

37+
/**
38+
* Pending XML_NATIVE_DUAL_PROTOCOL guidance queued by the text-block
39+
* handler. Consumed (read + cleared) by every path that emits a
40+
* tool_result for the turn so it cannot leak into later turns.
41+
*/
42+
private pendingGuide: string | undefined
43+
3744
private getOrCreate(category: string): CategoryState {
3845
let state = this.perCategory.get(category)
3946
if (!state) {
@@ -102,6 +109,31 @@ export class TaskErrorState {
102109
}
103110
this.perCategory.clear()
104111
}
112+
113+
/** Returns the pending native protocol guide without clearing it. */
114+
public getPendingNativeProtocolGuide(): string | undefined {
115+
return this.pendingGuide
116+
}
117+
118+
/** Queues a native protocol guide to be merged into the next tool_result. */
119+
public setPendingNativeProtocolGuide(guide: string): void {
120+
this.pendingGuide = guide
121+
}
122+
123+
/** Clears any pending native protocol guide. */
124+
public clearPendingNativeProtocolGuide(): void {
125+
this.pendingGuide = undefined
126+
}
127+
128+
/**
129+
* Atomically reads and clears the pending native protocol guide.
130+
* Returns undefined when no guide is queued.
131+
*/
132+
public consumePendingNativeProtocolGuide(): string | undefined {
133+
const guide = this.pendingGuide
134+
this.pendingGuide = undefined
135+
return guide
136+
}
105137
}
106138

107139
/**

src/core/tools/error-interception/ToolErrorInterceptor.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -218,7 +218,11 @@ export class ToolErrorInterceptor {
218218
})
219219
const transformed = this.transformSignal(task, signal, taskState)
220220
if (transformed !== undefined) {
221-
;(rawPushToolResult as (content: ToolResponse, ...rest: unknown[]) => void)(transformed, ...rest)
221+
const nonTextBlocks = content.filter((item) => item.type !== "text")
222+
;(rawPushToolResult as (content: ToolResponse, ...rest: unknown[]) => void)(
223+
[{ type: "text", text: transformed } as (typeof content)[number], ...nonTextBlocks],
224+
...rest,
225+
)
222226
return
223227
}
224228
}

0 commit comments

Comments
 (0)