Skip to main content

everruns_core/capabilities/
tool_call_repair.rs

1// Tool-call repair capability (EVE-600)
2//
3// Detects and repairs malformed tool-call arguments emitted by a model and
4// recovers the turn instead of surfacing a raw parse error (or silently
5// collapsing the call to `{}` the way some drivers do on a parse failure).
6//
7// # Chosen seam
8//
9// The provider-agnostic interception point is the `reason` atom, right after
10// the assistant completion has been finalized into `Vec<ToolCall>` (see
11// `crates/core/src/atoms/reason.rs`, just before the assistant message is built
12// and `output.message.completed` is emitted). At that point each `ToolCall`
13// already carries its `arguments` as a parsed `serde_json::Value`, but for a
14// malformed call that value is either:
15//   * a raw JSON *string* the driver could not parse into an object, or
16//   * an object that does not satisfy the target tool's JSON schema.
17// Individual drivers (`crates/anthropic`, `crates/openai`, ...) are not touched;
18// they keep parsing as before. The capability runs only when explicitly enabled,
19// so the default path is byte-for-byte unchanged.
20//
21// # Two-stage repair
22//
23// 1. **Deterministic local salvage** ([`salvage_tool_arguments`]) — a pure,
24//    table-testable function that extracts the JSON object from fenced blocks,
25//    surrounding prose, trailing commas, and single-quoted keys/strings, then
26//    coerces known argument keys against the tool's JSON schema. The
27//    already-valid case is a no-op.
28// 2. **Bounded corrective re-prompt** — when local salvage cannot recover the
29//    call, the capability allows up to `max_reprompts` (default 1) attempts per
30//    call before falling through to today's exact error behavior. The re-prompt
31//    itself is realized by the outer agent loop: the unrepaired call proceeds to
32//    the act phase, produces today's tool error, and the model retries on the
33//    next iteration. The capability only *bounds* and *labels* this, so there is
34//    never an infinite repair loop.
35//
36// # Safety
37//
38// Salvage parses untrusted model output, so it is bounded: it refuses inputs
39// over `MAX_SALVAGE_INPUT_BYTES`, scans without recursion, and never allocates
40// proportionally more than the input. See the security notes on each helper.
41
42use std::sync::Arc;
43
44use crate::capabilities::{Capability, CapabilityLocalization};
45use serde_json::Value;
46
47pub const TOOL_CALL_REPAIR_CAPABILITY_ID: &str = "tool_call_repair";
48
49/// Default number of corrective re-prompt attempts allowed per tool call before
50/// falling through to the existing error path. Kept small on purpose — repair is
51/// a recovery aid, not a retry loop.
52pub const DEFAULT_MAX_REPROMPTS: u32 = 1;
53
54/// Hard cap on the size of a raw argument blob the salvage routine will inspect.
55/// Larger inputs are rejected outright (treated as un-salvageable) so a hostile
56/// or runaway model cannot drive unbounded CPU/allocation in the parser.
57pub const MAX_SALVAGE_INPUT_BYTES: usize = 256 * 1024;
58
59/// Observable outcome of a repair attempt, used as the event/metric label.
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub enum RepairOutcome {
62    /// Local deterministic salvage recovered a valid JSON object.
63    LocalSalvage,
64    /// Local salvage failed but a bounded corrective re-prompt is still allowed.
65    Reprompt,
66    /// All bounded attempts are exhausted; fall through to the error path.
67    GaveUp,
68}
69
70impl RepairOutcome {
71    /// Stable machine-readable label for events/metrics.
72    pub fn label(self) -> &'static str {
73        match self {
74            RepairOutcome::LocalSalvage => "local-salvage",
75            RepairOutcome::Reprompt => "re-prompt",
76            RepairOutcome::GaveUp => "gave-up",
77        }
78    }
79}
80
81/// Result of running [`salvage_tool_arguments`] on one tool call's raw arguments.
82#[derive(Debug, Clone, PartialEq)]
83pub enum SalvageResult {
84    /// The arguments were already a valid JSON object satisfying the schema (or
85    /// no schema was available to check against). No change is needed.
86    AlreadyValid,
87    /// Local salvage produced a repaired JSON object. The wrapped value replaces
88    /// the call's arguments.
89    Repaired(Value),
90    /// Local salvage could not recover a usable object.
91    Unsalvageable,
92}
93
94/// Deterministically salvage a single tool call's raw/garbled `arguments`.
95///
96/// Pure and side-effect free, so it is exhaustively table-tested. Handles, in
97/// order: already-valid objects; raw strings wrapping a JSON object (possibly
98/// inside ```json fences, surrounded by prose); trailing commas; and
99/// single-quoted keys/strings. When a `schema` is supplied, recovered string
100/// values for known keys are coerced to the declared primitive type where it is
101/// unambiguous (e.g. `"42"` -> `42` for an `integer` property).
102///
103/// Returns [`SalvageResult::AlreadyValid`] when nothing needs changing,
104/// [`SalvageResult::Repaired`] with the recovered object, or
105/// [`SalvageResult::Unsalvageable`] when no JSON object can be extracted.
106///
107/// Security: input larger than [`MAX_SALVAGE_INPUT_BYTES`] is treated as
108/// unsalvageable without parsing. All scanning is linear and non-recursive.
109pub fn salvage_tool_arguments(raw: &Value, schema: Option<&Value>) -> SalvageResult {
110    // Case 1: already a JSON object. Try to coerce string-typed known keys to
111    // their declared primitive type (e.g. `"7"` -> `7` for an integer property);
112    // a string where the schema wants a number is malformed even when all
113    // `required` keys are present. If coercion changes nothing, the object is
114    // valid as-is and the call is a no-op.
115    if let Value::Object(_) = raw {
116        let coerced = coerce_known_keys(raw.clone(), schema);
117        // A call that still violates the schema after coercion (a missing
118        // `required` key, or a value whose type cannot be coerced to the declared
119        // primitive) is malformed-vs-schema even though it is syntactically valid
120        // JSON. Treat it as unsalvageable so it flows to the bounded re-prompt /
121        // error path and is observable, rather than being silently AlreadyValid.
122        if let Value::Object(ref m) = coerced
123            && violates_schema(m, schema)
124        {
125            return SalvageResult::Unsalvageable;
126        }
127        if coerced == *raw {
128            return SalvageResult::AlreadyValid;
129        }
130        return SalvageResult::Repaired(coerced);
131    }
132
133    // Case 2: a raw string. Drivers that fail to parse the model's argument
134    // string sometimes pass it through verbatim as a JSON string. Extract the
135    // embedded JSON object from it.
136    let Some(raw_str) = raw.as_str() else {
137        // Non-object, non-string (number/bool/null/array): not a tool-argument
138        // shape we can repair.
139        return SalvageResult::Unsalvageable;
140    };
141
142    if raw_str.len() > MAX_SALVAGE_INPUT_BYTES {
143        return SalvageResult::Unsalvageable;
144    }
145
146    let trimmed = raw_str.trim();
147    if trimmed.is_empty() {
148        // An empty argument string is conventionally an empty object — unless the
149        // schema requires keys we cannot supply, in which case `{}` would just
150        // fail downstream, so route it to the re-prompt / error path instead.
151        let empty = serde_json::Map::new();
152        if violates_schema(&empty, schema) {
153            return SalvageResult::Unsalvageable;
154        }
155        return SalvageResult::Repaired(Value::Object(empty));
156    }
157
158    match extract_json_object(trimmed) {
159        Some(obj) => {
160            let coerced = coerce_known_keys(obj, schema);
161            // A recovered object that still violates the schema does not actually
162            // recover the turn (the tool would reject it downstream), so surface
163            // it as unsalvageable for the bounded re-prompt path.
164            if let Value::Object(ref m) = coerced
165                && violates_schema(m, schema)
166            {
167                return SalvageResult::Unsalvageable;
168            }
169            SalvageResult::Repaired(coerced)
170        }
171        None => SalvageResult::Unsalvageable,
172    }
173}
174
175/// Conservative schema check used to decide whether a syntactically-valid object
176/// is still malformed *relative to the tool's schema*. Returns true only on
177/// unambiguous violations: a declared `required` key is absent, or a present
178/// (non-null) value's JSON type does not match a declared primitive `type`.
179///
180/// Intentionally shallow and non-recursive: it does not validate nested objects,
181/// enums, formats, or unknown keys, so it never flags a call the downstream tool
182/// would accept. With no schema (or no `type`/`required` info) nothing is flagged.
183fn violates_schema(obj: &serde_json::Map<String, Value>, schema: Option<&Value>) -> bool {
184    let Some(schema) = schema else {
185        return false;
186    };
187
188    if let Some(required) = schema.get("required").and_then(Value::as_array) {
189        for key in required.iter().filter_map(Value::as_str) {
190            if !obj.contains_key(key) {
191                return true;
192            }
193        }
194    }
195
196    if let Some(props) = schema.get("properties").and_then(Value::as_object) {
197        for (key, prop_schema) in props {
198            let Some(declared) = prop_schema.get("type").and_then(Value::as_str) else {
199                continue;
200            };
201            let Some(val) = obj.get(key) else {
202                continue;
203            };
204            // Null is left to the downstream tool (fields are often nullable).
205            if val.is_null() {
206                continue;
207            }
208            let matches = match declared {
209                "integer" => val.is_i64() || val.is_u64(),
210                "number" => val.is_number(),
211                "boolean" => val.is_boolean(),
212                "string" => val.is_string(),
213                "array" => val.is_array(),
214                "object" => val.is_object(),
215                // Unknown/compound declared type: do not flag.
216                _ => true,
217            };
218            if !matches {
219                return true;
220            }
221        }
222    }
223
224    false
225}
226
227/// Extract the first balanced JSON object from a blob that may be wrapped in
228/// ```json fences and/or surrounded by prose, tolerating trailing commas and
229/// single-quoted strings/keys. Returns the parsed object, or `None`.
230///
231/// Non-recursive: brace matching is a single linear scan with a depth counter.
232fn extract_json_object(input: &str) -> Option<Value> {
233    let candidate = strip_code_fences(input);
234
235    // Try a direct parse of the (fence-stripped) candidate first.
236    if let Ok(value @ Value::Object(_)) = serde_json::from_str::<Value>(candidate.trim()) {
237        return Some(value);
238    }
239
240    // Locate the first balanced `{ ... }` span, honoring string literals so a
241    // brace inside a string does not throw off the depth counter.
242    let span = first_balanced_object_span(candidate)?;
243    let slice = &candidate[span];
244
245    if let Ok(value @ Value::Object(_)) = serde_json::from_str::<Value>(slice) {
246        return Some(value);
247    }
248
249    // Apply lenient fix-ups (single quotes -> double, drop trailing commas) and
250    // retry once. Bounded: a single rewrite pass over the already-bounded slice.
251    let relaxed = relax_json(slice);
252    match serde_json::from_str::<Value>(&relaxed) {
253        Ok(value @ Value::Object(_)) => Some(value),
254        _ => None,
255    }
256}
257
258/// Remove a leading/trailing Markdown code fence (```json ... ``` or ``` ... ```).
259/// When no fence is present the input is returned unchanged.
260fn strip_code_fences(input: &str) -> &str {
261    let trimmed = input.trim();
262    let Some(after_open) = trimmed.strip_prefix("```") else {
263        return trimmed;
264    };
265    // Drop an optional language tag on the opening fence line.
266    let after_lang = match after_open.find('\n') {
267        Some(nl) => &after_open[nl + 1..],
268        None => after_open,
269    };
270    after_lang.strip_suffix("```").unwrap_or(after_lang).trim()
271}
272
273/// Byte range of the first balanced top-level `{...}` object in `input`,
274/// respecting double-quoted string literals and escapes. `None` if unbalanced.
275fn first_balanced_object_span(input: &str) -> Option<std::ops::Range<usize>> {
276    let bytes = input.as_bytes();
277    let start = bytes.iter().position(|&b| b == b'{')?;
278    let mut depth: u32 = 0;
279    let mut in_string = false;
280    let mut escaped = false;
281    // Track the active quote char so single- and double-quoted strings both mask
282    // braces during the scan (the relax pass later normalizes quotes).
283    let mut quote: u8 = 0;
284    for (i, &b) in bytes.iter().enumerate().skip(start) {
285        if in_string {
286            if escaped {
287                escaped = false;
288            } else if b == b'\\' {
289                escaped = true;
290            } else if b == quote {
291                in_string = false;
292            }
293            continue;
294        }
295        match b {
296            b'"' | b'\'' => {
297                in_string = true;
298                quote = b;
299            }
300            b'{' => depth += 1,
301            b'}' => {
302                depth -= 1;
303                if depth == 0 {
304                    return Some(start..i + 1);
305                }
306            }
307            _ => {}
308        }
309    }
310    None
311}
312
313/// Best-effort lenient JSON normalization for a single object slice:
314/// converts single-quoted strings/keys to double-quoted and removes trailing
315/// commas before `}`/`]`. Single linear pass, no recursion.
316fn relax_json(slice: &str) -> String {
317    // Phase 1: quote normalization. Walk char-by-char; outside a double-quoted
318    // string, replace `'` with `"`. Inside a double-quoted string, leave content
319    // untouched (so apostrophes in values survive).
320    let mut out = String::with_capacity(slice.len());
321    let mut in_double = false;
322    let mut escaped = false;
323    for ch in slice.chars() {
324        if in_double {
325            out.push(ch);
326            if escaped {
327                escaped = false;
328            } else if ch == '\\' {
329                escaped = true;
330            } else if ch == '"' {
331                in_double = false;
332            }
333            continue;
334        }
335        match ch {
336            '"' => {
337                in_double = true;
338                out.push(ch);
339            }
340            '\'' => out.push('"'),
341            _ => out.push(ch),
342        }
343    }
344
345    // Phase 2: strip trailing commas (`,` followed by optional whitespace then
346    // `}` or `]`). Operate on the quote-normalized string, skipping commas that
347    // live inside double-quoted strings.
348    strip_trailing_commas(&out)
349}
350
351/// Remove commas that immediately precede a closing `}`/`]` (ignoring
352/// whitespace), skipping any comma inside a double-quoted string.
353fn strip_trailing_commas(input: &str) -> String {
354    let chars: Vec<char> = input.chars().collect();
355    let mut out = String::with_capacity(input.len());
356    let mut in_string = false;
357    let mut escaped = false;
358    for i in 0..chars.len() {
359        let ch = chars[i];
360        if in_string {
361            out.push(ch);
362            if escaped {
363                escaped = false;
364            } else if ch == '\\' {
365                escaped = true;
366            } else if ch == '"' {
367                in_string = false;
368            }
369            continue;
370        }
371        if ch == '"' {
372            in_string = true;
373            out.push(ch);
374            continue;
375        }
376        if ch == ',' {
377            // Look ahead past whitespace for a closing bracket.
378            let mut j = i + 1;
379            while j < chars.len() && chars[j].is_whitespace() {
380                j += 1;
381            }
382            if j < chars.len() && (chars[j] == '}' || chars[j] == ']') {
383                // Skip this trailing comma.
384                continue;
385            }
386        }
387        out.push(ch);
388    }
389    out
390}
391
392/// Coerce string-typed values for known schema keys to the declared primitive
393/// type when unambiguous (`integer`, `number`, `boolean`). Unknown keys and
394/// values that do not cleanly convert are left untouched.
395fn coerce_known_keys(value: Value, schema: Option<&Value>) -> Value {
396    let Value::Object(mut obj) = value else {
397        return value;
398    };
399    let Some(props) = schema
400        .and_then(|s| s.get("properties"))
401        .and_then(Value::as_object)
402    else {
403        return Value::Object(obj);
404    };
405
406    for (key, prop_schema) in props {
407        let Some(declared) = prop_schema.get("type").and_then(Value::as_str) else {
408            continue;
409        };
410        let Some(current) = obj.get(key) else {
411            continue;
412        };
413        let Some(text) = current.as_str() else {
414            continue;
415        };
416        let coerced = match declared {
417            "integer" => text.trim().parse::<i64>().ok().map(Value::from),
418            "number" => text.trim().parse::<f64>().ok().map(Value::from),
419            "boolean" => match text.trim() {
420                "true" => Some(Value::Bool(true)),
421                "false" => Some(Value::Bool(false)),
422                _ => None,
423            },
424            _ => None,
425        };
426        if let Some(coerced) = coerced {
427            obj.insert(key.clone(), coerced);
428        }
429    }
430    Value::Object(obj)
431}
432
433/// Per-agent config for the tool-call-repair capability.
434#[derive(Debug, Clone, Copy, PartialEq, Eq)]
435pub struct ToolCallRepairConfig {
436    /// Maximum corrective re-prompt attempts per tool call before falling
437    /// through to the existing error path.
438    pub max_reprompts: u32,
439}
440
441impl Default for ToolCallRepairConfig {
442    fn default() -> Self {
443        Self {
444            max_reprompts: DEFAULT_MAX_REPROMPTS,
445        }
446    }
447}
448
449impl ToolCallRepairConfig {
450    /// Parse config from the per-agent JSON blob, falling back to defaults for
451    /// missing/unknown fields.
452    pub fn from_json(config: &Value) -> Self {
453        let max_reprompts = config
454            .get("max_reprompts")
455            .and_then(Value::as_u64)
456            .map(|v| v as u32)
457            .unwrap_or(DEFAULT_MAX_REPROMPTS);
458        Self { max_reprompts }
459    }
460
461    /// Decide the bounded outcome when local salvage has failed, given how many
462    /// re-prompt attempts have already happened for this call.
463    ///
464    /// Returns [`RepairOutcome::Reprompt`] while attempts remain, otherwise
465    /// [`RepairOutcome::GaveUp`]. This is the only place the attempt cap is
466    /// enforced, keeping the loop bounded.
467    pub fn outcome_after_failed_salvage(&self, prior_attempts: u32) -> RepairOutcome {
468        if prior_attempts < self.max_reprompts {
469            RepairOutcome::Reprompt
470        } else {
471            RepairOutcome::GaveUp
472        }
473    }
474}
475
476/// Opt-in capability that repairs malformed tool calls. Disabled by default:
477/// it is registered in the global registry but contributes nothing unless an
478/// agent explicitly enables it, and the `reason` atom only runs repair when the
479/// capability is present in the resolved capability set.
480pub struct ToolCallRepairCapability;
481
482impl Capability for ToolCallRepairCapability {
483    fn id(&self) -> &str {
484        TOOL_CALL_REPAIR_CAPABILITY_ID
485    }
486
487    fn name(&self) -> &str {
488        "Tool Call Repair"
489    }
490
491    fn description(&self) -> &str {
492        "Detects and repairs malformed tool-call arguments from the model, \
493         recovering the turn instead of surfacing a raw parse error."
494    }
495
496    fn is_guardrail(&self) -> bool {
497        true
498    }
499
500    fn config_schema(&self) -> Option<Value> {
501        Some(serde_json::json!({
502            "type": "object",
503            "properties": {
504                "max_reprompts": {
505                    "type": "integer",
506                    "title": "Max corrective re-prompts",
507                    "description": "How many corrective re-prompt attempts are allowed per malformed tool call before falling through to the normal error path.",
508                    "minimum": 0,
509                    "maximum": 5,
510                    "default": DEFAULT_MAX_REPROMPTS
511                }
512            }
513        }))
514    }
515
516    fn validate_config(&self, config: &Value) -> Result<(), String> {
517        if config.is_null() {
518            return Ok(());
519        }
520        if !config.is_object() {
521            return Err("tool_call_repair config must be an object".to_string());
522        }
523        match config.get("max_reprompts") {
524            None => Ok(()),
525            Some(value) => match value.as_u64() {
526                Some(n) if n <= 5 => Ok(()),
527                _ => Err(format!(
528                    "max_reprompts must be an integer between 0 and 5, got {value}"
529                )),
530            },
531        }
532    }
533
534    fn localizations(&self) -> Vec<CapabilityLocalization> {
535        vec![CapabilityLocalization {
536            locale: "en",
537            name: None,
538            description: None,
539            config_description: Some(
540                "Controls how many corrective re-prompts are attempted before a malformed tool call falls through to the normal error path.",
541            ),
542            config_overlay: None,
543        }]
544    }
545}
546
547/// Convenience: the registered capability as an `Arc` (mirrors how other
548/// capabilities are surfaced where an `Arc<dyn Capability>` is needed).
549pub fn tool_call_repair_capability() -> Arc<dyn Capability> {
550    Arc::new(ToolCallRepairCapability)
551}
552
553#[cfg(test)]
554mod tests {
555    use super::*;
556    use serde_json::json;
557
558    fn schema() -> Value {
559        json!({
560            "type": "object",
561            "properties": {
562                "path": { "type": "string" },
563                "limit": { "type": "integer" },
564                "ratio": { "type": "number" },
565                "recursive": { "type": "boolean" }
566            },
567            "required": ["path"]
568        })
569    }
570
571    // ---- Salvage: table-driven across each malformation class ----
572
573    #[test]
574    fn already_valid_object_is_noop() {
575        let raw = json!({ "path": "/foo", "limit": 10 });
576        assert_eq!(
577            salvage_tool_arguments(&raw, Some(&schema())),
578            SalvageResult::AlreadyValid
579        );
580    }
581
582    #[test]
583    fn already_valid_object_without_schema_is_noop() {
584        let raw = json!({ "anything": 1 });
585        assert_eq!(
586            salvage_tool_arguments(&raw, None),
587            SalvageResult::AlreadyValid
588        );
589    }
590
591    #[test]
592    fn empty_string_becomes_empty_object() {
593        let raw = json!("   ");
594        assert_eq!(
595            salvage_tool_arguments(&raw, None),
596            SalvageResult::Repaired(json!({}))
597        );
598    }
599
600    #[test]
601    fn empty_string_with_required_schema_is_unsalvageable() {
602        // `{}` would just fail downstream when the schema requires `path`, so
603        // route it to the bounded re-prompt / error path instead.
604        let raw = json!("");
605        assert_eq!(
606            salvage_tool_arguments(&raw, Some(&schema())),
607            SalvageResult::Unsalvageable
608        );
609    }
610
611    #[test]
612    fn object_missing_required_key_is_unsalvageable() {
613        // Syntactically valid JSON, but missing the required `path` key — this is
614        // malformed vs the schema and cannot be locally repaired.
615        let raw = json!({ "limit": 3 });
616        assert_eq!(
617            salvage_tool_arguments(&raw, Some(&schema())),
618            SalvageResult::Unsalvageable
619        );
620    }
621
622    #[test]
623    fn object_with_uncoercible_type_is_unsalvageable() {
624        // `limit` is an integer property; "abc" cannot be coerced, so after the
625        // coercion pass the type still mismatches.
626        let raw = json!({ "path": "/foo", "limit": "abc" });
627        assert_eq!(
628            salvage_tool_arguments(&raw, Some(&schema())),
629            SalvageResult::Unsalvageable
630        );
631    }
632
633    #[test]
634    fn extracted_object_missing_required_is_unsalvageable() {
635        // Recovered from prose but still missing the required key: not a usable
636        // repair, so surface it for the re-prompt path.
637        let raw = json!("here you go: {\"limit\": 3}");
638        assert_eq!(
639            salvage_tool_arguments(&raw, Some(&schema())),
640            SalvageResult::Unsalvageable
641        );
642    }
643
644    #[test]
645    fn object_missing_required_key_without_schema_is_noop() {
646        // With no schema we cannot know a key is required, so do not flag it.
647        let raw = json!({ "limit": 3 });
648        assert_eq!(
649            salvage_tool_arguments(&raw, None),
650            SalvageResult::AlreadyValid
651        );
652    }
653
654    #[test]
655    fn raw_string_object_is_parsed() {
656        let raw = json!("{\"path\": \"/foo\"}");
657        assert_eq!(
658            salvage_tool_arguments(&raw, Some(&schema())),
659            SalvageResult::Repaired(json!({ "path": "/foo" }))
660        );
661    }
662
663    #[test]
664    fn fenced_json_block_is_unwrapped() {
665        let raw = json!("```json\n{\"path\": \"/foo\"}\n```");
666        assert_eq!(
667            salvage_tool_arguments(&raw, Some(&schema())),
668            SalvageResult::Repaired(json!({ "path": "/foo" }))
669        );
670    }
671
672    #[test]
673    fn bare_fenced_block_is_unwrapped() {
674        let raw = json!("```\n{\"path\": \"/bar\"}\n```");
675        assert_eq!(
676            salvage_tool_arguments(&raw, Some(&schema())),
677            SalvageResult::Repaired(json!({ "path": "/bar" }))
678        );
679    }
680
681    #[test]
682    fn leading_and_trailing_prose_is_stripped() {
683        let raw = json!("Sure! Here are the args: {\"path\": \"/foo\"} hope that helps");
684        assert_eq!(
685            salvage_tool_arguments(&raw, Some(&schema())),
686            SalvageResult::Repaired(json!({ "path": "/foo" }))
687        );
688    }
689
690    #[test]
691    fn trailing_commas_are_removed() {
692        let raw = json!("{\"path\": \"/foo\", \"limit\": 3,}");
693        assert_eq!(
694            salvage_tool_arguments(&raw, Some(&schema())),
695            SalvageResult::Repaired(json!({ "path": "/foo", "limit": 3 }))
696        );
697    }
698
699    #[test]
700    fn single_quotes_are_normalized() {
701        let raw = json!("{'path': '/foo', 'limit': 5}");
702        assert_eq!(
703            salvage_tool_arguments(&raw, Some(&schema())),
704            SalvageResult::Repaired(json!({ "path": "/foo", "limit": 5 }))
705        );
706    }
707
708    #[test]
709    fn apostrophe_inside_double_quoted_value_survives() {
710        let raw = json!("{\"path\": \"it's here\"}");
711        assert_eq!(
712            salvage_tool_arguments(&raw, Some(&schema())),
713            SalvageResult::Repaired(json!({ "path": "it's here" }))
714        );
715    }
716
717    #[test]
718    fn brace_inside_string_does_not_break_span() {
719        let raw = json!("prose {\"path\": \"a}b\"} more");
720        assert_eq!(
721            salvage_tool_arguments(&raw, Some(&schema())),
722            SalvageResult::Repaired(json!({ "path": "a}b" }))
723        );
724    }
725
726    #[test]
727    fn known_keys_are_coerced_against_schema() {
728        // Model emitted everything as strings; coerce against the schema types.
729        let raw = json!(
730            "{\"path\": \"/foo\", \"limit\": \"42\", \"ratio\": \"1.5\", \"recursive\": \"true\"}"
731        );
732        assert_eq!(
733            salvage_tool_arguments(&raw, Some(&schema())),
734            SalvageResult::Repaired(
735                json!({ "path": "/foo", "limit": 42, "ratio": 1.5, "recursive": true })
736            )
737        );
738    }
739
740    #[test]
741    fn object_with_string_typed_known_key_is_coerced_in_place() {
742        // Already an object, but `limit` is a string and required `path` present.
743        let raw = json!({ "path": "/foo", "limit": "7" });
744        assert_eq!(
745            salvage_tool_arguments(&raw, Some(&schema())),
746            SalvageResult::Repaired(json!({ "path": "/foo", "limit": 7 }))
747        );
748    }
749
750    #[test]
751    fn unparseable_garbage_is_unsalvageable() {
752        let raw = json!("path equals slash foo, no json here at all");
753        assert_eq!(
754            salvage_tool_arguments(&raw, Some(&schema())),
755            SalvageResult::Unsalvageable
756        );
757    }
758
759    #[test]
760    fn oversized_input_is_rejected_without_parsing() {
761        let big = format!("{{\"path\": \"{}\"}}", "a".repeat(MAX_SALVAGE_INPUT_BYTES));
762        let raw = json!(big);
763        assert_eq!(
764            salvage_tool_arguments(&raw, Some(&schema())),
765            SalvageResult::Unsalvageable
766        );
767    }
768
769    #[test]
770    fn non_object_non_string_is_unsalvageable() {
771        assert_eq!(
772            salvage_tool_arguments(&json!(42), None),
773            SalvageResult::Unsalvageable
774        );
775        assert_eq!(
776            salvage_tool_arguments(&json!([1, 2]), None),
777            SalvageResult::Unsalvageable
778        );
779    }
780
781    // ---- Bounded re-prompt / give-up ----
782
783    #[test]
784    fn outcome_reprompts_until_cap_then_gives_up() {
785        let cfg = ToolCallRepairConfig { max_reprompts: 2 };
786        assert_eq!(cfg.outcome_after_failed_salvage(0), RepairOutcome::Reprompt);
787        assert_eq!(cfg.outcome_after_failed_salvage(1), RepairOutcome::Reprompt);
788        assert_eq!(cfg.outcome_after_failed_salvage(2), RepairOutcome::GaveUp);
789        assert_eq!(cfg.outcome_after_failed_salvage(3), RepairOutcome::GaveUp);
790    }
791
792    #[test]
793    fn zero_reprompts_gives_up_immediately() {
794        let cfg = ToolCallRepairConfig { max_reprompts: 0 };
795        assert_eq!(cfg.outcome_after_failed_salvage(0), RepairOutcome::GaveUp);
796    }
797
798    #[test]
799    fn config_parses_from_json_with_defaults() {
800        assert_eq!(
801            ToolCallRepairConfig::from_json(&json!({})),
802            ToolCallRepairConfig::default()
803        );
804        assert_eq!(
805            ToolCallRepairConfig::from_json(&json!({ "max_reprompts": 3 })).max_reprompts,
806            3
807        );
808    }
809
810    #[test]
811    fn outcome_labels_are_stable() {
812        assert_eq!(RepairOutcome::LocalSalvage.label(), "local-salvage");
813        assert_eq!(RepairOutcome::Reprompt.label(), "re-prompt");
814        assert_eq!(RepairOutcome::GaveUp.label(), "gave-up");
815    }
816
817    // ---- Capability wiring ----
818
819    #[test]
820    fn capability_id_and_validation() {
821        let cap = ToolCallRepairCapability;
822        assert_eq!(cap.id(), TOOL_CALL_REPAIR_CAPABILITY_ID);
823        assert!(cap.is_guardrail());
824        assert!(cap.config_schema().is_some());
825
826        assert!(cap.validate_config(&Value::Null).is_ok());
827        assert!(cap.validate_config(&json!({})).is_ok());
828        assert!(cap.validate_config(&json!({ "max_reprompts": 2 })).is_ok());
829        assert!(cap.validate_config(&json!({ "max_reprompts": 9 })).is_err());
830        assert!(
831            cap.validate_config(&json!({ "max_reprompts": "x" }))
832                .is_err()
833        );
834    }
835}