| 1 | //! Deterministic JSON argument repair for malformed tool-call inputs. |
| 2 | //! |
| 3 | //! DeepSeek streams `tool_calls.function.arguments` as deltas. Two failure |
| 4 | //! shapes are common: (a) SSE chunk boundary cuts inside a JSON string and |
| 5 | //! reassembly leaves a trailing comma or unclosed brace; (b) some local |
| 6 | //! backends emit literal control characters inside JSON string values. |
| 7 | //! |
| 8 | //! The repair ladder runs five stages before falling back to an empty object: |
| 9 | //! |
| 10 | //! 1. Strict parse — done if it parses. |
| 11 | //! 2. Strip literal control chars inside string values. |
| 12 | //! 3. Strip trailing commas before `}` or `]`. |
| 13 | //! 4. Balance braces/brackets (append closers). |
| 14 | //! 5. Strip excess closers if delta is negative. |
| 15 | //! 6. Fallback: empty object `{}`. |
| 16 | |
| 17 | use serde_json::{Map, Value}; |
| 18 | |
| 19 | /// Maximum raw argument length we'll attempt to repair (1 MiB). |
| 20 | const MAX_ARG_LEN: usize = 1024 * 1024; |
| 21 | |
| 22 | #[derive(Debug, thiserror::Error)] |
| 23 | pub enum ArgRepairError { |
| 24 | #[error("argument exceeded {0} chars; refusing to repair")] |
| 25 | TooLarge(usize), |
| 26 | } |
| 27 | |
| 28 | /// Repair a raw JSON argument string into a valid `serde_json::Value`. |
| 29 | /// |
| 30 | /// Runs the deterministic ladder; on success returns the parsed value. |
| 31 | /// The final fallback is an empty object `{}` so dispatch always proceeds. |
| 32 | pub fn repair(raw: &str) -> Result<Value, ArgRepairError> { |
| 33 | if raw.len() > MAX_ARG_LEN { |
| 34 | return Err(ArgRepairError::TooLarge(raw.len())); |
| 35 | } |
| 36 | // Stage 1: strict parse |
| 37 | if let Ok(v) = serde_json::from_str(raw) { |
| 38 | return Ok(v); |
| 39 | } |
| 40 | // Stage 2: strip control chars inside strings |
| 41 | let mut s = strip_control_chars_in_strings(raw); |
| 42 | if let Ok(v) = serde_json::from_str(&s) { |
| 43 | return Ok(v); |
| 44 | } |
| 45 | // Stage 3: strip trailing commas |
| 46 | s = strip_trailing_commas(&s); |
| 47 | if let Ok(v) = serde_json::from_str(&s) { |
| 48 | return Ok(v); |
| 49 | } |
| 50 | // Stage 4: balance braces |
| 51 | s = balance_braces(&s, 50); |
| 52 | if let Ok(v) = serde_json::from_str(&s) { |
| 53 | return Ok(v); |
| 54 | } |
| 55 | // Stage 5: strip excess closers |
| 56 | s = strip_excess_closers(&s); |
| 57 | if let Ok(v) = serde_json::from_str(&s) { |
| 58 | return Ok(v); |
| 59 | } |
| 60 | // Fallback: empty object |
| 61 | Ok(Value::Object(Map::new())) |
| 62 | } |
| 63 | |
| 64 | /// Strip ASCII control characters (0x00–0x1F except \t, \n, \r) that appear |
| 65 | /// inside JSON string values. We walk character-by-character tracking whether |
| 66 | /// we're inside a string (between unescaped double-quotes). |
| 67 | fn strip_control_chars_in_strings(s: &str) -> String { |
| 68 | let mut out = String::with_capacity(s.len()); |
| 69 | let mut in_string = false; |
| 70 | let mut escape = false; |
| 71 | for ch in s.chars() { |
| 72 | if escape { |
| 73 | out.push(ch); |
| 74 | escape = false; |
| 75 | continue; |
| 76 | } |
| 77 | if ch == '\\' { |
| 78 | escape = true; |
| 79 | out.push(ch); |
| 80 | continue; |
| 81 | } |
| 82 | if ch == '"' { |
| 83 | in_string = !in_string; |
| 84 | out.push(ch); |
| 85 | continue; |
| 86 | } |
| 87 | if in_string && (ch as u32) < 0x20 && ch != '\t' && ch != '\n' && ch != '\r' { |
| 88 | // Drop control characters inside strings |
| 89 | continue; |
| 90 | } |
| 91 | out.push(ch); |
| 92 | } |
| 93 | out |
| 94 | } |
| 95 | |
| 96 | /// Strip trailing commas before `}` or `]`. |
| 97 | fn strip_trailing_commas(s: &str) -> String { |
| 98 | // Repeatedly replace ",}" and ",]" until stable (handles nested cases). |
| 99 | let mut out = s.to_string(); |
| 100 | loop { |
| 101 | let prev = out.clone(); |
| 102 | out = out.replace(",}", "}").replace(",]", "]"); |
| 103 | // Handle trailing comma at end of string |
| 104 | out = out.trim_end_matches(',').to_string(); |
| 105 | if out == prev { |
| 106 | break; |
| 107 | } |
| 108 | } |
| 109 | out |
| 110 | } |
| 111 | |
| 112 | /// Balance braces and brackets: count `{`/`}` and `[`/`]`, append closers if |
| 113 | /// positive delta (more opens than closes). Caps iterations so a |
| 114 | /// catastrophically broken input doesn't loop forever. |
| 115 | fn balance_braces(s: &str, max_iter: usize) -> String { |
| 116 | let mut out = s.to_string(); |
| 117 | for _ in 0..max_iter { |
| 118 | let brace_delta: i32 = out |
| 119 | .chars() |
| 120 | .map(|ch| match ch { |
| 121 | '{' => 1, |
| 122 | '}' => -1, |
| 123 | _ => 0, |
| 124 | }) |
| 125 | .sum(); |
| 126 | let bracket_delta: i32 = out |
| 127 | .chars() |
| 128 | .map(|ch| match ch { |
| 129 | '[' => 1, |
| 130 | ']' => -1, |
| 131 | _ => 0, |
| 132 | }) |
| 133 | .sum(); |
| 134 | if brace_delta <= 0 && bracket_delta <= 0 { |
| 135 | break; |
| 136 | } |
| 137 | // Append needed closers in reverse order (brackets before braces |
| 138 | // for correct nesting when both are unbalanced). |
| 139 | for _ in 0..bracket_delta.max(0) { |
| 140 | out.push(']'); |
| 141 | } |
| 142 | for _ in 0..brace_delta.max(0) { |
| 143 | out.push('}'); |
| 144 | } |
| 145 | } |
| 146 | out |
| 147 | } |
| 148 | |
| 149 | /// Strip excess closers when the delta is negative (more closes than opens). |
| 150 | fn strip_excess_closers(s: &str) -> String { |
| 151 | let mut brace_depth: i32 = 0; |
| 152 | let mut bracket_depth: i32 = 0; |
| 153 | let mut out = String::with_capacity(s.len()); |
| 154 | for ch in s.chars() { |
| 155 | match ch { |
| 156 | '}' => { |
| 157 | if brace_depth > 0 { |
| 158 | brace_depth -= 1; |
| 159 | out.push(ch); |
| 160 | } |
| 161 | // else drop excess closer |
| 162 | } |
| 163 | ']' => { |
| 164 | if bracket_depth > 0 { |
| 165 | bracket_depth -= 1; |
| 166 | out.push(ch); |
| 167 | } |
| 168 | } |
| 169 | '{' => { |
| 170 | brace_depth += 1; |
| 171 | out.push(ch); |
| 172 | } |
| 173 | '[' => { |
| 174 | bracket_depth += 1; |
| 175 | out.push(ch); |
| 176 | } |
| 177 | _ => out.push(ch), |
| 178 | } |
| 179 | } |
| 180 | out |
| 181 | } |
| 182 | |
| 183 | #[cfg(test)] |
| 184 | mod tests { |
| 185 | use super::*; |
| 186 | use serde_json::json; |
| 187 | |
| 188 | #[test] |
| 189 | fn strict_parse_passes_through() { |
| 190 | let v = repair(r#"{"path": "hello.txt"}"#).unwrap(); |
| 191 | assert_eq!(v, json!({"path": "hello.txt"})); |
| 192 | } |
| 193 | |
| 194 | #[test] |
| 195 | fn repairs_trailing_comma() { |
| 196 | let v = repair(r#"{"path": "hello.txt",}"#).unwrap(); |
| 197 | assert_eq!(v, json!({"path": "hello.txt"})); |
| 198 | } |
| 199 | |
| 200 | #[test] |
| 201 | fn repairs_trailing_comma_in_array() { |
| 202 | let v = repair(r#"["a", "b",]"#).unwrap(); |
| 203 | assert_eq!(v, json!(["a", "b"])); |
| 204 | } |
| 205 | |
| 206 | #[test] |
| 207 | fn repairs_missing_close_brace() { |
| 208 | let v = repair(r#"{"path": "hello.txt""#).unwrap(); |
| 209 | assert_eq!(v, json!({"path": "hello.txt"})); |
| 210 | } |
| 211 | |
| 212 | #[test] |
| 213 | fn repairs_missing_close_bracket() { |
| 214 | let v = repair(r#"["a", "b""#).unwrap(); |
| 215 | assert_eq!(v, json!(["a", "b"])); |
| 216 | } |
| 217 | |
| 218 | #[test] |
| 219 | fn strips_embedded_control_chars() { |
| 220 | // Raw \x0B (vertical tab) inside a string value |
| 221 | let raw = "{\"key\": \"val\x0Bue\"}"; |
| 222 | let v = repair(raw).unwrap(); |
| 223 | assert_eq!(v, json!({"key": "value"})); |
| 224 | } |
| 225 | |
| 226 | #[test] |
| 227 | fn handles_empty_string() { |
| 228 | let v = repair("").unwrap(); |
| 229 | assert_eq!(v, json!({})); |
| 230 | } |
| 231 | |
| 232 | #[test] |
| 233 | fn handles_gibberish() { |
| 234 | let v = repair("not json at all").unwrap(); |
| 235 | assert_eq!(v, json!({})); |
| 236 | } |
| 237 | |
| 238 | #[test] |
| 239 | fn balances_nested_braces() { |
| 240 | let v = repair(r#"{"outer": {"inner": "val""#).unwrap(); |
| 241 | assert_eq!(v, json!({"outer": {"inner": "val"}})); |
| 242 | } |
| 243 | |
| 244 | #[test] |
| 245 | fn strips_excess_closers() { |
| 246 | let v = repair(r#"{"key": "val"}}"#).unwrap(); |
| 247 | assert_eq!(v, json!({"key": "val"})); |
| 248 | } |
| 249 | |
| 250 | #[test] |
| 251 | fn handles_double_encoded_json() { |
| 252 | // This is a valid JSON string containing a JSON object literal. |
| 253 | // repair parses it as a string; the engine's existing fallback |
| 254 | // (parse_tool_input) will unwrap the string and re-parse. |
| 255 | let v = repair(r#""{\"path\": \"hello.txt\"}""#).unwrap(); |
| 256 | assert_eq!(v, Value::String(r#"{"path": "hello.txt"}"#.to_string())); |
| 257 | } |
| 258 | |
| 259 | #[test] |
| 260 | fn oversize_input_rejected() { |
| 261 | let big = "x".repeat(MAX_ARG_LEN + 1); |
| 262 | assert!(repair(&big).is_err()); |
| 263 | } |
| 264 | |
| 265 | #[test] |
| 266 | fn repairs_brace_balance_with_trailing_comma() { |
| 267 | let v = repair(r#"{"a": 1,"#).unwrap(); |
| 268 | assert_eq!(v, json!({"a": 1})); |
| 269 | } |
| 270 | } |
| 271 |