Skip to main content

gglib_core/normalize/
coerce.rs

1//! Deterministic local repair of a tool-call body the parsers rejected.
2//!
3//! The cheapest rung of the escalation ladder, and the only one that costs no
4//! model call at all. When a model emits a tool call wrapped in a code fence,
5//! trailed by prose, or carrying a trailing comma, the payload is *right* and
6//! the packaging is wrong. Today that turn is thrown away and the raw bytes
7//! are shown to the person as text. Repairing the packaging is measured in
8//! microseconds and cannot cost a generation.
9//!
10//! # Only ever additive
11//!
12//! [`coerce_json_object`] returns `Some` only when its output parses. Every
13//! failure path returns `None` and the caller behaves exactly as it did
14//! before. A turn can therefore be rescued by this module but never made
15//! worse by it — the same fail-open rule the repair re-issue follows.
16//!
17//! # The one repair deliberately not attempted
18//!
19//! **An unterminated string is never closed.** Given
20//! `{"name":"read_file","arguments":{"path":"/etc/ho` the structurally
21//! obvious fix is to add `"}}`, which yields valid JSON and a tool call that
22//! reads the wrong file. Dispatching a plausible-but-wrong call is worse than
23//! dispatching none: the client executes it, and a truncated path or query is
24//! a side effect nobody asked for. Truncation mid-string means the model's
25//! output was cut off, which is a real failure, and this module declines to
26//! paper over it.
27//!
28//! Structural delimiters are different in kind. A missing `}` at the very end
29//! of an otherwise complete object cannot change the meaning of any value
30//! already present; it can only fail to terminate them.
31
32use serde_json::Value;
33
34/// The most nesting a tool-call body may legitimately carry.
35///
36/// Bounds the repair against a pathological input — a model emitting
37/// thousands of `[` produces a body this refuses rather than a very long
38/// string of `]`.
39const MAX_NESTING: usize = 64;
40
41/// Try to make `body` parse as a JSON object, without changing what it says.
42///
43/// Returns the repaired text only when it parses *and* is an object; `None`
44/// otherwise, leaving the caller to fail exactly as it would have.
45#[must_use]
46pub(crate) fn coerce_json_object(body: &str) -> Option<String> {
47    let candidate = strip_packaging(body);
48    if candidate.is_empty() {
49        return None;
50    }
51
52    // Cheap path: the packaging was the whole problem.
53    if parses_as_object(&candidate) {
54        return Some(candidate);
55    }
56
57    let without_commas = drop_trailing_commas(&candidate);
58    if parses_as_object(&without_commas) {
59        return Some(without_commas);
60    }
61
62    let closed = close_delimiters(&without_commas)?;
63    parses_as_object(&closed).then_some(closed)
64}
65
66fn parses_as_object(text: &str) -> bool {
67    serde_json::from_str::<Value>(text).is_ok_and(|v| v.is_object())
68}
69
70/// Remove a surrounding code fence and any prose either side of the object.
71///
72/// Models introduce a tool call conversationally ("Sure — I'll read it:") or
73/// wrap it in markdown out of habit. Both leave the JSON itself intact.
74fn strip_packaging(body: &str) -> String {
75    let mut text = body.trim();
76
77    // ```json … ``` or ``` … ```
78    if let Some(rest) = text.strip_prefix("```") {
79        let rest = rest.strip_prefix("json").unwrap_or(rest);
80        text = rest.trim_start_matches(['\r', '\n']).trim();
81        if let Some(stripped) = text.strip_suffix("```") {
82            text = stripped.trim();
83        }
84    }
85
86    // Prose either side. Anchored on the outermost braces rather than the
87    // first, so a sentence containing `{` does not truncate the object.
88    match (text.find('{'), text.rfind('}')) {
89        (Some(start), Some(end)) if end > start => text[start..=end].trim().to_owned(),
90        // No closing brace at all: keep everything from the opening one, so
91        // `close_delimiters` still gets a chance.
92        (Some(start), _) => text[start..].trim().to_owned(),
93        _ => String::new(),
94    }
95}
96
97/// Drop commas that sit immediately before a closing delimiter.
98///
99/// Skips anything inside a string, so a value like `"a, }"` is untouched.
100fn drop_trailing_commas(text: &str) -> String {
101    let mut out = String::with_capacity(text.len());
102    let mut pending_comma: Option<usize> = None;
103    let mut scan = StringScan::default();
104
105    for ch in text.chars() {
106        if scan.step(ch) {
107            // Inside a string (or its escape): copy verbatim.
108            if let Some(idx) = pending_comma.take() {
109                out.insert(idx, ',');
110            }
111            out.push(ch);
112            continue;
113        }
114        match ch {
115            ',' => {
116                if let Some(idx) = pending_comma.take() {
117                    out.insert(idx, ',');
118                }
119                pending_comma = Some(out.len());
120            }
121            '}' | ']' => {
122                pending_comma = None;
123                out.push(ch);
124            }
125            c if c.is_whitespace() => out.push(c),
126            c => {
127                if let Some(idx) = pending_comma.take() {
128                    out.insert(idx, ',');
129                }
130                out.push(c);
131            }
132        }
133    }
134    if let Some(idx) = pending_comma {
135        out.insert(idx, ',');
136    }
137    out
138}
139
140/// Append whatever closing delimiters the body is missing.
141///
142/// Returns `None` when the text ends inside a string, when a delimiter is
143/// mismatched, or when nesting exceeds [`MAX_NESTING`] — see the module doc
144/// on why an unterminated string is left alone.
145fn close_delimiters(text: &str) -> Option<String> {
146    let mut stack: Vec<char> = Vec::new();
147    let mut scan = StringScan::default();
148
149    for ch in text.chars() {
150        if scan.step(ch) {
151            continue;
152        }
153        match ch {
154            '{' | '[' => {
155                if stack.len() >= MAX_NESTING {
156                    return None;
157                }
158                stack.push(ch);
159            }
160            '}' if stack.pop() != Some('{') => return None,
161            ']' if stack.pop() != Some('[') => return None,
162            _ => {}
163        }
164    }
165
166    // Cut off mid-string: refuse, rather than invent the rest of a value.
167    if scan.in_string {
168        return None;
169    }
170    if stack.is_empty() {
171        return None; // Nothing to close; the failure is something else.
172    }
173
174    let mut out = text.trim_end().to_owned();
175    while let Some(open) = stack.pop() {
176        out.push(if open == '{' { '}' } else { ']' });
177    }
178    Some(out)
179}
180
181/// Tracks whether the scan is inside a JSON string literal.
182#[derive(Default)]
183struct StringScan {
184    in_string: bool,
185    escaped: bool,
186}
187
188impl StringScan {
189    /// Feed one character; returns `true` if it belongs to a string literal
190    /// (and so must not be read as structure).
191    const fn step(&mut self, ch: char) -> bool {
192        if self.in_string {
193            if self.escaped {
194                self.escaped = false;
195            } else if ch == '\\' {
196                self.escaped = true;
197            } else if ch == '"' {
198                self.in_string = false;
199                return true; // the closing quote is still string punctuation
200            }
201            return true;
202        }
203        if ch == '"' {
204            self.in_string = true;
205            return true;
206        }
207        false
208    }
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214
215    const GOOD: &str = r#"{"name":"read_file","arguments":{"path":"a"}}"#;
216
217    #[test]
218    fn a_code_fence_is_unwrapped() {
219        let fenced = format!("```json\n{GOOD}\n```");
220        assert_eq!(coerce_json_object(&fenced).as_deref(), Some(GOOD));
221    }
222
223    #[test]
224    fn prose_either_side_is_dropped() {
225        let chatty = format!("Sure, I'll read it:\n{GOOD}\nLet me know!");
226        assert_eq!(coerce_json_object(&chatty).as_deref(), Some(GOOD));
227    }
228
229    #[test]
230    fn a_trailing_comma_is_removed() {
231        let sloppy = r#"{"name":"read_file","arguments":{"path":"a"},}"#;
232        assert!(coerce_json_object(sloppy).is_some());
233    }
234
235    #[test]
236    fn missing_closing_braces_are_appended() {
237        let cut = r#"{"name":"read_file","arguments":{"path":"a"}"#;
238        assert_eq!(coerce_json_object(cut).as_deref(), Some(GOOD));
239    }
240
241    /// The safety rule this module exists to respect. Closing the string
242    /// would yield valid JSON and a call that reads the wrong file.
243    #[test]
244    fn an_unterminated_string_is_never_completed() {
245        let truncated = r#"{"name":"read_file","arguments":{"path":"/etc/ho"#;
246        assert_eq!(coerce_json_object(truncated), None);
247    }
248
249    #[test]
250    fn a_comma_inside_a_string_survives() {
251        let body = r#"{"name":"say","arguments":{"text":"a, b, }"}}"#;
252        let out = coerce_json_object(body).expect("already valid");
253        assert!(out.contains("a, b, }"), "string content altered: {out}");
254    }
255
256    #[test]
257    fn mismatched_delimiters_are_refused() {
258        assert_eq!(coerce_json_object(r#"{"name":"x","args":[}"#), None);
259    }
260
261    #[test]
262    fn a_non_object_is_refused() {
263        assert_eq!(coerce_json_object("[1, 2, 3]"), None);
264        assert_eq!(coerce_json_object("\"just a string\""), None);
265    }
266
267    #[test]
268    fn pathological_nesting_is_refused() {
269        let deep = "[".repeat(MAX_NESTING + 5);
270        assert_eq!(coerce_json_object(&deep), None);
271    }
272
273    #[test]
274    fn text_with_no_object_at_all_is_refused() {
275        assert_eq!(coerce_json_object("I could not do that."), None);
276        assert_eq!(coerce_json_object(""), None);
277    }
278
279    /// Valid input must survive untouched — the repair never rewrites a body
280    /// that was already fine.
281    #[test]
282    fn an_already_valid_body_is_returned_unchanged() {
283        assert_eq!(coerce_json_object(GOOD).as_deref(), Some(GOOD));
284    }
285}