11use super :: types:: * ;
22use super :: utils:: { self , REQUEST_TIMEOUT } ;
33use crate :: error:: { Result , SofosError } ;
4+ use crate :: tools:: tool_name:: ToolName ;
45use reqwest:: header:: { AUTHORIZATION , CONTENT_TYPE , HeaderMap , HeaderValue } ;
56use serde:: Deserialize ;
67use serde_json:: json;
@@ -181,7 +182,7 @@ impl OpenAIClient {
181182
182183 if let Some ( tool_calls) = item. tool_calls {
183184 for call in tool_calls {
184- let input = utils :: parse_tool_arguments ( & call. arguments ) ;
185+ let input = parse_openai_tool_arguments ( & call . name , & call. arguments ) ;
185186 content_blocks. push ( ContentBlock :: ToolUse {
186187 id : call. id ,
187188 name : call. name ,
@@ -194,7 +195,7 @@ impl OpenAIClient {
194195 if let ( Some ( name) , Some ( arguments) , Some ( call_id) ) =
195196 ( item. name , item. arguments , item. call_id )
196197 {
197- let input = utils :: parse_tool_arguments ( & arguments) ;
198+ let input = parse_openai_tool_arguments ( & name , & arguments) ;
198199 content_blocks. push ( ContentBlock :: ToolUse {
199200 id : call_id,
200201 name,
@@ -244,6 +245,56 @@ impl OpenAIClient {
244245 }
245246}
246247
248+ /// Parse tool-call arguments emitted by OpenAI into a JSON value.
249+ ///
250+ /// `morph_edit_file` uses strict parsing because its `code_edit` field carries
251+ /// arbitrary source code; the repair heuristics below can "succeed" on a
252+ /// truncated payload and silently merge corrupted code into a file. For every
253+ /// other tool we attempt a small repair ladder (trim, drop trailing commas,
254+ /// close a missing brace) and fall back to `{"raw_arguments": args}` so the
255+ /// model can see its own malformed output and self-correct on the next turn —
256+ /// rather than receiving an empty `{}` that round-trips back to OpenAI as a
257+ /// `function_call` with no args.
258+ fn parse_openai_tool_arguments ( name : & str , args : & str ) -> serde_json:: Value {
259+ if name == ToolName :: MorphEditFile . as_str ( ) {
260+ return serde_json:: from_str ( args)
261+ . unwrap_or_else ( |_| serde_json:: Value :: Object ( serde_json:: Map :: new ( ) ) ) ;
262+ }
263+
264+ if let Ok ( v) = serde_json:: from_str :: < serde_json:: Value > ( args) {
265+ return v;
266+ }
267+
268+ let trimmed = args. trim ( ) ;
269+ if trimmed != args {
270+ if let Ok ( v) = serde_json:: from_str :: < serde_json:: Value > ( trimmed) {
271+ return v;
272+ }
273+ }
274+
275+ let fixed = trimmed. replace ( ",}" , "}" ) . replace ( ",]" , "]" ) ;
276+ if fixed != trimmed {
277+ if let Ok ( v) = serde_json:: from_str :: < serde_json:: Value > ( & fixed) {
278+ return v;
279+ }
280+ }
281+
282+ if trimmed. starts_with ( '{' ) && !trimmed. ends_with ( '}' ) {
283+ let braced = format ! ( "{}}}" , trimmed. trim_end_matches( ',' ) ) ;
284+ if let Ok ( v) = serde_json:: from_str :: < serde_json:: Value > ( & braced) {
285+ return v;
286+ }
287+ }
288+
289+ let preview_end = utils:: truncate_at_char_boundary ( args, 200 ) ;
290+ eprintln ! (
291+ " \x1b [33m⚠\x1b [0m Failed to parse tool arguments as JSON for {}: {}" ,
292+ name,
293+ & args[ ..preview_end]
294+ ) ;
295+ json ! ( { "raw_arguments" : args} )
296+ }
297+
247298fn build_response_input ( request : & CreateMessageRequest ) -> Vec < serde_json:: Value > {
248299 let mut input = Vec :: new ( ) ;
249300
@@ -432,3 +483,75 @@ struct OpenAIResponseUsage {
432483 #[ serde( default ) ]
433484 output_tokens : Option < u32 > ,
434485}
486+
487+ #[ cfg( test) ]
488+ mod tests {
489+ use super :: * ;
490+
491+ #[ test]
492+ fn parse_args_valid_object_round_trips ( ) {
493+ let v = parse_openai_tool_arguments ( "read_file" , r#"{"path":"src/main.rs"}"# ) ;
494+ assert_eq ! ( v[ "path" ] , "src/main.rs" ) ;
495+ }
496+
497+ #[ test]
498+ fn parse_args_repairs_trailing_comma ( ) {
499+ let v = parse_openai_tool_arguments ( "read_file" , r#"{"path":"src/main.rs",}"# ) ;
500+ assert_eq ! ( v[ "path" ] , "src/main.rs" ) ;
501+ }
502+
503+ #[ test]
504+ fn parse_args_repairs_missing_closing_brace ( ) {
505+ let v = parse_openai_tool_arguments ( "read_file" , r#"{"path":"src/main.rs""# ) ;
506+ assert_eq ! ( v[ "path" ] , "src/main.rs" ) ;
507+ }
508+
509+ #[ test]
510+ fn parse_args_unrepairable_falls_back_to_raw_arguments ( ) {
511+ let v = parse_openai_tool_arguments ( "read_file" , "not json at all" ) ;
512+ assert_eq ! ( v[ "raw_arguments" ] , "not json at all" ) ;
513+ }
514+
515+ #[ test]
516+ fn parse_args_morph_edit_strict_returns_empty_object_on_failure ( ) {
517+ // Truncated code_edit must NOT be silently "repaired" — that would
518+ // merge corrupted source into the user's file. Strict parse → empty
519+ // object → tool dispatch reports a clear "missing parameter" error.
520+ let v = parse_openai_tool_arguments (
521+ ToolName :: MorphEditFile . as_str ( ) ,
522+ r#"{"target_filepath":"src/lib.rs","code_edit":"fn x() { let y = [1,2,"# ,
523+ ) ;
524+ assert ! ( v. is_object( ) ) ;
525+ assert_eq ! ( v. as_object( ) . unwrap( ) . len( ) , 0 ) ;
526+ }
527+
528+ #[ test]
529+ fn parse_args_empty_string_falls_back_to_raw_arguments ( ) {
530+ let v = parse_openai_tool_arguments ( "read_file" , "" ) ;
531+ assert_eq ! ( v[ "raw_arguments" ] , "" ) ;
532+ }
533+
534+ #[ test]
535+ fn parse_args_whitespace_only_falls_back_to_raw_arguments ( ) {
536+ let v = parse_openai_tool_arguments ( "read_file" , " \n \t " ) ;
537+ assert_eq ! ( v[ "raw_arguments" ] , " \n \t " ) ;
538+ }
539+
540+ #[ test]
541+ fn parse_args_array_root_returned_as_is ( ) {
542+ // Non-object root: dispatcher will surface a missing-parameter error,
543+ // which the model can self-correct from. We just need to not panic.
544+ let v = parse_openai_tool_arguments ( "read_file" , "[1,2,3]" ) ;
545+ assert ! ( v. is_array( ) ) ;
546+ }
547+
548+ #[ test]
549+ fn parse_args_morph_edit_valid_round_trips ( ) {
550+ let v = parse_openai_tool_arguments (
551+ ToolName :: MorphEditFile . as_str ( ) ,
552+ r#"{"target_filepath":"src/lib.rs","instructions":"add fn","code_edit":"fn x() {}"}"# ,
553+ ) ;
554+ assert_eq ! ( v[ "target_filepath" ] , "src/lib.rs" ) ;
555+ assert_eq ! ( v[ "code_edit" ] , "fn x() {}" ) ;
556+ }
557+ }
0 commit comments