use std::sync::LazyLock;
use lingshu_types::ToolError;
use regex::Regex;
use crate::registry::ToolRegistry;
static TRAILING_COMMA: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r",\s*([}\]])").expect("valid trailing-comma regex"));
pub fn repair_tool_arguments(raw: &str) -> String {
repair_tool_arguments_inner(raw, empty_fallback_enabled())
}
fn repair_tool_arguments_inner(raw: &str, allow_empty_fallback: bool) -> String {
let raw_stripped = raw.trim();
if raw_stripped.is_empty() || raw_stripped == "null" || raw_stripped == "None" {
tracing::warn!("tool_argument_pipeline: empty tool args normalized to {{}}");
return "{}".to_string();
}
if let Ok(value) = serde_json::from_str::<serde_json::Value>(raw_stripped) {
return canonical_tool_args_json(&value);
}
let mut fixed = normalize_python_json_literals(raw_stripped);
fixed = TRAILING_COMMA.replace_all(&fixed, "$1").into_owned();
fixed = close_unclosed_delimiters(&fixed);
fixed = trim_excess_closing_delimiters(fixed);
if serde_json::from_str::<serde_json::Value>(&fixed).is_ok() {
tracing::warn!(
original = %truncate_for_log(raw_stripped),
repaired = %truncate_for_log(&fixed),
"tool_argument_pipeline: repaired malformed tool arguments"
);
return fixed;
}
let escaped = escape_invalid_chars_in_json_strings(&fixed);
if escaped != fixed && serde_json::from_str::<serde_json::Value>(&escaped).is_ok() {
tracing::warn!(
original = %truncate_for_log(raw_stripped),
repaired = %truncate_for_log(&escaped),
"tool_argument_pipeline: repaired control-char-laced tool arguments"
);
return escaped;
}
if allow_empty_fallback {
tracing::warn!(
tool_args = %truncate_for_log(raw_stripped),
"tool_argument_pipeline: unrepairable args replaced with {{}} (EDGECRAB_TOOL_ARGS_EMPTY_FALLBACK)"
);
return "{}".to_string();
}
fixed
}
pub fn parse_tool_arguments_json(raw: &str) -> Result<serde_json::Value, ToolError> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return Ok(serde_json::json!({}));
}
match serde_json::from_str(trimmed) {
Ok(value) => ensure_object(value),
Err(first_err) => {
let repaired = repair_tool_arguments(trimmed);
serde_json::from_str(&repaired)
.map_err(|e| ToolError::InvalidArgs {
tool: "tool_call".into(),
message: format!("invalid JSON arguments after repair: {e} (initial: {first_err})"),
})
.and_then(ensure_object)
}
}
}
pub fn canonical_tool_args_json(args: &serde_json::Value) -> String {
serde_json::to_string(args).unwrap_or_else(|_| "{}".to_string())
}
pub fn prepare_parsed_tool_arguments(
registry: &ToolRegistry,
tool_name: &str,
raw: &str,
) -> Result<serde_json::Value, ToolError> {
let mut args = parse_tool_arguments_json(raw).map_err(|e| match e {
ToolError::InvalidArgs { tool, message } if tool == "tool_call" => ToolError::InvalidArgs {
tool: tool_name.to_string(),
message,
},
other => other,
})?;
registry.prepare_tool_arguments(tool_name, &mut args);
validate_required_tool_fields(tool_name, &args)?;
Ok(args)
}
fn validate_required_tool_fields(
tool_name: &str,
args: &serde_json::Value,
) -> Result<(), ToolError> {
if tool_name != "write_file" {
return Ok(());
}
let Some(obj) = args.as_object() else {
return Ok(());
};
let path_ok = obj
.get("path")
.and_then(serde_json::Value::as_str)
.is_some_and(|s| !s.is_empty());
if !path_ok {
return Err(crate::recovery_catalog::write_file_missing_path());
}
if !obj.contains_key("content") {
return Err(crate::recovery_catalog::write_file_missing_content(None));
}
Ok(())
}
pub fn repair_stream_tool_arguments(raw: &str) -> String {
repair_tool_arguments(raw)
}
fn ensure_object(value: serde_json::Value) -> Result<serde_json::Value, ToolError> {
if value.is_object() {
Ok(value)
} else {
Err(ToolError::InvalidArgs {
tool: "tool_call".into(),
message: format!(
"tool arguments must be a JSON object, got {}",
value_type_name(&value)
),
})
}
}
fn value_type_name(value: &serde_json::Value) -> &'static str {
match value {
serde_json::Value::Null => "null",
serde_json::Value::Bool(_) => "boolean",
serde_json::Value::Number(_) => "number",
serde_json::Value::String(_) => "string",
serde_json::Value::Array(_) => "array",
serde_json::Value::Object(_) => "object",
}
}
fn normalize_python_json_literals(raw: &str) -> String {
raw.replace(": True", ": true")
.replace(":True", ":true")
.replace(": False", ": false")
.replace(":False", ":false")
.replace(": None", ": null")
.replace(":None", ":null")
}
fn close_unclosed_delimiters(fixed: &str) -> String {
let mut out = fixed.to_string();
let open_curly = out.chars().filter(|c| *c == '{').count();
let close_curly = out.chars().filter(|c| *c == '}').count();
if open_curly > close_curly {
out.push_str(&"}".repeat(open_curly - close_curly));
}
let open_sq = out.chars().filter(|c| *c == '[').count();
let close_sq = out.chars().filter(|c| *c == ']').count();
if open_sq > close_sq {
out.push_str(&"]".repeat(open_sq - close_sq));
}
out
}
fn trim_excess_closing_delimiters(mut fixed: String) -> String {
for _ in 0..50 {
if serde_json::from_str::<serde_json::Value>(&fixed).is_ok() {
break;
}
if fixed.ends_with('}')
&& fixed.chars().filter(|c| *c == '}').count()
> fixed.chars().filter(|c| *c == '{').count()
{
fixed.pop();
continue;
}
if fixed.ends_with(']')
&& fixed.chars().filter(|c| *c == ']').count()
> fixed.chars().filter(|c| *c == '[').count()
{
fixed.pop();
continue;
}
break;
}
fixed
}
fn escape_invalid_chars_in_json_strings(raw: &str) -> String {
let mut out = String::with_capacity(raw.len() + 16);
let mut in_string = false;
let mut chars = raw.chars().peekable();
while let Some(ch) = chars.next() {
if in_string {
if ch == '\\' {
if let Some(next) = chars.next() {
out.push('\\');
out.push(next);
}
continue;
}
if ch == '"' {
in_string = false;
out.push(ch);
continue;
}
if ch.is_ascii_control() {
out.push_str(&format!("\\u{:04x}", ch as u32));
continue;
}
out.push(ch);
} else {
if ch == '"' {
in_string = true;
}
out.push(ch);
}
}
out
}
fn empty_fallback_enabled() -> bool {
match std::env::var("EDGECRAB_TOOL_ARGS_EMPTY_FALLBACK")
.ok()
.as_deref()
.map(str::trim)
{
Some("0") | Some("false") | Some("FALSE") | Some("off") | Some("OFF") => false,
Some("1") | Some("true") | Some("TRUE") | Some("on") | Some("ON") => true,
None | Some(_) => true,
}
}
fn truncate_for_log(s: &str) -> String {
crate::safe_truncate(s, 80).to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ta01_empty_and_none_normalize_to_empty_object() {
assert_eq!(repair_tool_arguments(""), "{}");
assert_eq!(repair_tool_arguments(" "), "{}");
assert_eq!(repair_tool_arguments("null"), "{}");
assert_eq!(repair_tool_arguments("None"), "{}");
}
#[test]
fn ta02_valid_json_canonicalizes() {
let raw = r#"{"path": "foo.rs", "line": 42}"#;
let repaired = repair_tool_arguments(raw);
let v: serde_json::Value = serde_json::from_str(&repaired).expect("parse");
assert_eq!(v["path"], "foo.rs");
assert_eq!(v["line"], 42);
}
#[test]
fn ta03_trailing_comma_object_and_array() {
let obj = repair_tool_arguments(r#"{"a": 1, "b": 2, }"#);
assert_eq!(
serde_json::from_str::<serde_json::Value>(&obj).expect("obj"),
serde_json::json!({"a": 1, "b": 2})
);
let arr = repair_tool_arguments(r#"{"items": [1, 2, 3, ]}"#);
assert_eq!(
serde_json::from_str::<serde_json::Value>(&arr).expect("arr")["items"]
.as_array()
.expect("array")
.len(),
3
);
}
#[test]
fn ta04_python_literals_and_unclosed_braces() {
let py = repair_tool_arguments(r#"{"flag": True, "other": False, "val": None}"#);
let v: serde_json::Value = serde_json::from_str(&py).expect("py");
assert_eq!(v["flag"], serde_json::json!(true));
assert!(v["val"].is_null());
let nested = repair_tool_arguments(r#"{"a": {"b": 1}"#);
assert_eq!(
serde_json::from_str::<serde_json::Value>(&nested).expect("nested")["a"]["b"],
1
);
}
#[test]
fn ta05_trim_excess_closing_brace() {
let fixed = repair_tool_arguments(r#"{"key": "value"}}"#);
assert_eq!(
serde_json::from_str::<serde_json::Value>(&fixed).expect("fixed"),
serde_json::json!({"key": "value"})
);
}
#[test]
fn ta06_control_chars_with_trailing_comma() {
let raw = r#"{"msg": "line
one",}"#;
let fixed = repair_tool_arguments(raw);
let v: serde_json::Value = serde_json::from_str(&fixed).expect("fixed");
assert!(v["msg"].as_str().expect("str").contains("line"));
}
#[test]
fn ta07_unrepairable_without_opt_out_still_attempts_structure() {
let garbage = repair_tool_arguments_inner("totally not json", false);
assert!(serde_json::from_str::<serde_json::Value>(&garbage).is_err());
}
#[test]
fn ta08_empty_fallback_default_on() {
let fixed = repair_tool_arguments("totally not json");
assert_eq!(fixed, "{}");
}
#[test]
fn ta08b_empty_fallback_opt_out() {
let fixed = repair_tool_arguments_inner("totally not json", false);
assert!(serde_json::from_str::<serde_json::Value>(&fixed).is_err());
}
#[test]
fn ta09_prepare_renames_file_path_via_registry() {
let registry = ToolRegistry::new();
let prepared = prepare_parsed_tool_arguments(
®istry,
"write_file",
r#"{"file_path":"x.py","text":"ok"}"#,
)
.expect("prepare");
assert_eq!(prepared["path"], "x.py");
assert_eq!(prepared["content"], "ok");
}
#[test]
fn ta10_write_file_missing_path_after_alias_normalize() {
let registry = ToolRegistry::new();
let err = prepare_parsed_tool_arguments(®istry, "write_file", r#"{"text":"ok"}"#)
.expect_err("missing path");
assert!(err.to_llm_response().contains("missing required field 'path'"));
}
#[test]
fn ta11_write_file_missing_content() {
let registry = ToolRegistry::new();
let err =
prepare_parsed_tool_arguments(®istry, "write_file", r#"{"path":"/tmp/x.md"}"#)
.expect_err("missing content");
assert!(err.to_llm_response().contains("missing required field 'content'"));
}
}