Skip to content

Commit a259fb2

Browse files
author
Zoo (VP)
committed
fix(error-interception): address PR review findings and improve guidance
1 parent 3a3dc1f commit a259fb2

23 files changed

Lines changed: 5513 additions & 2139 deletions

.gitignore

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,3 +55,12 @@ qdrant_storage/
5555
plans/
5656

5757
roo-cli-*.tar.gz*
58+
59+
# Local dev scripts (not for CI)
60+
/ci-fix-commit.ps1
61+
/commit-and-push.ps1
62+
/commit-message.txt
63+
/resolve_conflicts.py
64+
65+
# AI session artifacts (timestamped session directories)
66+
/docs/*_session_*/

ci-fix-commit.ps1

Lines changed: 0 additions & 16 deletions
This file was deleted.

commit-and-push.ps1

Lines changed: 0 additions & 4 deletions
This file was deleted.

commit-message.txt

Lines changed: 0 additions & 22 deletions
This file was deleted.

docs/260726_0003_session_error-hiding-fix/074338_code-light-report.md

Lines changed: 0 additions & 45 deletions
This file was deleted.

resolve_conflicts.py

Lines changed: 0 additions & 23 deletions
This file was deleted.

src/core/assistant-message/NativeToolCallParser.ts

Lines changed: 185 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,32 @@ type NativeArgsFor<TName extends ToolName> = TName extends keyof NativeToolArgs
3838
*/
3939
export type ToolCallStreamEvent = ApiStreamToolCallStartChunk | ApiStreamToolCallDeltaChunk | ApiStreamToolCallEndChunk
4040

41+
/**
42+
* Discriminated union for parser failure kinds.
43+
*
44+
* - `json_syntax`: The arguments string could not be parsed as JSON.
45+
* - `missing_required_arguments`: The JSON was valid but one or more required
46+
* fields were absent (including the empty-object case).
47+
* - `invalid_argument_shape`: The JSON was valid and required field names were
48+
* present, but the structural shape did not match the tool schema (e.g. a
49+
* field had the wrong type or the value could not be coerced).
50+
*/
51+
export type ParserFailureKind = "json_syntax" | "missing_required_arguments" | "invalid_argument_shape"
52+
53+
/**
54+
* Typed, sanitized descriptor for a parser failure.
55+
*
56+
* IMPORTANT: This descriptor MUST NOT contain raw argument bodies, file paths,
57+
* commands, task IDs, or secrets. It carries only structural facts needed for
58+
* error classification and model guidance.
59+
*/
60+
export interface NativeToolParseFailure {
61+
kind: ParserFailureKind
62+
toolName?: string
63+
missingParameters?: string[] // Known missing required field names from parser's tool contract
64+
emptyArguments?: boolean // true if the input was {} or ""
65+
}
66+
4167
/**
4268
* Parser for native tool calls (OpenAI-style function calling).
4369
* Converts native tool call format to ToolUse format for compatibility
@@ -79,12 +105,76 @@ export class NativeToolCallParser {
79105
* here so presentAssistantMessage can retrieve it and route the signal to
80106
* the INVALID_JSON_ARGUMENTS error-interception pattern instead of the
81107
* generic PARAM_MISSING path.
108+
*
109+
* @deprecated Use {@link parseFailures} and {@link consumeParseFailure} for
110+
* typed failure descriptors. This legacy string map is retained only as a
111+
* compatibility wrapper for human diagnostics.
82112
*/
83113
private static parseErrors = new Map<string, string>()
84114

115+
/**
116+
* Stores typed parser failure descriptors keyed by tool call ID.
117+
* When parseToolCall() catches any failure (JSON syntax, missing required
118+
* arguments, or invalid argument shape), it records a typed descriptor here
119+
* so downstream consumers can classify the failure precisely instead of
120+
* relying on raw error strings.
121+
*/
122+
private static parseFailures = new Map<string, NativeToolParseFailure>()
123+
124+
/**
125+
* Required parameter names for each native tool, derived from
126+
* {@link NativeToolArgs}. Used to classify missing-required-arguments
127+
* failures with precise field names.
128+
*/
129+
private static readonly REQUIRED_PARAMETERS: Record<string, string[]> = {
130+
access_mcp_resource: ["server_name", "uri"],
131+
read_file: ["path"],
132+
read_command_output: ["artifact_id"],
133+
attempt_completion: ["result"],
134+
execute_command: ["command"],
135+
apply_diff: ["path", "diff"],
136+
edit: ["file_path", "old_string", "new_string"],
137+
search_and_replace: ["file_path", "old_string", "new_string"],
138+
search_replace: ["file_path", "old_string", "new_string"],
139+
edit_file: ["file_path", "old_string", "new_string"],
140+
apply_patch: ["patch"],
141+
list_files: ["path"],
142+
new_task: ["mode", "message"],
143+
ask_followup_question: ["question", "follow_up"],
144+
codebase_search: ["query"],
145+
generate_image: ["prompt", "path"],
146+
run_slash_command: ["command"],
147+
skill: ["skill"],
148+
search_files: ["path", "regex"],
149+
switch_mode: ["mode_slug", "reason"],
150+
update_todo_list: ["todos"],
151+
use_mcp_tool: ["server_name", "tool_name"],
152+
write_to_file: ["path", "content"],
153+
}
154+
155+
/**
156+
* Retrieve and remove the typed parse failure descriptor for a given tool
157+
* call ID. Returns undefined if no failure was recorded or if it was
158+
* already consumed.
159+
*
160+
* Atomic consume-and-delete, matching the lifecycle of the legacy
161+
* {@link consumeParseError} string side channel.
162+
*/
163+
public static consumeParseFailure(toolCallId: string): NativeToolParseFailure | undefined {
164+
const failure = NativeToolCallParser.parseFailures.get(toolCallId)
165+
if (failure !== undefined) {
166+
NativeToolCallParser.parseFailures.delete(toolCallId)
167+
}
168+
return failure
169+
}
170+
85171
/**
86172
* Retrieve and remove the parse error for a given tool call ID.
87173
* Returns undefined if no parse error was recorded.
174+
*
175+
* @deprecated Compatibility wrapper. New production code should use
176+
* {@link consumeParseFailure} for typed failure descriptors. This method
177+
* returns the string representation for human diagnostics only.
88178
*/
89179
public static consumeParseError(toolCallId: string): string | undefined {
90180
const error = NativeToolCallParser.parseErrors.get(toolCallId)
@@ -1032,11 +1122,43 @@ export class NativeToolCallParser {
10321122
// Native-only: core tools must always have typed nativeArgs.
10331123
// If we couldn't construct it, the model produced an invalid tool call payload.
10341124
if (!nativeArgs && !customToolRegistry.has(resolvedName)) {
1035-
throw new Error(
1036-
`[NativeToolCallParser] Invalid arguments for tool '${resolvedName}'. ` +
1037-
`Native tool calls require a valid JSON payload matching the tool schema. ` +
1038-
`Received: ${JSON.stringify(args)}`,
1039-
)
1125+
// Classify the failure precisely so the catch block can store a
1126+
// typed descriptor instead of a raw error string.
1127+
//
1128+
// If args is not a plain object (e.g. a primitive, array, or null),
1129+
// the structural shape is fundamentally wrong.
1130+
const isPlainObject = typeof args === "object" && args !== null && !Array.isArray(args)
1131+
1132+
if (!isPlainObject) {
1133+
throw {
1134+
__parserFailureKind: "invalid_argument_shape" as const,
1135+
toolName: resolvedName as string,
1136+
missingParameters: [],
1137+
emptyArguments: false,
1138+
}
1139+
}
1140+
1141+
const required = NativeToolCallParser.REQUIRED_PARAMETERS[resolvedName as string] ?? []
1142+
const missing = required.filter((p) => args[p] === undefined)
1143+
const isEmpty = Object.keys(args).length === 0
1144+
1145+
if (missing.length > 0) {
1146+
throw {
1147+
__parserFailureKind: "missing_required_arguments" as const,
1148+
toolName: resolvedName as string,
1149+
missingParameters: missing,
1150+
emptyArguments: isEmpty,
1151+
}
1152+
}
1153+
1154+
// Required fields are present but the structural shape didn't match
1155+
// any known pattern in the switch above.
1156+
throw {
1157+
__parserFailureKind: "invalid_argument_shape" as const,
1158+
toolName: resolvedName as string,
1159+
missingParameters: [],
1160+
emptyArguments: isEmpty,
1161+
}
10401162
}
10411163

10421164
const result: ToolUse<TName> = {
@@ -1059,23 +1181,67 @@ export class NativeToolCallParser {
10591181

10601182
return result
10611183
} catch (error) {
1062-
const errorMessage = error instanceof Error ? error.message : String(error)
1063-
1064-
console.error(
1065-
`Failed to parse tool call arguments: ${errorMessage}`,
1066-
)
1067-
1068-
console.error(`Tool call: ${JSON.stringify(toolCall, null, 2)}`)
1069-
1070-
// Store the parse error so presentAssistantMessage can route it
1071-
// to the INVALID_JSON_ARGUMENTS error-interception pattern
1072-
// instead of the generic PARAM_MISSING path.
1073-
NativeToolCallParser.parseErrors.set(toolCall.id, errorMessage)
1074-
1075-
return null
1184+
// Determine whether this is a JSON.parse syntax failure or a
1185+
// post-parse structural failure (missing required arguments or
1186+
// invalid argument shape). The structural failures are thrown as
1187+
// tagged objects with __parserFailureKind; JSON.parse failures are
1188+
// standard SyntaxError instances.
1189+
const failure = NativeToolCallParser.classifyParseFailure(error, resolvedName as string)
1190+
1191+
const errorMessage = error instanceof Error ? error.message : String(error)
1192+
1193+
console.error(`Failed to parse tool call arguments: ${errorMessage}`)
1194+
1195+
console.error(`Tool call: ${JSON.stringify(toolCall, null, 2)}`)
1196+
1197+
// Store the legacy string error for backward compatibility with
1198+
// existing callers of consumeParseError().
1199+
NativeToolCallParser.parseErrors.set(toolCall.id, errorMessage)
1200+
1201+
// Store the typed failure descriptor for new callers that use
1202+
// consumeParseFailure().
1203+
NativeToolCallParser.parseFailures.set(toolCall.id, failure)
1204+
1205+
return null
1206+
}
1207+
}
1208+
1209+
/**
1210+
* Classify a caught error from parseToolCall() into a typed
1211+
* {@link NativeToolParseFailure} descriptor.
1212+
*
1213+
* - If the error is a tagged object with `__parserFailureKind`, it was
1214+
* thrown by the structural validation logic and carries precise metadata.
1215+
* - Otherwise, the error originated from JSON.parse (a SyntaxError) and is
1216+
* classified as `json_syntax`.
1217+
*/
1218+
private static classifyParseFailure(error: unknown, toolName: string): NativeToolParseFailure {
1219+
// Check for tagged structural failure objects thrown by the validation
1220+
// logic above. These are not Error instances — they are plain objects
1221+
// with a __parserFailureKind discriminator.
1222+
if (typeof error === "object" && error !== null && "__parserFailureKind" in error) {
1223+
const tagged = error as {
1224+
__parserFailureKind: ParserFailureKind
1225+
toolName?: string
1226+
missingParameters?: string[]
1227+
emptyArguments?: boolean
1228+
}
1229+
return {
1230+
kind: tagged.__parserFailureKind,
1231+
toolName: tagged.toolName ?? toolName,
1232+
missingParameters: tagged.missingParameters,
1233+
emptyArguments: tagged.emptyArguments,
10761234
}
10771235
}
10781236

1237+
// Any other error (SyntaxError from JSON.parse, or unexpected runtime
1238+
// error) is classified as a JSON syntax failure.
1239+
return {
1240+
kind: "json_syntax",
1241+
toolName,
1242+
}
1243+
}
1244+
10791245
/**
10801246
* Parse dynamic MCP tools (named mcp--serverName--toolName).
10811247
* These are generated dynamically by getMcpServerTools() and are returned

0 commit comments

Comments
 (0)