fix: escape control characters in LLM tool call arguments JSON (#2893)
Signed-off-by: Julian Brown <contact@julianbrown.dev> Co-authored-by: Julian Brown <jbrown@stripe.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
use crate::message::{Message, MessageContent};
|
||||
use crate::model::ModelConfig;
|
||||
use crate::providers::utils::{
|
||||
convert_image, detect_image_path, is_valid_function_name, load_image_file,
|
||||
convert_image, detect_image_path, is_valid_function_name, load_image_file, safely_parse_json,
|
||||
sanitize_function_name, ImageFormat,
|
||||
};
|
||||
use anyhow::{anyhow, Error};
|
||||
@@ -324,14 +324,19 @@ pub fn response_to_message(response: &Value) -> anyhow::Result<Message> {
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let mut arguments = tool_call["function"]["arguments"]
|
||||
|
||||
// Get the raw arguments string from the LLM.
|
||||
let arguments_str = tool_call["function"]["arguments"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
// If arguments is empty, we will have invalid json parsing error later.
|
||||
if arguments.is_empty() {
|
||||
arguments = "{}".to_string();
|
||||
}
|
||||
|
||||
// If arguments_str is empty, default to an empty JSON object string.
|
||||
let arguments_str = if arguments_str.is_empty() {
|
||||
"{}".to_string()
|
||||
} else {
|
||||
arguments_str
|
||||
};
|
||||
|
||||
if !is_valid_function_name(&function_name) {
|
||||
let error = ToolError::NotFound(format!(
|
||||
@@ -340,7 +345,7 @@ pub fn response_to_message(response: &Value) -> anyhow::Result<Message> {
|
||||
));
|
||||
content.push(MessageContent::tool_request(id, Err(error)));
|
||||
} else {
|
||||
match serde_json::from_str::<Value>(&arguments) {
|
||||
match safely_parse_json(&arguments_str) {
|
||||
Ok(params) => {
|
||||
content.push(MessageContent::tool_request(
|
||||
id,
|
||||
@@ -349,8 +354,8 @@ pub fn response_to_message(response: &Value) -> anyhow::Result<Message> {
|
||||
}
|
||||
Err(e) => {
|
||||
let error = ToolError::InvalidParameters(format!(
|
||||
"Could not interpret tool use parameters for id {}: {}",
|
||||
id, e
|
||||
"Could not interpret tool use parameters for id {}: {}. Raw arguments: '{}'",
|
||||
id, e, arguments_str
|
||||
));
|
||||
content.push(MessageContent::tool_request(id, Err(error)));
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ use crate::message::{Message, MessageContent};
|
||||
use crate::model::ModelConfig;
|
||||
use crate::providers::base::{ProviderUsage, Usage};
|
||||
use crate::providers::utils::{
|
||||
convert_image, detect_image_path, is_valid_function_name, load_image_file,
|
||||
convert_image, detect_image_path, is_valid_function_name, load_image_file, safely_parse_json,
|
||||
sanitize_function_name, ImageFormat,
|
||||
};
|
||||
use anyhow::{anyhow, Error};
|
||||
@@ -284,14 +284,19 @@ pub fn response_to_message(response: &Value) -> anyhow::Result<Message> {
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let mut arguments = tool_call["function"]["arguments"]
|
||||
|
||||
// Get the raw arguments string from the LLM.
|
||||
let arguments_str = tool_call["function"]["arguments"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
// If arguments is empty, we will have invalid json parsing error later.
|
||||
if arguments.is_empty() {
|
||||
arguments = "{}".to_string();
|
||||
}
|
||||
|
||||
// If arguments_str is empty, default to an empty JSON object string.
|
||||
let arguments_str = if arguments_str.is_empty() {
|
||||
"{}".to_string()
|
||||
} else {
|
||||
arguments_str
|
||||
};
|
||||
|
||||
if !is_valid_function_name(&function_name) {
|
||||
let error = ToolError::NotFound(format!(
|
||||
@@ -300,7 +305,7 @@ pub fn response_to_message(response: &Value) -> anyhow::Result<Message> {
|
||||
));
|
||||
content.push(MessageContent::tool_request(id, Err(error)));
|
||||
} else {
|
||||
match serde_json::from_str::<Value>(&arguments) {
|
||||
match safely_parse_json(&arguments_str) {
|
||||
Ok(params) => {
|
||||
content.push(MessageContent::tool_request(
|
||||
id,
|
||||
@@ -309,8 +314,8 @@ pub fn response_to_message(response: &Value) -> anyhow::Result<Message> {
|
||||
}
|
||||
Err(e) => {
|
||||
let error = ToolError::InvalidParameters(format!(
|
||||
"Could not interpret tool use parameters for id {}: {}",
|
||||
id, e
|
||||
"Could not interpret tool use parameters for id {}: {}. Raw arguments: '{}'",
|
||||
id, e, arguments_str
|
||||
));
|
||||
content.push(MessageContent::tool_request(id, Err(error)));
|
||||
}
|
||||
|
||||
@@ -347,6 +347,72 @@ pub fn emit_debug_trace<T1, T2>(
|
||||
);
|
||||
}
|
||||
|
||||
/// Safely parse a JSON string that may contain doubly-encoded or malformed JSON.
|
||||
/// This function first attempts to parse the input string as-is. If that fails,
|
||||
/// it applies control character escaping and tries again.
|
||||
///
|
||||
/// This approach preserves valid JSON like `{"key1": "value1",\n"key2": "value"}`
|
||||
/// (which contains a literal \n but is perfectly valid JSON) while still fixing
|
||||
/// broken JSON like `{"key1": "value1\n","key2": "value"}` (which contains an
|
||||
/// unescaped newline character).
|
||||
pub fn safely_parse_json(s: &str) -> Result<serde_json::Value, serde_json::Error> {
|
||||
// First, try parsing the string as-is
|
||||
match serde_json::from_str(s) {
|
||||
Ok(value) => Ok(value),
|
||||
Err(_) => {
|
||||
// If that fails, try with control character escaping
|
||||
let escaped = json_escape_control_chars_in_string(s);
|
||||
serde_json::from_str(&escaped)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper to escape control characters in a string that is supposed to be a JSON document.
|
||||
/// This function iterates through the input string `s` and replaces any literal
|
||||
/// control characters (U+0000 to U+001F) with their JSON-escaped equivalents
|
||||
/// (e.g., '\n' becomes "\\n", '\u0001' becomes "\\u0001").
|
||||
///
|
||||
/// It does NOT escape quotes (") or backslashes (\) because it assumes `s` is a
|
||||
/// full JSON document, and these characters might be structural (e.g., object delimiters,
|
||||
/// existing valid escape sequences). The goal is to fix common LLM errors where
|
||||
/// control characters are emitted raw into what should be JSON string values,
|
||||
/// making the overall JSON structure unparsable.
|
||||
///
|
||||
/// If the input string `s` has other JSON syntax errors (e.g., an unescaped quote
|
||||
/// *within* a string value like `{"key": "string with " quote"}`), this function
|
||||
/// will not fix them. It specifically targets unescaped control characters.
|
||||
pub fn json_escape_control_chars_in_string(s: &str) -> String {
|
||||
let mut r = String::with_capacity(s.len()); // Pre-allocate for efficiency
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
// ASCII Control characters (U+0000 to U+001F)
|
||||
'\u{0000}'..='\u{001F}' => {
|
||||
match c {
|
||||
'\u{0008}' => r.push_str("\\b"), // Backspace
|
||||
'\u{000C}' => r.push_str("\\f"), // Form feed
|
||||
'\n' => r.push_str("\\n"), // Line feed
|
||||
'\r' => r.push_str("\\r"), // Carriage return
|
||||
'\t' => r.push_str("\\t"), // Tab
|
||||
// Other control characters (e.g., NUL, SOH, VT, etc.)
|
||||
// that don't have a specific short escape sequence.
|
||||
_ => {
|
||||
r.push_str(&format!("\\u{:04x}", c as u32));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Other characters are passed through.
|
||||
// This includes quotes (") and backslashes (\). If these are part of the
|
||||
// JSON structure (e.g. {"key": "value"}) or part of an already correctly
|
||||
// escaped sequence within a string value (e.g. "string with \\\" quote"),
|
||||
// they are preserved as is. This function does not attempt to fix
|
||||
// malformed quote or backslash usage *within* string values if the LLM
|
||||
// generates them incorrectly (e.g. {"key": "unescaped " quote in string"}).
|
||||
_ => r.push(c),
|
||||
}
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -572,4 +638,89 @@ mod tests {
|
||||
assert_eq!(result, expected_status);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_safely_parse_json() {
|
||||
// Test valid JSON that should parse without escaping (contains proper escape sequence)
|
||||
let valid_json = r#"{"key1": "value1","key2": "value2"}"#;
|
||||
let result = safely_parse_json(valid_json).unwrap();
|
||||
assert_eq!(result["key1"], "value1");
|
||||
assert_eq!(result["key2"], "value2");
|
||||
|
||||
// Test JSON with actual unescaped newlines that needs escaping
|
||||
let invalid_json = "{\"key1\": \"value1\n\",\"key2\": \"value2\"}";
|
||||
let result = safely_parse_json(invalid_json).unwrap();
|
||||
assert_eq!(result["key1"], "value1\n");
|
||||
assert_eq!(result["key2"], "value2");
|
||||
|
||||
// Test already valid JSON - should parse on first try
|
||||
let good_json = r#"{"test": "value"}"#;
|
||||
let result = safely_parse_json(good_json).unwrap();
|
||||
assert_eq!(result["test"], "value");
|
||||
|
||||
// Test completely invalid JSON that can't be fixed
|
||||
let broken_json = r#"{"key": "unclosed_string"#;
|
||||
assert!(safely_parse_json(broken_json).is_err());
|
||||
|
||||
// Test empty object
|
||||
let empty_json = "{}";
|
||||
let result = safely_parse_json(empty_json).unwrap();
|
||||
assert!(result.as_object().unwrap().is_empty());
|
||||
|
||||
// Test JSON with escaped newlines (valid JSON) - should parse on first try
|
||||
let escaped_json = r#"{"key": "value with\nnewline"}"#;
|
||||
let result = safely_parse_json(escaped_json).unwrap();
|
||||
assert_eq!(result["key"], "value with\nnewline");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_json_escape_control_chars_in_string() {
|
||||
// Test basic control character escaping
|
||||
assert_eq!(
|
||||
json_escape_control_chars_in_string("Hello\nWorld"),
|
||||
"Hello\\nWorld"
|
||||
);
|
||||
assert_eq!(
|
||||
json_escape_control_chars_in_string("Hello\tWorld"),
|
||||
"Hello\\tWorld"
|
||||
);
|
||||
assert_eq!(
|
||||
json_escape_control_chars_in_string("Hello\rWorld"),
|
||||
"Hello\\rWorld"
|
||||
);
|
||||
|
||||
// Test multiple control characters
|
||||
assert_eq!(
|
||||
json_escape_control_chars_in_string("Hello\n\tWorld\r"),
|
||||
"Hello\\n\\tWorld\\r"
|
||||
);
|
||||
|
||||
// Test that quotes and backslashes are preserved (not escaped)
|
||||
assert_eq!(
|
||||
json_escape_control_chars_in_string("Hello \"World\""),
|
||||
"Hello \"World\""
|
||||
);
|
||||
assert_eq!(
|
||||
json_escape_control_chars_in_string("Hello\\World"),
|
||||
"Hello\\World"
|
||||
);
|
||||
|
||||
// Test JSON-like string with control characters
|
||||
assert_eq!(
|
||||
json_escape_control_chars_in_string("{\"message\": \"Hello\nWorld\"}"),
|
||||
"{\"message\": \"Hello\\nWorld\"}"
|
||||
);
|
||||
|
||||
// Test no changes for normal strings
|
||||
assert_eq!(
|
||||
json_escape_control_chars_in_string("Hello World"),
|
||||
"Hello World"
|
||||
);
|
||||
|
||||
// Test other control characters get unicode escapes
|
||||
assert_eq!(
|
||||
json_escape_control_chars_in_string("Hello\u{0001}World"),
|
||||
"Hello\\u0001World"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user