| 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 reporting unrecoverable input: |
| 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 | |
| 16 | use serde_json::Value; |
| 17 | |
| 18 | /// Maximum raw argument length we'll attempt to repair (1 MiB). |
| 19 | const MAX_ARG_LEN: usize = 1024 * 1024; |
| 20 | |
| 21 | #[derive(Debug, thiserror::Error)] |
| 22 | pub enum ArgRepairError { |
| 23 | #[error("argument exceeded {0} chars; refusing to repair")] |
| 24 | TooLarge(usize), |
| 25 | #[error("argument could not be repaired into valid JSON")] |
| 26 | Unrepairable, |
| 27 | } |
| 28 | |
| 29 | /// Repair a raw JSON argument string into a valid `serde_json::Value`. |
| 30 | /// |
| 31 | /// Runs the deterministic ladder; on success returns the parsed value. |
| 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 | Err(ArgRepairError::Unrepairable) |
| 61 | } |
| 62 | |
| 63 | /// Strip ASCII control characters (0x00–0x1F except \t, \n, \r) that appear |
| 64 | /// inside JSON string values. We walk character-by-character tracking whether |
| 65 | /// we're inside a string (between unescaped double-quotes). |
| 66 | fn strip_control_chars_in_strings(s: &str) -> String { |
| 67 | let mut out = String::with_capacity(s.len()); |
| 68 | let mut in_string = false; |
| 69 | let mut escape = false; |
| 70 | for ch in s.chars() { |
| 71 | if escape { |
| 72 | out.push(ch); |
| 73 | escape = false; |
| 74 | continue; |
| 75 | } |
| 76 | if ch == '\\' { |
| 77 | escape = true; |
| 78 | out.push(ch); |
| 79 | continue; |
| 80 | } |
| 81 | if ch == '"' { |
| 82 | in_string = !in_string; |
| 83 | out.push(ch); |
| 84 | continue; |
| 85 | } |
| 86 | if in_string && (ch as u32) < 0x20 && ch != '\t' && ch != '\n' && ch != '\r' { |
| 87 | // Drop control characters inside strings |
| 88 | continue; |
| 89 | } |
| 90 | out.push(ch); |
| 91 | } |
| 92 | out |
| 93 | } |
| 94 | |
| 95 | /// Strip trailing commas before `}` or `]`. |
| 96 | fn strip_trailing_commas(s: &str) -> String { |
| 97 | // Repeatedly replace ",}" and ",]" until stable (handles nested cases). |
| 98 | let mut out = s.to_string(); |
| 99 | loop { |
| 100 | let prev = out.clone(); |
| 101 | out = out.replace(",}", "}").replace(",]", "]"); |
| 102 | // Handle trailing comma at end of string |
| 103 | out = out.trim_end_matches(',').to_string(); |
| 104 | if out == prev { |
| 105 | break; |
| 106 | } |
| 107 | } |
| 108 | out |
| 109 | } |
| 110 | |
| 111 | /// Balance braces and brackets: count `{`/`}` and `[`/`]`, append closers if |
| 112 | /// positive delta (more opens than closes). Caps iterations so a |
| 113 | /// catastrophically broken input doesn't loop forever. |
| 114 | fn balance_braces(s: &str, max_iter: usize) -> String { |
| 115 | let mut out = s.to_string(); |
| 116 | for _ in 0..max_iter { |
| 117 | let brace_delta: i32 = out |
| 118 | .chars() |
| 119 | .map(|ch| match ch { |
| 120 | '{' => 1, |
| 121 | '}' => -1, |
| 122 | _ => 0, |
| 123 | }) |
| 124 | .sum(); |
| 125 | let bracket_delta: i32 = out |
| 126 | .chars() |
| 127 | .map(|ch| match ch { |
| 128 | '[' => 1, |
| 129 | ']' => -1, |
| 130 | _ => 0, |
| 131 | }) |
| 132 | .sum(); |
| 133 | if brace_delta <= 0 && bracket_delta <= 0 { |
| 134 | break; |
| 135 | } |
| 136 | // Append needed closers in reverse order (brackets before braces |
| 137 | // for correct nesting when both are unbalanced). |
| 138 | for _ in 0..bracket_delta.max(0) { |
| 139 | out.push(']'); |
| 140 | } |
| 141 | for _ in 0..brace_delta.max(0) { |
| 142 | out.push('}'); |
| 143 | } |
| 144 | } |
| 145 | out |
| 146 | } |
| 147 | |
| 148 | /// Strip excess closers when the delta is negative (more closes than opens). |
| 149 | fn strip_excess_closers(s: &str) -> String { |
| 150 | let mut brace_depth: i32 = 0; |
| 151 | let mut bracket_depth: i32 = 0; |
| 152 | let mut out = String::with_capacity(s.len()); |
| 153 | for ch in s.chars() { |
| 154 | match ch { |
| 155 | '}' => { |
| 156 | if brace_depth > 0 { |
| 157 | brace_depth -= 1; |
| 158 | out.push(ch); |
| 159 | } |
| 160 | // else drop excess closer |
| 161 | } |
| 162 | ']' => { |
| 163 | if bracket_depth > 0 { |
| 164 | bracket_depth -= 1; |
| 165 | out.push(ch); |
| 166 | } |
| 167 | } |
| 168 | '{' => { |
| 169 | brace_depth += 1; |
| 170 | out.push(ch); |
| 171 | } |
| 172 | '[' => { |
| 173 | bracket_depth += 1; |
| 174 | out.push(ch); |
| 175 | } |
| 176 | _ => out.push(ch), |
| 177 | } |
| 178 | } |
| 179 | out |
| 180 | } |
| 181 | |
| 182 | #[cfg(test)] |
| 183 | mod tests { |
| 184 | use super::*; |
| 185 | use serde_json::json; |
| 186 | |
| 187 | #[test] |
| 188 | fn strict_parse_passes_through() { |
| 189 | let v = repair(r#"{"path": "hello.txt"}"#).unwrap(); |
| 190 | assert_eq!(v, json!({"path": "hello.txt"})); |
| 191 | } |
| 192 | |
| 193 | #[test] |
| 194 | fn repairs_trailing_comma() { |
| 195 | let v = repair(r#"{"path": "hello.txt",}"#).unwrap(); |
| 196 | assert_eq!(v, json!({"path": "hello.txt"})); |
| 197 | } |
| 198 | |
| 199 | #[test] |
| 200 | fn repairs_trailing_comma_in_array() { |
| 201 | let v = repair(r#"["a", "b",]"#).unwrap(); |
| 202 | assert_eq!(v, json!(["a", "b"])); |
| 203 | } |
| 204 | |
| 205 | #[test] |
| 206 | fn repairs_missing_close_brace() { |
| 207 | let v = repair(r#"{"path": "hello.txt""#).unwrap(); |
| 208 | assert_eq!(v, json!({"path": "hello.txt"})); |
| 209 | } |
| 210 | |
| 211 | #[test] |
| 212 | fn repairs_missing_close_bracket() { |
| 213 | let v = repair(r#"["a", "b""#).unwrap(); |
| 214 | assert_eq!(v, json!(["a", "b"])); |
| 215 | } |
| 216 | |
| 217 | #[test] |
| 218 | fn strips_embedded_control_chars() { |
| 219 | // Raw \x0B (vertical tab) inside a string value |
| 220 | let raw = "{\"key\": \"val\x0Bue\"}"; |
| 221 | let v = repair(raw).unwrap(); |
| 222 | assert_eq!(v, json!({"key": "value"})); |
| 223 | } |
| 224 | |
| 225 | #[test] |
| 226 | fn rejects_empty_string() { |
| 227 | assert!(matches!(repair(""), Err(ArgRepairError::Unrepairable))); |
| 228 | } |
| 229 | |
| 230 | #[test] |
| 231 | fn rejects_gibberish() { |
| 232 | assert!(matches!( |
| 233 | repair("not json at all"), |
| 234 | Err(ArgRepairError::Unrepairable) |
| 235 | )); |
| 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 |