返回 CodeWhale
arg_repair.rs
根目录 / crates / tui / src / tools / arg_repair.rs
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 //! Stages 1-3 never change structure: they parse as-is, or normalize text
17 //! that was already structurally complete. Stages 4-5 do — they synthesize
18 //! or discard closers to force a parse. A value that only parsed because of
19 //! stage 4 or 5 came from argument text that was *incomplete*, and the usual
20 //! cause is a provider cutting the stream at its output limit mid-argument.
21 //! `Repaired::structure_synthesized` reports that, because such a value must
22 //! never be dispatched as if the model had finished writing it.
23
24 use serde_json::Value;
25
26 /// Maximum raw argument length we'll attempt to repair (1 MiB).
27 const MAX_ARG_LEN: usize = 1024 * 1024;
28
29 #[derive(Debug, thiserror::Error)]
30 pub enum ArgRepairError {
31 #[error("argument exceeded {0} chars; refusing to repair")]
32 TooLarge(usize),
33 #[error("argument could not be repaired into valid JSON")]
34 Unrepairable,
35 }
36
37 /// Repair a raw JSON argument string into a valid `serde_json::Value`.
38 ///
39 /// Runs the deterministic ladder; on success returns the parsed value.
40 /// A repaired value plus whether the repair had to invent structure.
41 #[derive(Debug, Clone)]
42 pub struct Repaired {
43 pub value: Value,
44 /// True when the text only parsed after closers were appended (stage 4)
45 /// or discarded (stage 5) — i.e. the argument text was structurally
46 /// incomplete. Callers making the *final* dispatch decision must treat
47 /// this as malformed input rather than executing it.
48 pub structure_synthesized: bool,
49 }
50
51 impl Repaired {
52 fn intact(value: Value) -> Self {
53 Self {
54 value,
55 structure_synthesized: false,
56 }
57 }
58 fn synthesized(value: Value) -> Self {
59 Self {
60 value,
61 structure_synthesized: true,
62 }
63 }
64 }
65
66 pub fn repair(raw: &str) -> Result<Repaired, ArgRepairError> {
67 // Stage 1: strict parse. Valid JSON is returned intact at any size: the
68 // size bound exists to cap the cost of the repair stages below, and a
69 // strict serde parse is what callers already fall back to on oversize
70 // input. Checking size first made every valid argument over the bound
71 // (a large `write`, a generated fixture) fail as malformed.
72 if let Ok(v) = serde_json::from_str(raw) {
73 return Ok(Repaired::intact(v));
74 }
75 if raw.len() > MAX_ARG_LEN {
76 return Err(ArgRepairError::TooLarge(raw.len()));
77 }
78 // Stage 2: strip control chars inside strings
79 let mut s = strip_control_chars_in_strings(raw);
80 if let Ok(v) = serde_json::from_str(&s) {
81 return Ok(Repaired::intact(v));
82 }
83 // Stage 3: strip trailing commas
84 s = strip_trailing_commas(&s);
85 if let Ok(v) = serde_json::from_str(&s) {
86 return Ok(Repaired::intact(v));
87 }
88 // Stage 4: balance braces
89 // Stages 4 and 5 change structure. Anything they rescue is reported as
90 // synthesized: `balance_braces` counts braces without tracking string
91 // literals, so a stream cut at the end of a complete string value yields
92 // JSON that parses cleanly and is still missing whatever the model had
93 // not written yet.
94 s = balance_braces(&s, 50);
95 if let Ok(v) = serde_json::from_str(&s) {
96 return Ok(Repaired::synthesized(v));
97 }
98 // Stage 5: strip excess closers
99 s = strip_excess_closers(&s);
100 if let Ok(v) = serde_json::from_str(&s) {
101 return Ok(Repaired::synthesized(v));
102 }
103 Err(ArgRepairError::Unrepairable)
104 }
105
106 /// Strip ASCII control characters (0x00–0x1F except \t, \n, \r) that appear
107 /// inside JSON string values. We walk character-by-character tracking whether
108 /// we're inside a string (between unescaped double-quotes).
109 fn strip_control_chars_in_strings(s: &str) -> String {
110 let mut out = String::with_capacity(s.len());
111 let mut in_string = false;
112 let mut escape = false;
113 for ch in s.chars() {
114 if escape {
115 out.push(ch);
116 escape = false;
117 continue;
118 }
119 if ch == '\\' {
120 escape = true;
121 out.push(ch);
122 continue;
123 }
124 if ch == '"' {
125 in_string = !in_string;
126 out.push(ch);
127 continue;
128 }
129 if in_string && (ch as u32) < 0x20 && ch != '\t' && ch != '\n' && ch != '\r' {
130 // Drop control characters inside strings
131 continue;
132 }
133 out.push(ch);
134 }
135 out
136 }
137
138 /// Strip trailing commas before `}` or `]` (optionally across whitespace)
139 /// and at end of input. String-aware: a `,}` or `,]` inside a string value
140 /// is content, e.g. source code in a `write` call, and is left untouched.
141 ///
142 /// Single pass: a run of commas and whitespace outside a string is held
143 /// back until the next significant character decides it, so a long run of
144 /// commas (a model repetition loop, re-repaired on every streamed delta)
145 /// costs linear time rather than a rescan per comma.
146 fn strip_trailing_commas(s: &str) -> String {
147 let mut out = String::with_capacity(s.len());
148 // Held-back commas and whitespace, and whether it holds any comma.
149 let mut held = String::new();
150 let mut held_comma = false;
151 let mut in_string = false;
152 let mut escape = false;
153 for ch in s.chars() {
154 if in_string {
155 if escape {
156 escape = false;
157 } else if ch == '\\' {
158 escape = true;
159 } else if ch == '"' {
160 in_string = false;
161 }
162 out.push(ch);
163 continue;
164 }
165 if ch == ',' {
166 held.push(ch);
167 held_comma = true;
168 continue;
169 }
170 if held_comma && ch.is_whitespace() {
171 held.push(ch);
172 continue;
173 }
174 if held_comma {
175 flush_held_commas(&mut out, &held, matches!(ch, '}' | ']'));
176 held.clear();
177 held_comma = false;
178 }
179 if ch == '"' {
180 in_string = true;
181 }
182 out.push(ch);
183 }
184 if held_comma {
185 flush_held_commas(&mut out, &held, true);
186 }
187 out
188 }
189
190 /// Emit a held comma/whitespace run: dropping its commas when it trails
191 /// before a closer or the end of input, keeping it verbatim otherwise.
192 fn flush_held_commas(out: &mut String, held: &str, drop_commas: bool) {
193 if drop_commas {
194 out.extend(held.chars().filter(|c| *c != ','));
195 } else {
196 out.push_str(held);
197 }
198 }
199
200 /// Balance braces and brackets: count `{`/`}` and `[`/`]`, append closers if
201 /// positive delta (more opens than closes). Caps iterations so a
202 /// catastrophically broken input doesn't loop forever.
203 fn balance_braces(s: &str, max_iter: usize) -> String {
204 let mut out = s.to_string();
205 for _ in 0..max_iter {
206 let brace_delta: i32 = out
207 .chars()
208 .map(|ch| match ch {
209 '{' => 1,
210 '}' => -1,
211 _ => 0,
212 })
213 .sum();
214 let bracket_delta: i32 = out
215 .chars()
216 .map(|ch| match ch {
217 '[' => 1,
218 ']' => -1,
219 _ => 0,
220 })
221 .sum();
222 if brace_delta <= 0 && bracket_delta <= 0 {
223 break;
224 }
225 // Append needed closers in reverse order (brackets before braces
226 // for correct nesting when both are unbalanced).
227 for _ in 0..bracket_delta.max(0) {
228 out.push(']');
229 }
230 for _ in 0..brace_delta.max(0) {
231 out.push('}');
232 }
233 }
234 out
235 }
236
237 /// Strip excess closers when the delta is negative (more closes than opens).
238 fn strip_excess_closers(s: &str) -> String {
239 let mut brace_depth: i32 = 0;
240 let mut bracket_depth: i32 = 0;
241 let mut out = String::with_capacity(s.len());
242 for ch in s.chars() {
243 match ch {
244 '}' => {
245 if brace_depth > 0 {
246 brace_depth -= 1;
247 out.push(ch);
248 }
249 // else drop excess closer
250 }
251 ']' => {
252 if bracket_depth > 0 {
253 bracket_depth -= 1;
254 out.push(ch);
255 }
256 }
257 '{' => {
258 brace_depth += 1;
259 out.push(ch);
260 }
261 '[' => {
262 bracket_depth += 1;
263 out.push(ch);
264 }
265 _ => out.push(ch),
266 }
267 }
268 out
269 }
270
271 #[cfg(test)]
272 mod tests {
273 use super::*;
274 use serde_json::json;
275
276 #[test]
277 fn strict_parse_passes_through() {
278 let r = repair(r#"{"path": "hello.txt"}"#).unwrap();
279 assert_eq!(r.value, json!({"path": "hello.txt"}));
280 assert!(!r.structure_synthesized);
281 }
282
283 #[test]
284 fn repairs_trailing_comma() {
285 let r = repair(r#"{"path": "hello.txt",}"#).unwrap();
286 assert_eq!(r.value, json!({"path": "hello.txt"}));
287 // Structurally complete, just sloppy — must stay dispatchable.
288 assert!(!r.structure_synthesized);
289 }
290
291 #[test]
292 fn repairs_trailing_comma_in_array() {
293 let r = repair(r#"["a", "b",]"#).unwrap();
294 assert_eq!(r.value, json!(["a", "b"]));
295 assert!(!r.structure_synthesized);
296 }
297
298 #[test]
299 fn repairs_missing_close_brace() {
300 let r = repair(r#"{"path": "hello.txt""#).unwrap();
301 assert_eq!(r.value, json!({"path": "hello.txt"}));
302 assert!(r.structure_synthesized);
303 }
304
305 #[test]
306 fn repairs_missing_close_bracket() {
307 let r = repair(r#"["a", "b""#).unwrap();
308 assert_eq!(r.value, json!(["a", "b"]));
309 assert!(r.structure_synthesized);
310 }
311
312 #[test]
313 fn strips_embedded_control_chars() {
314 // Raw \x0B (vertical tab) inside a string value
315 let raw = "{\"key\": \"val\x0Bue\"}";
316 let v = repair(raw).unwrap();
317 assert_eq!(v.value, json!({"key": "value"}));
318 assert!(!v.structure_synthesized);
319 }
320
321 #[test]
322 fn rejects_empty_string() {
323 assert!(matches!(repair(""), Err(ArgRepairError::Unrepairable)));
324 }
325
326 #[test]
327 fn rejects_gibberish() {
328 assert!(matches!(
329 repair("not json at all"),
330 Err(ArgRepairError::Unrepairable)
331 ));
332 }
333
334 #[test]
335 fn balances_nested_braces() {
336 let r = repair(r#"{"outer": {"inner": "val""#).unwrap();
337 assert_eq!(r.value, json!({"outer": {"inner": "val"}}));
338 // Closers were appended, so this is a truncated argument, not a
339 // complete one that merely needed tidying.
340 assert!(r.structure_synthesized);
341 }
342
343 #[test]
344 fn strips_excess_closers() {
345 let r = repair(r#"{"key": "val"}}"#).unwrap();
346 assert_eq!(r.value, json!({"key": "val"}));
347 assert!(r.structure_synthesized);
348 }
349
350 #[test]
351 fn handles_double_encoded_json() {
352 // This is a valid JSON string containing a JSON object literal.
353 // repair parses it as a string; the engine's existing fallback
354 // (parse_tool_input) will unwrap the string and re-parse.
355 let r = repair(r#""{\"path\": \"hello.txt\"}""#).unwrap();
356 assert_eq!(
357 r.value,
358 Value::String(r#"{"path": "hello.txt"}"#.to_string())
359 );
360 assert!(!r.structure_synthesized);
361 }
362
363 #[test]
364 fn oversize_input_rejected() {
365 let big = "x".repeat(MAX_ARG_LEN + 1);
366 assert!(repair(&big).is_err());
367 }
368
369 #[test]
370 fn oversize_valid_json_parses_intact() {
371 // A valid argument over the repair bound whose string content holds
372 // an unbalanced brace must come back exactly as written, not be
373 // refused as too large or "repaired".
374 let content = format!("fn main() {{{}", "x".repeat(MAX_ARG_LEN));
375 let raw = serde_json::json!({"path": "big.rs", "content": content}).to_string();
376 assert!(raw.len() > MAX_ARG_LEN);
377 let r = repair(&raw).expect("valid oversize JSON parses");
378 assert!(!r.structure_synthesized);
379 assert_eq!(r.value["content"].as_str(), Some(content.as_str()));
380
381 // Oversize text that is not valid JSON is still refused before any
382 // repair stage runs.
383 let truncated = &raw[..raw.len() - 2];
384 assert!(matches!(
385 repair(truncated),
386 Err(ArgRepairError::TooLarge(_))
387 ));
388 }
389
390 #[test]
391 fn a_write_cut_at_a_string_boundary_is_reported_as_synthesized() {
392 // The defect this flag exists for: `balance_braces` counts braces
393 // without tracking string literals, so a provider that cuts the
394 // stream at its output limit right after a complete string value
395 // yields text that parses cleanly once one `}` is appended. Nothing
396 // downstream could previously tell this from a finished argument, so
397 // the truncated `content` was written to the user's file.
398 let cut = r#"{"path": "notes.md", "content": "first line""#;
399 let r = repair(cut).unwrap();
400 assert_eq!(
401 r.value,
402 json!({"path": "notes.md", "content": "first line"}),
403 "the ladder still parses it — that is exactly why the flag is needed"
404 );
405 assert!(
406 r.structure_synthesized,
407 "a truncated write must be reported as synthesized so dispatch refuses it"
408 );
409 }
410
411 #[test]
412 fn a_complete_argument_needing_only_control_char_stripping_stays_intact() {
413 // Stage 2 normalizes text that was already structurally complete, so
414 // it must NOT be flagged — otherwise every DeepSeek chunk-boundary
415 // repair would start failing tool calls that are perfectly fine.
416 let r = repair("{\"a\": \"line\u{0008}break\"}").unwrap();
417 assert_eq!(r.value, json!({"a": "linebreak"}));
418 assert!(!r.structure_synthesized);
419 }
420
421 #[test]
422 fn repairs_brace_balance_with_trailing_comma() {
423 let r = repair(r#"{"a": 1,"#).unwrap();
424 assert_eq!(r.value, json!({"a": 1}));
425 assert!(r.structure_synthesized);
426 }
427
428 #[test]
429 fn trailing_comma_repair_leaves_string_content_alone() {
430 // Object-level trailing comma forces the repair path; the `,}` and
431 // `,]` inside `content` are the model's code and must survive.
432 let raw = r#"{"path":"a.js","content":"const o = {a:1,};\nlet v = [1,2,];\n",}"#;
433 let r = repair(raw).unwrap();
434 assert_eq!(
435 r.value,
436 json!({"path": "a.js", "content": "const o = {a:1,};\nlet v = [1,2,];\n"})
437 );
438 assert!(!r.structure_synthesized);
439 }
440
441 #[test]
442 fn a_long_comma_run_is_repaired_in_linear_time() {
443 // A repetition loop of commas outside any string must not cost a
444 // rescan per comma: repair runs on every streamed partial buffer.
445 let mut raw = String::from(r#"{"a":[1"#);
446 for _ in 0..200_000 {
447 raw.push_str(", ");
448 }
449 raw.push_str("]}");
450 let (tx, rx) = std::sync::mpsc::channel();
451 std::thread::spawn(move || {
452 let _ = tx.send(repair(&raw).map(|r| r.value));
453 });
454 let repaired = rx
455 .recv_timeout(std::time::Duration::from_secs(5))
456 .expect("trailing-comma repair must finish in linear time");
457 assert_eq!(repaired.unwrap(), json!({"a": [1]}));
458 }
459
460 #[test]
461 fn interior_comma_runs_are_left_for_the_parser_to_reject() {
462 // Only commas that trail before a closer are removed; a doubled
463 // separator between values is not invented away.
464 assert!(repair(r#"{"a":[1,,2]}"#).is_err());
465 let r = repair("{\"a\":[1 , ,\n]}").unwrap();
466 assert_eq!(r.value, json!({"a": [1]}));
467 assert!(!r.structure_synthesized);
468 }
469
470 #[test]
471 fn trailing_comma_before_whitespace_and_closer_is_stripped() {
472 let r = repair("{\"a\": [1, 2 ,\n ],\n}").unwrap();
473 assert_eq!(r.value, json!({"a": [1, 2]}));
474 assert!(!r.structure_synthesized);
475 }
476 }
477
477 lines RUST