Skip to content

Commit 2bed5e4

Browse files
committed
Restore OpenAI tool-argument repair, keep morph_edit_file strict
1 parent 486ab21 commit 2bed5e4

1 file changed

Lines changed: 125 additions & 2 deletions

File tree

src/api/openai.rs

Lines changed: 125 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
use super::types::*;
22
use super::utils::{self, REQUEST_TIMEOUT};
33
use crate::error::{Result, SofosError};
4+
use crate::tools::tool_name::ToolName;
45
use reqwest::header::{AUTHORIZATION, CONTENT_TYPE, HeaderMap, HeaderValue};
56
use serde::Deserialize;
67
use 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+
247298
fn 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

Comments
 (0)