Skip to main content

harness/
tool_repair.rs

1//! Repair of malformed tool-call arguments from weak models.
2//!
3//! Two layers, applied at different points:
4//!
5//!   1. [`repair_truncated_json`] — runs where the streamed `arguments`
6//!      buffer is parsed (`consume_step_stream`); rescues JSON cut off
7//!      mid-stream (unclosed braces/strings, dangling `:` or `,`) and,
8//!      failing that, salvages whatever top-level pairs survive.
9//!   2. [`repair_tool_input_for_spec`] — runs at dispatch time per
10//!      invocation, guided by the tool's `input_schema`; fixes common
11//!      shape mistakes (`"true"`→`true`, `"30"`→`30`, bare string→array,
12//!      stringified array, `{}`→`[]`, optional `null` dropped, markdown
13//!      autolink unwrapped on path fields).
14//!
15//! Safety rule: schema-guided repairs are committed ONLY if a second
16//! validation pass comes back clean — a repair that leaves other issues
17//! standing reverts to the original input wholesale, so we never make a
18//! bad input differently-bad.
19//!
20//! Deferred (see phased-plan §8.1 A8): recovering tool calls from
21//! reasoning text, a repeated-call breaker, and flat-argument renesting.
22
23use serde_json::{Map, Value};
24
25// ───────────────────────── truncated-JSON repair ─────────────────────────
26
27/// Outcome of [`repair_truncated_json`]. `notes` carries human-readable
28/// markers of what was done, for tracing.
29#[derive(Debug)]
30pub struct TruncationRepair {
31    pub repaired: String,
32    pub changed: bool,
33    pub notes: Vec<&'static str>,
34}
35
36/// Best-effort rescue of a truncated / malformed tool-arguments string.
37/// Valid input is returned unchanged;
38/// otherwise try, in order: close open strings/brackets, complete a
39/// dangling `"key":` with `null`, strip trailing commas; then shrink to
40/// the longest parseable object prefix; then salvage individual top-level
41/// `"key": literal` pairs; finally fall back to `{}` (empty args beat a
42/// failed turn).
43pub fn repair_truncated_json(raw: &str) -> TruncationRepair {
44    if raw.trim().is_empty() {
45        return TruncationRepair {
46            repaired: "{}".into(),
47            changed: true,
48            notes: vec!["empty input -> {}"],
49        };
50    }
51    if is_valid_json(raw) {
52        return TruncationRepair {
53            repaired: raw.into(),
54            changed: false,
55            notes: vec![],
56        };
57    }
58    let mut candidate = raw.to_string();
59    if let Some(closed) = close_likely_json(&candidate) {
60        candidate = closed;
61    }
62    if ends_with_dangling_colon(&candidate) {
63        candidate.push_str(" null");
64    }
65    candidate = strip_trailing_commas(&candidate);
66    if is_valid_json(&candidate) {
67        return TruncationRepair {
68            repaired: candidate,
69            changed: true,
70            notes: vec!["truncation repaired"],
71        };
72    }
73    // Salvage minimal object from recoverable prefix before hard fallback.
74    if let Some(obj) = salvage_json_object_prefix(&candidate) {
75        return TruncationRepair {
76            repaired: obj,
77            changed: true,
78            notes: vec!["salvaged prefix object"],
79        };
80    }
81    // If we can extract at least one k:v pair, preserve it instead of
82    // dropping all args.
83    if let Some(kv) = salvage_top_level_pairs(&candidate) {
84        return TruncationRepair {
85            repaired: kv,
86            changed: true,
87            notes: vec!["salvaged top-level pairs"],
88        };
89    }
90    TruncationRepair {
91        repaired: "{}".into(),
92        changed: true,
93        notes: vec!["fallback -> {}"],
94    }
95}
96
97fn is_valid_json(s: &str) -> bool {
98    serde_json::from_str::<serde::de::IgnoredAny>(s).is_ok()
99}
100
101/// String-aware bracket-stack scan: close an unterminated string and any
102/// unclosed `{`/`[` in reverse order. Returns `None` when the input has a
103/// mismatched closer (e.g. `{]`) — that's corruption, not truncation.
104fn close_likely_json(raw: &str) -> Option<String> {
105    let mut stack: Vec<u8> = Vec::new();
106    let mut in_string = false;
107    let mut escape = false;
108    for &ch in raw.as_bytes() {
109        if in_string {
110            if escape {
111                escape = false;
112                continue;
113            }
114            match ch {
115                b'\\' => escape = true,
116                b'"' => in_string = false,
117                _ => {}
118            }
119            continue;
120        }
121        match ch {
122            b'"' => in_string = true,
123            b'{' => stack.push(b'}'),
124            b'[' => stack.push(b']'),
125            // Mismatched (or unopened) closer — unrepairable. `last()` keeps
126            // the guard side-effect free; the matching closer is popped in
127            // the arm below.
128            b'}' | b']' if stack.last() != Some(&ch) => return None,
129            b'}' | b']' => {
130                stack.pop();
131            }
132            _ => {}
133        }
134    }
135    let mut out = String::with_capacity(raw.len() + stack.len() + 1);
136    out.push_str(raw);
137    if in_string {
138        out.push('"');
139    }
140    while let Some(c) = stack.pop() {
141        out.push(c as char);
142    }
143    Some(out)
144}
145
146/// Detect a dangling `"key":` ending: the input ends with a quoted
147/// key's closing `"`, then `:`, with optional whitespace around it.
148fn ends_with_dangling_colon(s: &str) -> bool {
149    let trimmed_end = s.trim_end();
150    let Some(before_colon) = trimmed_end.strip_suffix(':') else {
151        return false;
152    };
153    before_colon.trim_end().ends_with('"')
154}
155
156/// Drop every comma that is
157/// directly followed (modulo whitespace) by a closing brace/bracket. This is NOT string-aware — the result must still
158/// pass JSON validation downstream, which bounds the damage.
159fn strip_trailing_commas(s: &str) -> String {
160    let bytes = s.as_bytes();
161    let mut out = Vec::with_capacity(bytes.len());
162    let mut i = 0;
163    while i < bytes.len() {
164        if bytes[i] == b',' {
165            let mut j = i + 1;
166            while j < bytes.len() && bytes[j].is_ascii_whitespace() {
167                j += 1;
168            }
169            if j < bytes.len() && (bytes[j] == b'}' || bytes[j] == b']') {
170                i += 1; // skip the comma, keep the whitespace + closer
171                continue;
172            }
173        }
174        out.push(bytes[i]);
175        i += 1;
176    }
177    // Input was valid UTF-8 and we only removed ASCII commas.
178    String::from_utf8(out).unwrap_or_else(|_| s.to_string())
179}
180
181/// Shrink from the end toward the first `{` until some prefix closes into
182/// valid JSON. O(n²) — tool args are bounded by model
183/// output size, and this path only runs on already-broken input.
184fn salvage_json_object_prefix(raw: &str) -> Option<String> {
185    let start = raw.find('{')?;
186    let mut end = raw.len();
187    while end > start {
188        if !raw.is_char_boundary(end) {
189            end -= 1;
190            continue;
191        }
192        let candidate = raw[start..end].trim();
193        if candidate.starts_with('{') {
194            if let Some(closed) = close_likely_json(candidate) {
195                let closed = strip_trailing_commas(&closed);
196                if is_valid_json(&closed) {
197                    return Some(closed);
198                }
199            }
200        }
201        end -= 1;
202    }
203    None
204}
205
206/// Extract up to 12 top-level `"key": <literal>` pairs (string / number /
207/// bool / null) from anywhere in the text and rebuild a minimal object.
208fn salvage_top_level_pairs(raw: &str) -> Option<String> {
209    let mut r = raw.trim();
210    if r.is_empty() {
211        return None;
212    }
213    if let Some(pos) = r.find('{') {
214        r = &r[pos + 1..];
215    }
216    let matches = find_pair_literals(r, 12);
217    if matches.is_empty() {
218        return None;
219    }
220    let mut out = Map::new();
221    for (key, lit) in matches {
222        let Ok(v) = serde_json::from_str::<Value>(&lit) else {
223            continue;
224        };
225        out.insert(key, v);
226    }
227    if out.is_empty() {
228        return None;
229    }
230    serde_json::to_string(&Value::Object(out)).ok()
231}
232
233/// Hand-rolled key:literal pair matcher, equivalent to:
234/// `"([A-Za-z0-9_\-\.]+)"\s*:\s*("([^"\\]|\\.)*"|-?\d+(\.\d+)?|true|false|null)`.
235/// Returns `(key, literal_source)` for the first `max` matches.
236fn find_pair_literals(s: &str, max: usize) -> Vec<(String, String)> {
237    let bytes = s.as_bytes();
238    let mut out = Vec::new();
239    let mut i = 0;
240    while i < bytes.len() && out.len() < max {
241        if bytes[i] != b'"' {
242            i += 1;
243            continue;
244        }
245        match match_pair_at(bytes, i) {
246            Some((key, lit, next)) => {
247                out.push((key, lit));
248                i = next;
249            }
250            None => i += 1,
251        }
252    }
253    out
254}
255
256/// Try to match one `"key"\s*:\s*<literal>` starting at byte `start`
257/// (which must be a `"`). Returns `(key, literal, index_after_match)`.
258fn match_pair_at(bytes: &[u8], start: usize) -> Option<(String, String, usize)> {
259    let mut i = start + 1;
260    let key_start = i;
261    while i < bytes.len()
262        && (bytes[i].is_ascii_alphanumeric() || matches!(bytes[i], b'_' | b'-' | b'.'))
263    {
264        i += 1;
265    }
266    if i == key_start || i >= bytes.len() || bytes[i] != b'"' {
267        return None;
268    }
269    let key = String::from_utf8(bytes[key_start..i].to_vec()).ok()?;
270    i += 1;
271    while i < bytes.len() && bytes[i].is_ascii_whitespace() {
272        i += 1;
273    }
274    if i >= bytes.len() || bytes[i] != b':' {
275        return None;
276    }
277    i += 1;
278    while i < bytes.len() && bytes[i].is_ascii_whitespace() {
279        i += 1;
280    }
281    let (lit, next) = match_literal_at(bytes, i)?;
282    Some((key, lit, next))
283}
284
285/// Match one literal: quoted string (escape-aware), number, true/false/null.
286fn match_literal_at(bytes: &[u8], start: usize) -> Option<(String, usize)> {
287    if start >= bytes.len() {
288        return None;
289    }
290    match bytes[start] {
291        b'"' => {
292            let mut i = start + 1;
293            while i < bytes.len() {
294                match bytes[i] {
295                    b'\\' => i += 2,
296                    b'"' => {
297                        let lit = String::from_utf8(bytes[start..=i].to_vec()).ok()?;
298                        return Some((lit, i + 1));
299                    }
300                    _ => i += 1,
301                }
302            }
303            None
304        }
305        b'-' | b'0'..=b'9' => {
306            let mut i = start;
307            if bytes[i] == b'-' {
308                i += 1;
309            }
310            let int_start = i;
311            while i < bytes.len() && bytes[i].is_ascii_digit() {
312                i += 1;
313            }
314            if i == int_start {
315                return None;
316            }
317            if i < bytes.len() && bytes[i] == b'.' {
318                let frac_start = i + 1;
319                let mut j = frac_start;
320                while j < bytes.len() && bytes[j].is_ascii_digit() {
321                    j += 1;
322                }
323                if j > frac_start {
324                    i = j;
325                }
326            }
327            let lit = String::from_utf8(bytes[start..i].to_vec()).ok()?;
328            Some((lit, i))
329        }
330        _ => {
331            for word in ["true", "false", "null"] {
332                if bytes[start..].starts_with(word.as_bytes()) {
333                    return Some((word.to_string(), start + word.len()));
334                }
335            }
336            None
337        }
338    }
339}
340
341// ──────────────────────── schema-guided repair ────────────────────────
342
343pub const REPAIR_NULL_OPTIONAL_OMITTED: &str = "null_optional_omitted";
344pub const REPAIR_STRINGIFIED_ARRAY: &str = "stringified_array";
345pub const REPAIR_BARE_STRING_TO_ARRAY: &str = "bare_string_to_array";
346pub const REPAIR_EMPTY_OBJECT_TO_ARRAY: &str = "empty_object_to_array";
347pub const REPAIR_MARKDOWN_AUTOLINK_PATH: &str = "markdown_autolink_path";
348pub const REPAIR_SEMANTIC_BOOLEAN: &str = "semantic_boolean_string";
349pub const REPAIR_SEMANTIC_INTEGER: &str = "semantic_integer_string";
350pub const REPAIR_FIELD_ALIAS: &str = "field_alias";
351
352/// One applied repair, for tracing / telemetry.
353#[derive(Debug, Clone, PartialEq)]
354pub struct ToolInputRepair {
355    pub kind: &'static str,
356    pub path: String,
357    pub before_type: &'static str,
358    pub after_type: &'static str,
359}
360
361/// A path segment into the (cloned) working value. We navigate to the parent object by path segments rather than holding a pointer.
362#[derive(Debug, Clone, PartialEq)]
363enum Seg {
364    Key(String),
365    Index(usize),
366}
367
368/// One schema violation found by the issue scan. `parent_loc` + `key`
369/// identify the owning object field when the issue is repairable in
370/// place; issues without a parent (root level, unknown fields bubbling
371/// rules) are never repaired and act as "poison" that reverts the whole
372/// repair in the second validation pass.
373struct Issue {
374    path: String,
375    expected: String,
376    items_type: Option<String>,
377    required: bool,
378    known_field: bool,
379    parent_loc: Option<(Vec<Seg>, String)>,
380}
381
382/// Apply narrowly scoped, schema-guided repairs to common tool-call
383/// argument shape mistakes. Valid inputs return `None` (unchanged), as do
384/// inputs whose issues can't ALL be repaired under a "two passes, revert unless fully clean" contract. On success returns the repaired value plus the repair list.
385pub fn repair_tool_input_for_spec(
386    schema: &Value,
387    input: &Value,
388) -> Option<(Value, Vec<ToolInputRepair>)> {
389    if !schema.is_object() || !input.is_object() {
390        return None;
391    }
392    let mut work = input.clone();
393    let mut repairs = collect_field_alias_repairs(schema, &mut work);
394    repairs.extend(collect_path_string_repairs(schema, &mut work, ""));
395    let issues = collect_issues(schema, &work, "", &[], true);
396    if issues.is_empty() && repairs.is_empty() {
397        return None;
398    }
399    for issue in &issues {
400        if let Some(repair) = apply_issue_repair(&mut work, issue) {
401            repairs.push(repair);
402        }
403    }
404    if repairs.is_empty() {
405        return None;
406    }
407    // Second pass: commit only if the repaired value is fully clean.
408    if !collect_issues(schema, &work, "", &[], true).is_empty() {
409        return None;
410    }
411    Some((work, repairs))
412}
413
414/// Repair common model-facing field aliases only when the target field is part
415/// of the advertised schema and is currently absent. This is intentionally a
416/// rename, not inference from conversation state.
417fn collect_field_alias_repairs(schema: &Value, value: &mut Value) -> Vec<ToolInputRepair> {
418    let Some(props) = schema.get("properties").and_then(Value::as_object) else {
419        return vec![];
420    };
421    let Some(obj) = value.as_object_mut() else {
422        return vec![];
423    };
424    let aliases = [
425        ("filePath", "path"),
426        ("oldString", "old_string"),
427        ("newString", "new_string"),
428        ("replaceAll", "replace_all"),
429        ("cmd", "command"),
430    ];
431    let mut out = Vec::new();
432    for (from, to) in aliases {
433        if !props.contains_key(to) || obj.contains_key(to) {
434            continue;
435        }
436        let Some(value) = obj.remove(from) else {
437            continue;
438        };
439        let before_type = json_type_name(&value);
440        obj.insert(to.to_string(), value);
441        out.push(ToolInputRepair {
442            kind: REPAIR_FIELD_ALIAS,
443            path: to.to_string(),
444            before_type,
445            after_type: before_type,
446        });
447    }
448    out
449}
450
451fn json_type_name(value: &Value) -> &'static str {
452    match value {
453        Value::Null => "null",
454        Value::Bool(_) => "boolean",
455        Value::Number(_) => "number",
456        Value::String(_) => "string",
457        Value::Array(_) => "array",
458        Value::Object(_) => "object",
459    }
460}
461
462/// Walk `value` along `segs`, mutably.
463fn navigate_mut<'a>(value: &'a mut Value, segs: &[Seg]) -> Option<&'a mut Value> {
464    let mut cur = value;
465    for seg in segs {
466        cur = match seg {
467            Seg::Key(k) => cur.as_object_mut()?.get_mut(k)?,
468            Seg::Index(i) => cur.as_array_mut()?.get_mut(*i)?,
469        };
470    }
471    Some(cur)
472}
473
474/// Unwrap markdown autolinks on path-ish string fields, recursively.
475/// Mutates `value` in place.
476fn collect_path_string_repairs(
477    schema: &Value,
478    value: &mut Value,
479    path: &str,
480) -> Vec<ToolInputRepair> {
481    match schema_type(schema).as_str() {
482        "object" | "" => {
483            let Some(obj) = value.as_object_mut() else {
484                return vec![];
485            };
486            let Some(props) = schema.get("properties").and_then(Value::as_object) else {
487                return vec![];
488            };
489            let mut keys: Vec<&String> = props.keys().collect();
490            keys.sort();
491            let mut out = Vec::new();
492            for key in keys {
493                let child_schema = &props[key.as_str()];
494                if !child_schema.is_object() {
495                    continue;
496                }
497                let Some(child_value) = obj.get_mut(key.as_str()) else {
498                    continue;
499                };
500                let child_path = join_path(path, key);
501                if schema_type(child_schema) == "string" && is_path_string_field(key) {
502                    if let Some(s) = child_value.as_str() {
503                        if let Some(fixed) = unwrap_markdown_autolink_path(s) {
504                            *child_value = Value::String(fixed);
505                            out.push(ToolInputRepair {
506                                kind: REPAIR_MARKDOWN_AUTOLINK_PATH,
507                                path: child_path,
508                                before_type: "string",
509                                after_type: "string",
510                            });
511                        }
512                    }
513                    continue;
514                }
515                out.extend(collect_path_string_repairs(
516                    child_schema,
517                    child_value,
518                    &child_path,
519                ));
520            }
521            out
522        }
523        "array" => {
524            let Some(arr) = value.as_array_mut() else {
525                return vec![];
526            };
527            let Some(item_schema) = schema.get("items").filter(|s| s.is_object()) else {
528                return vec![];
529            };
530            let mut out = Vec::new();
531            for (i, item) in arr.iter_mut().enumerate() {
532                let item_path = format!("{path}[{i}]");
533                out.extend(collect_path_string_repairs(item_schema, item, &item_path));
534            }
535            out
536        }
537        _ => vec![],
538    }
539}
540
541/// Scan `value` against `schema`, returning every violation. Mirrors
542/// the issue scan. `loc` is the segment path to `value`
543/// within the working root (used to rebuild parent locations).
544fn collect_issues(
545    schema: &Value,
546    value: &Value,
547    path: &str,
548    loc: &[Seg],
549    required: bool,
550) -> Vec<Issue> {
551    let mut expected = schema_type(schema);
552    if expected.is_empty() && schema.get("properties").is_some_and(Value::is_object) {
553        expected = "object".into();
554    }
555    let issue_here = || Issue {
556        path: path.to_string(),
557        expected: expected.clone(),
558        items_type: schema_array_items_type(schema),
559        required,
560        known_field: !path.is_empty(),
561        parent_loc: None,
562    };
563    if value.is_null() {
564        if type_allows_null(schema) {
565            return vec![];
566        }
567        return vec![issue_here()];
568    }
569    match expected.as_str() {
570        "" => vec![],
571        "object" => {
572            let Some(obj) = value.as_object() else {
573                return vec![issue_here()];
574            };
575            collect_object_issues(schema, obj, path, loc)
576        }
577        "array" => {
578            let Some(arr) = value.as_array() else {
579                return vec![issue_here()];
580            };
581            let mut out = Vec::new();
582            if let Some(min) = schema_min_items(schema) {
583                if arr.len() < min {
584                    out.push(issue_here());
585                }
586            }
587            let Some(item_schema) = schema.get("items").filter(|s| s.is_object()) else {
588                return out;
589            };
590            for (i, item) in arr.iter().enumerate() {
591                let item_path = format!("{path}[{i}]");
592                let mut item_loc = loc.to_vec();
593                item_loc.push(Seg::Index(i));
594                out.extend(collect_issues(
595                    item_schema,
596                    item,
597                    &item_path,
598                    &item_loc,
599                    true,
600                ));
601            }
602            out
603        }
604        "string" if value.is_string() => vec![],
605        "integer" if is_json_integer(value) => vec![],
606        "number" if value.is_number() => vec![],
607        "boolean" if value.is_boolean() => vec![],
608        "string" | "integer" | "number" | "boolean" => vec![issue_here()],
609        _ => vec![],
610    }
611}
612
613fn collect_object_issues(
614    schema: &Value,
615    obj: &Map<String, Value>,
616    path: &str,
617    loc: &[Seg],
618) -> Vec<Issue> {
619    let props = schema
620        .get("properties")
621        .and_then(Value::as_object)
622        .cloned()
623        .unwrap_or_default();
624    let req = required_set(schema.get("required"));
625    let mut out = Vec::new();
626    let mut keys: Vec<&String> = props.keys().collect();
627    keys.sort();
628    for key in keys {
629        let child_schema = &props[key.as_str()];
630        if !child_schema.is_object() {
631            continue;
632        }
633        let child_path = join_path(path, key);
634        let Some(value) = obj.get(key.as_str()) else {
635            if req.contains(key) {
636                out.push(Issue {
637                    path: child_path,
638                    expected: schema_type(child_schema),
639                    items_type: schema_array_items_type(child_schema),
640                    required: true,
641                    known_field: true,
642                    parent_loc: Some((loc.to_vec(), key.clone())),
643                });
644            }
645            continue;
646        };
647        let mut child_loc = loc.to_vec();
648        child_loc.push(Seg::Key(key.clone()));
649        let mut issues = collect_issues(
650            child_schema,
651            value,
652            &child_path,
653            &child_loc,
654            req.contains(key),
655        );
656        for issue in &mut issues {
657            issue.known_field = true;
658            if issue.parent_loc.is_none() {
659                issue.parent_loc = Some((loc.to_vec(), key.clone()));
660            }
661        }
662        out.extend(issues);
663    }
664    if schema.get("additionalProperties") == Some(&Value::Bool(false)) {
665        for key in obj.keys() {
666            if props.contains_key(key.as_str()) {
667                continue;
668            }
669            out.push(Issue {
670                path: join_path(path, key),
671                expected: "none".into(),
672                items_type: None,
673                required: false,
674                known_field: false,
675                parent_loc: Some((loc.to_vec(), key.clone())),
676            });
677        }
678    }
679    out
680}
681
682/// Attempt one in-place repair for an issue. Only known object fields are touched,
683/// only via the narrow conversions listed at the top of this module.
684fn apply_issue_repair(work: &mut Value, issue: &Issue) -> Option<ToolInputRepair> {
685    if !issue.known_field {
686        return None;
687    }
688    let (parent_loc, key) = issue.parent_loc.as_ref()?;
689    let parent = navigate_mut(work, parent_loc)?.as_object_mut()?;
690    let value = parent.get(key.as_str())?.clone();
691    if value.is_null() && !issue.required {
692        parent.remove(key.as_str());
693        return Some(ToolInputRepair {
694            kind: REPAIR_NULL_OPTIONAL_OMITTED,
695            path: issue.path.clone(),
696            before_type: "null",
697            after_type: "omitted",
698        });
699    }
700    if let Some(s) = value.as_str() {
701        match issue.expected.as_str() {
702            "boolean" => {
703                let parsed = match s {
704                    "true" => true,
705                    "false" => false,
706                    _ => return None,
707                };
708                parent.insert(key.clone(), Value::Bool(parsed));
709                return Some(ToolInputRepair {
710                    kind: REPAIR_SEMANTIC_BOOLEAN,
711                    path: issue.path.clone(),
712                    before_type: "string",
713                    after_type: "boolean",
714                });
715            }
716            "integer" => {
717                if !is_decimal_integer_literal(s) {
718                    return None;
719                }
720                let n: i64 = s.parse().ok()?;
721                parent.insert(key.clone(), Value::from(n));
722                return Some(ToolInputRepair {
723                    kind: REPAIR_SEMANTIC_INTEGER,
724                    path: issue.path.clone(),
725                    before_type: "string",
726                    after_type: "number",
727                });
728            }
729            _ => {}
730        }
731    }
732    if issue.expected != "array" {
733        return None;
734    }
735    match &value {
736        Value::String(s) => {
737            if let Ok(arr) = serde_json::from_str::<Vec<Value>>(s.trim()) {
738                parent.insert(key.clone(), Value::Array(arr));
739                return Some(ToolInputRepair {
740                    kind: REPAIR_STRINGIFIED_ARRAY,
741                    path: issue.path.clone(),
742                    before_type: "string",
743                    after_type: "array",
744                });
745            }
746            if issue.items_type.as_deref() != Some("string") {
747                return None;
748            }
749            parent.insert(key.clone(), Value::Array(vec![value]));
750            Some(ToolInputRepair {
751                kind: REPAIR_BARE_STRING_TO_ARRAY,
752                path: issue.path.clone(),
753                before_type: "string",
754                after_type: "array",
755            })
756        }
757        Value::Object(m) if m.is_empty() => {
758            parent.insert(key.clone(), Value::Array(vec![]));
759            Some(ToolInputRepair {
760                kind: REPAIR_EMPTY_OBJECT_TO_ARRAY,
761                path: issue.path.clone(),
762                before_type: "object",
763                after_type: "array",
764            })
765        }
766        _ => None,
767    }
768}
769
770fn is_decimal_integer_literal(s: &str) -> bool {
771    // Bare "-" strips to an empty `rest`, so the non-empty check already
772    // rejects it — no separate guard needed.
773    let rest = s.strip_prefix('-').unwrap_or(s);
774    !rest.is_empty() && rest.bytes().all(|b| b.is_ascii_digit())
775}
776
777/// Resolve the schema's primary type: a plain string, or the first
778/// non-"null" entry of a type array.
779fn schema_type(schema: &Value) -> String {
780    match schema.get("type") {
781        Some(Value::String(s)) => s.clone(),
782        Some(Value::Array(items)) => items
783            .iter()
784            .filter_map(Value::as_str)
785            .find(|s| *s != "null")
786            .unwrap_or_default()
787            .to_string(),
788        _ => String::new(),
789    }
790}
791
792fn type_allows_null(schema: &Value) -> bool {
793    match schema.get("type") {
794        Some(Value::String(s)) => s == "null",
795        Some(Value::Array(items)) => items.iter().filter_map(Value::as_str).any(|s| s == "null"),
796        _ => false,
797    }
798}
799
800fn schema_array_items_type(schema: &Value) -> Option<String> {
801    let items = schema.get("items")?;
802    items.is_object().then(|| schema_type(items))
803}
804
805fn schema_min_items(schema: &Value) -> Option<usize> {
806    let v = schema.get("minItems")?.as_f64()?;
807    (v >= 0.0 && v.trunc() == v).then_some(v as usize)
808}
809
810fn is_json_integer(value: &Value) -> bool {
811    if value.is_i64() || value.is_u64() {
812        return true;
813    }
814    value.as_f64().is_some_and(|f| f.trunc() == f)
815}
816
817fn required_set(required: Option<&Value>) -> Vec<String> {
818    required
819        .and_then(Value::as_array)
820        .map(|arr| {
821            arr.iter()
822                .filter_map(Value::as_str)
823                .map(str::to_string)
824                .collect()
825        })
826        .unwrap_or_default()
827}
828
829fn is_path_string_field(key: &str) -> bool {
830    matches!(key.trim(), "file_path" | "path" | "cwd" | "directory")
831}
832
833/// Unwrap `[text](http://target)` into `text` when the link is a
834/// model-mangled file path: the URL (minus protocol) must round-trip to
835/// the link text or the full replacement, and any prefix must end with a
836/// path separator. Normal markdown links (text ≠ target) are left alone.
837fn unwrap_markdown_autolink_path(value: &str) -> Option<String> {
838    let trimmed = value.trim();
839    let start = trimmed.find('[')?;
840    let prefix = &trimmed[..start];
841    if !prefix.is_empty() && !prefix.ends_with('/') && !prefix.ends_with('\\') {
842        return None;
843    }
844    let rest = &trimmed[start..];
845    let end_text = rest.find(']')?;
846    if end_text <= 1
847        || end_text + 1 >= rest.len()
848        || rest.as_bytes()[end_text + 1] != b'('
849        || !rest.ends_with(')')
850    {
851        return None;
852    }
853    let text = &rest[1..end_text];
854    let url = &rest[end_text + 2..rest.len() - 1];
855    let target = strip_http_protocol(url)?;
856    let replacement = format!("{prefix}{text}");
857    let normalized_target = target.trim();
858    if normalized_target != text.trim() && normalized_target != replacement.trim() {
859        return None;
860    }
861    if replacement == value {
862        return None;
863    }
864    Some(replacement)
865}
866
867fn strip_http_protocol(value: &str) -> Option<&str> {
868    let value = value.trim();
869    for prefix in ["http://", "https://"] {
870        if let Some(rest) = value.strip_prefix(prefix) {
871            return Some(rest.trim());
872        }
873    }
874    None
875}
876
877fn join_path(parent: &str, key: &str) -> String {
878    if parent.is_empty() {
879        key.to_string()
880    } else {
881        format!("{parent}.{key}")
882    }
883}
884
885// ───────────────────────── lightweight schema validation ─────────────────────────
886
887/// Lightweight, best-effort validation of `input` against a tool's
888/// `input_schema`. Deliberately NOT a full JSON Schema validator (no extra
889/// dependency): it checks only the two failure modes weak models hit most —
890/// (1) a `required` field is missing or null (when null isn't allowed), and
891/// (2) a present field's top-level JSON type contradicts the schema's
892/// declared `type`. Returns `Ok(())` when no such violation is found (so
893/// unmodeled constraints like enums / patterns pass through), or the first
894/// violation as a human-readable string for a teaching error.
895///
896/// Runs at dispatch time AFTER [`repair_tool_input_for_spec`], so anything
897/// the repair pass can fix never reaches here. A non-object schema or input
898/// is treated as un-validatable and passes.
899pub fn validate_against_schema(schema: &Value, input: &Value) -> Result<(), String> {
900    let (Some(_), Some(obj)) = (schema.as_object(), input.as_object()) else {
901        return Ok(());
902    };
903    for field in required_set(schema.get("required")) {
904        let prop = schema
905            .get("properties")
906            .and_then(Value::as_object)
907            .and_then(|p| p.get(&field));
908        let allows_null = prop.is_some_and(type_allows_null);
909        match obj.get(&field) {
910            None => return Err(format!("missing required field `{field}`")),
911            Some(Value::Null) if !allows_null => {
912                return Err(format!("required field `{field}` must not be null"))
913            }
914            _ => {}
915        }
916    }
917    let Some(props) = schema.get("properties").and_then(Value::as_object) else {
918        return Ok(());
919    };
920    for (key, value) in obj {
921        let Some(prop) = props.get(key) else { continue };
922        let expected = schema_type(prop);
923        if expected.is_empty() || (value.is_null() && type_allows_null(prop)) {
924            continue;
925        }
926        if !json_value_matches_type(value, &expected) {
927            return Err(format!(
928                "field `{key}` should be {expected}, got {}",
929                json_type_name(value)
930            ));
931        }
932    }
933    Ok(())
934}
935
936/// Does `value`'s JSON type satisfy the schema `type` string? `integer`
937/// accepts whole-valued numbers; `number` accepts any number.
938fn json_value_matches_type(value: &Value, expected: &str) -> bool {
939    match expected {
940        "string" => value.is_string(),
941        "boolean" => value.is_boolean(),
942        "integer" => is_json_integer(value),
943        "number" => value.is_number(),
944        "array" => value.is_array(),
945        "object" => value.is_object(),
946        "null" => value.is_null(),
947        _ => true,
948    }
949}
950
951/// Synthesize a minimal valid example object from a tool's `input_schema`:
952/// each required field (or every property when none are required) mapped to
953/// a type-appropriate placeholder. Used to make invalid-argument errors
954/// teaching: the model sees the shape it should have produced.
955pub fn example_for_schema(schema: &Value) -> Value {
956    let Some(props) = schema.get("properties").and_then(Value::as_object) else {
957        return Value::Object(Map::new());
958    };
959    let required = required_set(schema.get("required"));
960    let keys: Vec<&String> = if required.is_empty() {
961        props.keys().collect()
962    } else {
963        required.iter().filter(|k| props.contains_key(*k)).collect()
964    };
965    let mut out = Map::new();
966    for key in keys {
967        if let Some(prop) = props.get(key) {
968            out.insert(key.clone(), placeholder_for_schema(prop));
969        }
970    }
971    Value::Object(out)
972}
973
974fn placeholder_for_schema(prop: &Value) -> Value {
975    match schema_type(prop).as_str() {
976        "string" => Value::String("<string>".into()),
977        "integer" | "number" => Value::from(0),
978        "boolean" => Value::Bool(false),
979        "array" => {
980            let item = schema_array_items_type(prop)
981                .map(|t| placeholder_for_schema(&serde_json::json!({ "type": t })))
982                .unwrap_or(Value::String("<item>".into()));
983            Value::Array(vec![item])
984        }
985        "object" => Value::Object(Map::new()),
986        _ => Value::String("<value>".into()),
987    }
988}
989
990#[cfg(test)]
991mod tests {
992    use super::*;
993    use serde_json::json;
994
995    // ── truncation repair ──
996
997    // ── schema validation & example synthesis ──
998
999    fn validate_schema() -> Value {
1000        json!({
1001            "type": "object",
1002            "properties": {
1003                "path": { "type": "string" },
1004                "limit": { "type": "integer" },
1005                "flag": { "type": "boolean" },
1006            },
1007            "required": ["path"],
1008            "additionalProperties": false
1009        })
1010    }
1011
1012    #[test]
1013    fn validate_accepts_well_formed_input() {
1014        assert!(validate_against_schema(&validate_schema(), &json!({"path": "a", "limit": 3})).is_ok());
1015    }
1016
1017    #[test]
1018    fn validate_flags_missing_required() {
1019        let err = validate_against_schema(&validate_schema(), &json!({"limit": 3})).unwrap_err();
1020        assert!(err.contains("path"), "{err}");
1021    }
1022
1023    #[test]
1024    fn validate_flags_wrong_type() {
1025        let err =
1026            validate_against_schema(&validate_schema(), &json!({"path": "a", "limit": "nope"}))
1027                .unwrap_err();
1028        assert!(err.contains("limit") && err.contains("integer"), "{err}");
1029    }
1030
1031    #[test]
1032    fn validate_flags_null_required() {
1033        let err = validate_against_schema(&validate_schema(), &json!({"path": null})).unwrap_err();
1034        assert!(err.contains("path") && err.contains("null"), "{err}");
1035    }
1036
1037    #[test]
1038    fn example_uses_required_fields_with_typed_placeholders() {
1039        let ex = example_for_schema(&validate_schema());
1040        assert_eq!(ex["path"], json!("<string>"));
1041        // Only required fields appear when `required` is non-empty.
1042        assert!(ex.get("limit").is_none());
1043    }
1044
1045    #[test]
1046    fn example_falls_back_to_all_properties_when_none_required() {
1047        let schema = json!({
1048            "type": "object",
1049            "properties": { "a": { "type": "integer" }, "b": { "type": "boolean" } }
1050        });
1051        let ex = example_for_schema(&schema);
1052        assert_eq!(ex["a"], json!(0));
1053        assert_eq!(ex["b"], json!(false));
1054    }
1055
1056    #[test]
1057    fn truncation_closes_truncated_json() {
1058        let res = repair_truncated_json(r#"{"file_path":"README.md","offset":0"#);
1059        assert!(res.changed);
1060        assert_eq!(res.repaired, r#"{"file_path":"README.md","offset":0}"#);
1061    }
1062
1063    #[test]
1064    fn truncation_leaves_valid_json_untouched() {
1065        let res = repair_truncated_json(r#"{"k":"v"}"#);
1066        assert!(!res.changed);
1067        assert_eq!(res.repaired, r#"{"k":"v"}"#);
1068    }
1069
1070    #[test]
1071    fn truncation_salvages_prefix_when_value_missing() {
1072        let res = repair_truncated_json(r#"{"file_path":"README.md","offset":0,"limit":"#);
1073        assert!(res.changed);
1074        assert_eq!(res.repaired, r#"{"file_path":"README.md","offset":0}"#);
1075    }
1076
1077    #[test]
1078    fn truncation_empty_input_becomes_empty_object() {
1079        let res = repair_truncated_json("   ");
1080        assert!(res.changed);
1081        assert_eq!(res.repaired, "{}");
1082    }
1083
1084    #[test]
1085    fn truncation_closes_unterminated_string() {
1086        let res = repair_truncated_json(r#"{"file_path":"READ"#);
1087        assert!(res.changed);
1088        let v: Value = serde_json::from_str(&res.repaired).unwrap();
1089        assert_eq!(v, json!({"file_path": "READ"}));
1090    }
1091
1092    #[test]
1093    fn truncation_strips_trailing_comma() {
1094        let res = repair_truncated_json(r#"{"a":1,}"#);
1095        assert!(res.changed);
1096        let v: Value = serde_json::from_str(&res.repaired).unwrap();
1097        assert_eq!(v, json!({"a": 1}));
1098    }
1099
1100    #[test]
1101    fn truncation_salvages_top_level_pairs_from_garbage() {
1102        // Mismatched closer defeats close_likely_json AND prefix salvage;
1103        // the pair scan still rescues the flat k:v pairs.
1104        let res = repair_truncated_json(r#"{"cmd":"ls","count":3]"#);
1105        assert!(res.changed);
1106        let v: Value = serde_json::from_str(&res.repaired).unwrap();
1107        assert_eq!(v, json!({"cmd": "ls", "count": 3}));
1108    }
1109
1110    #[test]
1111    fn truncation_falls_back_to_empty_object() {
1112        let res = repair_truncated_json("not json at all");
1113        assert!(res.changed);
1114        assert_eq!(res.repaired, "{}");
1115    }
1116
1117    // ── schema-guided repair ──
1118
1119    #[test]
1120    fn spec_repairs_safe_field_aliases() {
1121        let schema = json!({
1122            "type": "object",
1123            "properties": {
1124                "path": {"type": "string"},
1125                "old_string": {"type": "string"},
1126                "new_string": {"type": "string"},
1127                "replace_all": {"type": "boolean"}
1128            },
1129            "required": ["path", "old_string", "new_string"],
1130            "additionalProperties": false
1131        });
1132        let input = json!({
1133            "filePath": "src/lib.rs",
1134            "oldString": "before",
1135            "newString": "after",
1136            "replaceAll": "true"
1137        });
1138        let (out, repairs) = repair_tool_input_for_spec(&schema, &input).unwrap();
1139        assert_eq!(out, json!({
1140            "path": "src/lib.rs",
1141            "old_string": "before",
1142            "new_string": "after",
1143            "replace_all": true
1144        }));
1145        assert!(repairs.iter().any(|r| r.kind == REPAIR_FIELD_ALIAS && r.path == "path"));
1146        assert!(repairs.iter().any(|r| r.kind == REPAIR_SEMANTIC_BOOLEAN && r.path == "replace_all"));
1147    }
1148
1149    fn repair_test_schema() -> Value {
1150        json!({
1151            "type": "object",
1152            "properties": {
1153                "prompts": {"type": "array", "items": {"type": "string"}, "minItems": 1},
1154                "ignore": {"type": "array", "items": {"type": "string"}},
1155                "limit": {"type": "integer"},
1156                "content": {"type": "string"}
1157            },
1158            "required": ["prompts"],
1159            "additionalProperties": false
1160        })
1161    }
1162
1163    fn grep_test_schema() -> Value {
1164        json!({
1165            "type": "object",
1166            "properties": {
1167                "pattern": {"type": "string"},
1168                "literal_text": {"type": "boolean"},
1169                "limit": {"type": "integer"}
1170            },
1171            "required": ["pattern"],
1172            "additionalProperties": false
1173        })
1174    }
1175
1176    fn path_test_schema() -> Value {
1177        json!({
1178            "type": "object",
1179            "properties": {
1180                "file_path": {"type": "string"},
1181                "path": {"type": "string"},
1182                "cwd": {"type": "string"},
1183                "content": {"type": "string"}
1184            },
1185            "required": ["content"],
1186            "additionalProperties": false
1187        })
1188    }
1189
1190    #[test]
1191    fn spec_leaves_valid_input_unchanged() {
1192        let input = json!({"prompts": ["a"], "content": "[\"not an arg array\"]"});
1193        assert!(repair_tool_input_for_spec(&repair_test_schema(), &input).is_none());
1194    }
1195
1196    #[test]
1197    fn spec_omits_optional_null() {
1198        let input = json!({"prompts": ["a"], "limit": null});
1199        let (out, repairs) = repair_tool_input_for_spec(&repair_test_schema(), &input).unwrap();
1200        assert_eq!(repairs.len(), 1);
1201        assert_eq!(repairs[0].kind, REPAIR_NULL_OPTIONAL_OMITTED);
1202        assert_eq!(repairs[0].path, "limit");
1203        assert!(out.get("limit").is_none());
1204    }
1205
1206    #[test]
1207    fn spec_does_not_omit_required_null() {
1208        let input = json!({"prompts": null});
1209        assert!(repair_tool_input_for_spec(&repair_test_schema(), &input).is_none());
1210    }
1211
1212    #[test]
1213    fn spec_parses_stringified_array_before_wrapping() {
1214        let input = json!({"prompts": "[\"a\",\"b\"]"});
1215        let (out, repairs) = repair_tool_input_for_spec(&repair_test_schema(), &input).unwrap();
1216        assert_eq!(repairs.len(), 1);
1217        assert_eq!(repairs[0].kind, REPAIR_STRINGIFIED_ARRAY);
1218        assert_eq!(repairs[0].path, "prompts");
1219        assert_eq!(repairs[0].before_type, "string");
1220        assert_eq!(repairs[0].after_type, "array");
1221        assert_eq!(out["prompts"], json!(["a", "b"]));
1222    }
1223
1224    #[test]
1225    fn spec_wraps_bare_string_for_string_array() {
1226        let input = json!({"prompts": "a"});
1227        let (out, repairs) = repair_tool_input_for_spec(&repair_test_schema(), &input).unwrap();
1228        assert_eq!(repairs.len(), 1);
1229        assert_eq!(repairs[0].kind, REPAIR_BARE_STRING_TO_ARRAY);
1230        assert_eq!(out["prompts"], json!(["a"]));
1231    }
1232
1233    #[test]
1234    fn spec_rejects_empty_array_when_min_items_fails() {
1235        // {} → [] would repair, but the second pass sees len 0 < minItems 1
1236        // and reverts everything.
1237        let input = json!({"prompts": {}});
1238        assert!(repair_tool_input_for_spec(&repair_test_schema(), &input).is_none());
1239    }
1240
1241    #[test]
1242    fn spec_repairs_empty_object_to_optional_array() {
1243        let input = json!({"prompts": ["a"], "ignore": {}});
1244        let (out, repairs) = repair_tool_input_for_spec(&repair_test_schema(), &input).unwrap();
1245        assert_eq!(repairs.len(), 1);
1246        assert_eq!(repairs[0].kind, REPAIR_EMPTY_OBJECT_TO_ARRAY);
1247        assert_eq!(repairs[0].path, "ignore");
1248        assert_eq!(out["ignore"], json!([]));
1249    }
1250
1251    #[test]
1252    fn spec_leaves_unknown_field_invalid() {
1253        // "prompts" would be repairable, but the unknown "extra" field is
1254        // an unrepairable issue → whole repair reverts.
1255        let input = json!({"prompts": "a", "extra": true});
1256        assert!(repair_tool_input_for_spec(&repair_test_schema(), &input).is_none());
1257    }
1258
1259    #[test]
1260    fn spec_coerces_semantic_boolean_string() {
1261        let input = json!({"pattern": "needle", "literal_text": "false"});
1262        let (out, repairs) = repair_tool_input_for_spec(&grep_test_schema(), &input).unwrap();
1263        assert_eq!(repairs.len(), 1);
1264        assert_eq!(repairs[0].kind, REPAIR_SEMANTIC_BOOLEAN);
1265        assert_eq!(repairs[0].path, "literal_text");
1266        assert_eq!(repairs[0].before_type, "string");
1267        assert_eq!(repairs[0].after_type, "boolean");
1268        assert_eq!(out["literal_text"], json!(false));
1269    }
1270
1271    #[test]
1272    fn spec_coerces_semantic_integer_string() {
1273        let input = json!({"pattern": "needle", "limit": "30"});
1274        let (out, repairs) = repair_tool_input_for_spec(&grep_test_schema(), &input).unwrap();
1275        assert_eq!(repairs.len(), 1);
1276        assert_eq!(repairs[0].kind, REPAIR_SEMANTIC_INTEGER);
1277        assert_eq!(repairs[0].path, "limit");
1278        assert_eq!(repairs[0].before_type, "string");
1279        assert_eq!(repairs[0].after_type, "number");
1280        assert_eq!(out["limit"], json!(30));
1281    }
1282
1283    #[test]
1284    fn spec_leaves_invalid_semantic_boolean_string() {
1285        let input = json!({"pattern": "needle", "literal_text": "no"});
1286        assert!(repair_tool_input_for_spec(&grep_test_schema(), &input).is_none());
1287    }
1288
1289    #[test]
1290    fn spec_unwraps_markdown_autolink_file_path() {
1291        let input = json!({"file_path": "[README.md](http://README.md)", "content": "x"});
1292        let (out, repairs) = repair_tool_input_for_spec(&path_test_schema(), &input).unwrap();
1293        assert_eq!(repairs.len(), 1);
1294        assert_eq!(repairs[0].kind, REPAIR_MARKDOWN_AUTOLINK_PATH);
1295        assert_eq!(repairs[0].path, "file_path");
1296        assert_eq!(repairs[0].before_type, "string");
1297        assert_eq!(repairs[0].after_type, "string");
1298        assert_eq!(out["file_path"], json!("README.md"));
1299    }
1300
1301    #[test]
1302    fn spec_unwraps_markdown_autolink_path_with_prefix() {
1303        let input = json!({"path": "sub/[a.txt](http://a.txt)", "content": "x"});
1304        let (out, repairs) = repair_tool_input_for_spec(&path_test_schema(), &input).unwrap();
1305        assert_eq!(repairs.len(), 1);
1306        assert_eq!(repairs[0].kind, REPAIR_MARKDOWN_AUTOLINK_PATH);
1307        assert_eq!(repairs[0].path, "path");
1308        assert_eq!(out["path"], json!("sub/a.txt"));
1309    }
1310
1311    #[test]
1312    fn spec_unwraps_markdown_autolink_cwd() {
1313        let input = json!({"cwd": "[internal](http://internal)", "content": "x"});
1314        let (out, repairs) = repair_tool_input_for_spec(&path_test_schema(), &input).unwrap();
1315        assert_eq!(repairs.len(), 1);
1316        assert_eq!(repairs[0].kind, REPAIR_MARKDOWN_AUTOLINK_PATH);
1317        assert_eq!(repairs[0].path, "cwd");
1318        assert_eq!(out["cwd"], json!("internal"));
1319    }
1320
1321    #[test]
1322    fn spec_does_not_unwrap_markdown_in_content() {
1323        let input = json!({"file_path": "README.md", "content": "[README.md](http://README.md)"});
1324        assert!(repair_tool_input_for_spec(&path_test_schema(), &input).is_none());
1325    }
1326
1327    #[test]
1328    fn spec_does_not_unwrap_normal_markdown_link() {
1329        let input = json!({"file_path": "[click](https://example.com)", "content": "x"});
1330        assert!(repair_tool_input_for_spec(&path_test_schema(), &input).is_none());
1331    }
1332
1333    #[test]
1334    fn spec_repairs_nested_array_path() {
1335        let schema = json!({
1336            "type": "object",
1337            "properties": {
1338                "questions": {
1339                    "type": "array",
1340                    "items": {
1341                        "type": "object",
1342                        "properties": {
1343                            "options": {"type": "array", "items": {"type": "string"}}
1344                        }
1345                    }
1346                }
1347            },
1348            "required": ["questions"],
1349            "additionalProperties": false
1350        });
1351        let input = json!({"questions": [{"options": "[\"yes\",\"no\"]"}]});
1352        let (out, repairs) = repair_tool_input_for_spec(&schema, &input).unwrap();
1353        assert_eq!(repairs.len(), 1);
1354        assert_eq!(repairs[0].kind, REPAIR_STRINGIFIED_ARRAY);
1355        assert_eq!(repairs[0].path, "questions[0].options");
1356        assert_eq!(out["questions"][0]["options"], json!(["yes", "no"]));
1357    }
1358}