everruns_core/capabilities/
tool_call_repair.rs1use 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
49pub const DEFAULT_MAX_REPROMPTS: u32 = 1;
53
54pub const MAX_SALVAGE_INPUT_BYTES: usize = 256 * 1024;
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub enum RepairOutcome {
62 LocalSalvage,
64 Reprompt,
66 GaveUp,
68}
69
70impl RepairOutcome {
71 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#[derive(Debug, Clone, PartialEq)]
83pub enum SalvageResult {
84 AlreadyValid,
87 Repaired(Value),
90 Unsalvageable,
92}
93
94pub fn salvage_tool_arguments(raw: &Value, schema: Option<&Value>) -> SalvageResult {
110 if let Value::Object(_) = raw {
116 let coerced = coerce_known_keys(raw.clone(), schema);
117 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 let Some(raw_str) = raw.as_str() else {
137 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 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 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
175fn 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 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 _ => true,
217 };
218 if !matches {
219 return true;
220 }
221 }
222 }
223
224 false
225}
226
227fn extract_json_object(input: &str) -> Option<Value> {
233 let candidate = strip_code_fences(input);
234
235 if let Ok(value @ Value::Object(_)) = serde_json::from_str::<Value>(candidate.trim()) {
237 return Some(value);
238 }
239
240 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 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
258fn 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 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
273fn 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 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
313fn relax_json(slice: &str) -> String {
317 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 strip_trailing_commas(&out)
349}
350
351fn 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 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 continue;
385 }
386 }
387 out.push(ch);
388 }
389 out
390}
391
392fn 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
435pub struct ToolCallRepairConfig {
436 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 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 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
476pub 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
547pub 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 #[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 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 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 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 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 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 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 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 #[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 #[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}