@@ -38,6 +38,32 @@ type NativeArgsFor<TName extends ToolName> = TName extends keyof NativeToolArgs
3838 */
3939export 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