返回 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 if raw.len() > MAX_ARG_LEN {
68 return Err(ArgRepairError::TooLarge(raw.len()));
69 }
70 // Stage 1: strict parse
71 if let Ok(v) = serde_json::from_str(raw) {
72 return Ok(Repaired::intact(v));
73 }
74 // Stage 2: strip control chars inside strings
75 let mut s = strip_control_chars_in_strings(raw);
76 if let Ok(v) = serde_json::from_str(&s) {
77 return Ok(Repaired::intact(v));
78 }
79 // Stage 3: strip trailing commas
80 s = strip_trailing_commas(&s);
81 if let Ok(v) = serde_json::from_str(&s) {
82 return Ok(Repaired::intact(v));
83 }
84 // Stage 4: balance braces
85 // Stages 4 and 5 change structure. Anything they rescue is reported as
86 // synthesized: `balance_braces` counts braces without tracking string
87 // literals, so a stream cut at the end of a complete string value yields
88 // JSON that parses cleanly and is still missing whatever the model had
89 // not written yet.
90 s = balance_braces(&s, 50);
91 if let Ok(v) = serde_json::from_str(&s) {
92 return Ok(Repaired::synthesized(v));
93 }
94 // Stage 5: strip excess closers
95 s = strip_excess_closers(&s);
96 if let Ok(v) = serde_json::from_str(&s) {
97 return Ok(Repaired::synthesized(v));
98 }
99 Err(ArgRepairError::Unrepairable)
100 }
101
102 /// Strip ASCII control characters (0x00–0x1F except \t, \n, \r) that appear
103 /// inside JSON string values. We walk character-by-character tracking whether
104 /// we're inside a string (between unescaped double-quotes).
105 fn strip_control_chars_in_strings(s: &str) -> String {
106 let mut out = String::with_capacity(s.len());
107 let mut in_string = false;
108 let mut escape = false;
109 for ch in s.chars() {
110 if escape {
111 out.push(ch);
112 escape = false;
113 continue;
114 }
115 if ch == '\\' {
116 escape = true;
117 out.push(ch);
118 continue;
119 }
120 if ch == '"' {
121 in_string = !in_string;
122 out.push(ch);
123 continue;
124 }
125 if in_string && (ch as u32) < 0x20 && ch != '\t' && ch != '\n' && ch != '\r' {
126 // Drop control characters inside strings
127 continue;
128 }
129 out.push(ch);
130 }
131 out
132 }
133
134 /// Strip trailing commas before `}` or `]`.
135 fn strip_trailing_commas(s: &str) -> String {
136 // Repeatedly replace ",}" and ",]" until stable (handles nested cases).
137 let mut out = s.to_string();
138 loop {
139 let prev = out.clone();
140 out = out.replace(",}", "}").replace(",]", "]");
141 // Handle trailing comma at end of string
142 out = out.trim_end_matches(',').to_string();
143 if out == prev {
144 break;
145 }
146 }
147 out
148 }
149
150 /// Balance braces and brackets: count `{`/`}` and `[`/`]`, append closers if
151 /// positive delta (more opens than closes). Caps iterations so a
152 /// catastrophically broken input doesn't loop forever.
153 fn balance_braces(s: &str, max_iter: usize) -> String {
154 let mut out = s.to_string();
155 for _ in 0..max_iter {
156 let brace_delta: i32 = out
157 .chars()
158 .map(|ch| match ch {
159 '{' => 1,
160 '}' => -1,
161 _ => 0,
162 })
163 .sum();
164 let bracket_delta: i32 = out
165 .chars()
166 .map(|ch| match ch {
167 '[' => 1,
168 ']' => -1,
169 _ => 0,
170 })
171 .sum();
172 if brace_delta <= 0 && bracket_delta <= 0 {
173 break;
174 }
175 // Append needed closers in reverse order (brackets before braces
176 // for correct nesting when both are unbalanced).
177 for _ in 0..bracket_delta.max(0) {
178 out.push(']');
179 }
180 for _ in 0..brace_delta.max(0) {
181 out.push('}');
182 }
183 }
184 out
185 }
186
187 /// Strip excess closers when the delta is negative (more closes than opens).
188 fn strip_excess_closers(s: &str) -> String {
189 let mut brace_depth: i32 = 0;
190 let mut bracket_depth: i32 = 0;
191 let mut out = String::with_capacity(s.len());
192 for ch in s.chars() {
193 match ch {
194 '}' => {
195 if brace_depth > 0 {
196 brace_depth -= 1;
197 out.push(ch);
198 }
199 // else drop excess closer
200 }
201 ']' => {
202 if bracket_depth > 0 {
203 bracket_depth -= 1;
204 out.push(ch);
205 }
206 }
207 '{' => {
208 brace_depth += 1;
209 out.push(ch);
210 }
211 '[' => {
212 bracket_depth += 1;
213 out.push(ch);
214 }
215 _ => out.push(ch),
216 }
217 }
218 out
219 }
220
221 #[cfg(test)]
222 mod tests {
223 use super::*;
224 use serde_json::json;
225
226 #[test]
227 fn strict_parse_passes_through() {
228 let r = repair(r#"{"path": "hello.txt"}"#).unwrap();
229 assert_eq!(r.value, json!({"path": "hello.txt"}));
230 assert!(!r.structure_synthesized);
231 }
232
233 #[test]
234 fn repairs_trailing_comma() {
235 let r = repair(r#"{"path": "hello.txt",}"#).unwrap();
236 assert_eq!(r.value, json!({"path": "hello.txt"}));
237 // Structurally complete, just sloppy — must stay dispatchable.
238 assert!(!r.structure_synthesized);
239 }
240
241 #[test]
242 fn repairs_trailing_comma_in_array() {
243 let r = repair(r#"["a", "b",]"#).unwrap();
244 assert_eq!(r.value, json!(["a", "b"]));
245 assert!(!r.structure_synthesized);
246 }
247
248 #[test]
249 fn repairs_missing_close_brace() {
250 let r = repair(r#"{"path": "hello.txt""#).unwrap();
251 assert_eq!(r.value, json!({"path": "hello.txt"}));
252 assert!(r.structure_synthesized);
253 }
254
255 #[test]
256 fn repairs_missing_close_bracket() {
257 let r = repair(r#"["a", "b""#).unwrap();
258 assert_eq!(r.value, json!(["a", "b"]));
259 assert!(r.structure_synthesized);
260 }
261
262 #[test]
263 fn strips_embedded_control_chars() {
264 // Raw \x0B (vertical tab) inside a string value
265 let raw = "{\"key\": \"val\x0Bue\"}";
266 let v = repair(raw).unwrap();
267 assert_eq!(v.value, json!({"key": "value"}));
268 assert!(!v.structure_synthesized);
269 }
270
271 #[test]
272 fn rejects_empty_string() {
273 assert!(matches!(repair(""), Err(ArgRepairError::Unrepairable)));
274 }
275
276 #[test]
277 fn rejects_gibberish() {
278 assert!(matches!(
279 repair("not json at all"),
280 Err(ArgRepairError::Unrepairable)
281 ));
282 }
283
284 #[test]
285 fn balances_nested_braces() {
286 let r = repair(r#"{"outer": {"inner": "val""#).unwrap();
287 assert_eq!(r.value, json!({"outer": {"inner": "val"}}));
288 // Closers were appended, so this is a truncated argument, not a
289 // complete one that merely needed tidying.
290 assert!(r.structure_synthesized);
291 }
292
293 #[test]
294 fn strips_excess_closers() {
295 let r = repair(r#"{"key": "val"}}"#).unwrap();
296 assert_eq!(r.value, json!({"key": "val"}));
297 assert!(r.structure_synthesized);
298 }
299
300 #[test]
301 fn handles_double_encoded_json() {
302 // This is a valid JSON string containing a JSON object literal.
303 // repair parses it as a string; the engine's existing fallback
304 // (parse_tool_input) will unwrap the string and re-parse.
305 let r = repair(r#""{\"path\": \"hello.txt\"}""#).unwrap();
306 assert_eq!(
307 r.value,
308 Value::String(r#"{"path": "hello.txt"}"#.to_string())
309 );
310 assert!(!r.structure_synthesized);
311 }
312
313 #[test]
314 fn oversize_input_rejected() {
315 let big = "x".repeat(MAX_ARG_LEN + 1);
316 assert!(repair(&big).is_err());
317 }
318
319 #[test]
320 fn a_write_cut_at_a_string_boundary_is_reported_as_synthesized() {
321 // The defect this flag exists for: `balance_braces` counts braces
322 // without tracking string literals, so a provider that cuts the
323 // stream at its output limit right after a complete string value
324 // yields text that parses cleanly once one `}` is appended. Nothing
325 // downstream could previously tell this from a finished argument, so
326 // the truncated `content` was written to the user's file.
327 let cut = r#"{"path": "notes.md", "content": "first line""#;
328 let r = repair(cut).unwrap();
329 assert_eq!(
330 r.value,
331 json!({"path": "notes.md", "content": "first line"}),
332 "the ladder still parses it — that is exactly why the flag is needed"
333 );
334 assert!(
335 r.structure_synthesized,
336 "a truncated write must be reported as synthesized so dispatch refuses it"
337 );
338 }
339
340 #[test]
341 fn a_complete_argument_needing_only_control_char_stripping_stays_intact() {
342 // Stage 2 normalizes text that was already structurally complete, so
343 // it must NOT be flagged — otherwise every DeepSeek chunk-boundary
344 // repair would start failing tool calls that are perfectly fine.
345 let r = repair("{\"a\": \"line\u{0008}break\"}").unwrap();
346 assert_eq!(r.value, json!({"a": "linebreak"}));
347 assert!(!r.structure_synthesized);
348 }
349
350 #[test]
351 fn repairs_brace_balance_with_trailing_comma() {
352 let r = repair(r#"{"a": 1,"#).unwrap();
353 assert_eq!(r.value, json!({"a": 1}));
354 assert!(r.structure_synthesized);
355 }
356 }
357
357 lines RUST