返回 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 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
271 lines RUST