Skip to main content

vtcode_core/tools/validation/
mod.rs

1pub mod commands;
2pub mod paths;
3
4use jsonschema::ValidationError;
5use jsonschema::error::{TypeKind, ValidationErrorKind};
6use serde_json::Value;
7
8/// Extract a condensed representation of a JSON Schema for error hints.
9///
10/// Returns a JSON object with:
11/// - `required`: array of required field names
12/// - `properties`: object mapping field name -> its declared `type` (or `"any"` if absent)
13///   with enum choices and numeric bounds included when present
14///
15/// This is intentionally compact so it can be included in validation error
16/// payloads without bloating the context.
17pub fn condensed_schema_hint(schema: &Value) -> Option<Value> {
18    let properties = schema.get("properties").and_then(Value::as_object)?;
19    let required: Vec<Value> = schema.get("required").and_then(Value::as_array).cloned().unwrap_or_default();
20
21    let mut prop_types = serde_json::Map::new();
22    for (name, def) in properties {
23        let type_str = def.get("type").and_then(Value::as_str).unwrap_or("any").to_string();
24        // Surface enum options inline (e.g. "string(grep|glob|list)") so a
25        // model that passed an invalid value can self-correct instead of
26        // retrying blind with the same malformed arguments.
27        let mut rendered = match def.get("enum").and_then(Value::as_array) {
28            Some(options) if !options.is_empty() => {
29                let joined = options
30                    .iter()
31                    .map(|option| match option {
32                        Value::String(s) => s.clone(),
33                        other => other.to_string(),
34                    })
35                    .collect::<Vec<_>>()
36                    .join("|");
37                format!("{type_str}({joined})")
38            }
39            _ => type_str,
40        };
41
42        let bounds = [("min", def.get("minimum")), ("max", def.get("maximum"))]
43            .into_iter()
44            .filter_map(|(label, value)| value.map(|value| format!("{label}={value}")))
45            .collect::<Vec<_>>();
46        if !bounds.is_empty() {
47            if rendered.ends_with(')') {
48                rendered.pop();
49                rendered.push(',');
50            } else {
51                rendered.push('(');
52            }
53            rendered.push_str(&bounds.join(","));
54            rendered.push(')');
55        }
56        prop_types.insert(name.clone(), Value::String(rendered));
57    }
58
59    Some(serde_json::json!({
60        "required": required,
61        "properties": prop_types,
62    }))
63}
64
65/// Render a `jsonschema` validation failure into a model-actionable message.
66///
67/// The default jsonschema error only quotes the offending *value*
68/// (e.g. `"content" is not one of "github", "sarif" ...`), which hides which
69/// field was wrong and led agents to retry the same malformed call blindly for
70/// many turns. This prefixes the failure with its JSON-pointer path and, for
71/// enum/const/type failures, lists the accepted values so the model can
72/// self-correct in a single pass instead of burning the tool budget.
73pub fn describe_jsonschema_error(err: &ValidationError<'_>) -> String {
74    let path = err.instance_path().to_string();
75    let path_label = if path.is_empty() { "(root)".to_string() } else { path };
76    let schema_path = err.schema_path().to_string();
77    let value = err.instance();
78    let raw_value_str = match &**value {
79        Value::String(s) => format!("\"{s}\""),
80        other => other.to_string(),
81    };
82    let value_str = truncate_for_error(&raw_value_str, 500);
83    match err.kind() {
84        ValidationErrorKind::Enum { options } => {
85            let opts = options
86                .as_array()
87                .map(|items| {
88                    items
89                        .iter()
90                        .map(|v| match v {
91                            Value::String(s) => s.clone(),
92                            other => other.to_string(),
93                        })
94                        .collect::<Vec<_>>()
95                        .join(", ")
96                })
97                .unwrap_or_default();
98            format!("field '{path_label}' value {value_str} is not one of the allowed enum: [{opts}]")
99        }
100        ValidationErrorKind::Constant { expected_value } => {
101            format!("field '{path_label}' value {value_str} must equal the required const {expected_value}")
102        }
103        ValidationErrorKind::Type { kind } => {
104            let expected = match kind {
105                TypeKind::Single(t) => t.to_string(),
106                TypeKind::Multiple(set) => format!("{set:?}"),
107            };
108            format!("field '{path_label}' has wrong type: expected {expected}, got {value_str}")
109        }
110        ValidationErrorKind::Required { property } => {
111            let name = match property {
112                Value::String(s) => s.clone(),
113                other => other.to_string(),
114            };
115            format!("field '{path_label}' missing required property '{name}' (schema {schema_path})")
116        }
117        ValidationErrorKind::AdditionalProperties { unexpected } => {
118            format!("unexpected field(s) {unexpected:?} not allowed by the schema (did you use the right field name?)")
119        }
120        ValidationErrorKind::Not { schema } => {
121            let forbidden = forbidden_properties_from_not_schema(schema);
122            if forbidden.is_empty() {
123                let schema_str = truncate_for_error(&schema.to_string(), 300);
124                format!(
125                    "field '{path_label}' value {value_str} is forbidden by schema {schema_path} (not {schema_str}); remove the forbidden field(s) and retry"
126                )
127            } else {
128                let present = present_forbidden_fields(value, &forbidden);
129                let offending = if present.is_empty() { forbidden.clone() } else { present };
130                format!(
131                    "field '{path_label}' must not include {offending:?} for this action (schema {schema_path} forbids them); remove {offending:?} and retry"
132                )
133            }
134        }
135        ValidationErrorKind::AnyOf { context } => {
136            describe_combinator_error(&path_label, &schema_path, "anyOf", context)
137        }
138        ValidationErrorKind::OneOfNotValid { context } => {
139            describe_combinator_error(&path_label, &schema_path, "oneOf", context)
140        }
141        ValidationErrorKind::OneOfMultipleValid { context } => {
142            let _ = context;
143            format!(
144                "field '{path_label}' value {value_str} matches more than one allowed shape (schema {schema_path} oneOf); make the call match exactly one variant"
145            )
146        }
147        ValidationErrorKind::FalseSchema => {
148            format!("field '{path_label}' value {value_str} is not allowed here (schema {schema_path} disallows it)")
149        }
150        _ => {
151            // Fall back to the validator's own message plus the schema location
152            // so conditional (`if`/`then`/`not`) failures don't collapse to a
153            // bare value dump. Truncate to keep large objects out of context.
154            let detail = truncate_for_error(&err.to_string(), 500);
155            if schema_path.is_empty() {
156                format!("field '{path_label}' failed validation: {detail}")
157            } else {
158                format!("field '{path_label}' failed validation (schema {schema_path}): {detail}")
159            }
160        }
161    }
162}
163
164fn truncate_for_error(raw: &str, limit: usize) -> String {
165    vtcode_commons::formatting::truncate_byte_budget(raw, limit, "…")
166}
167
168fn forbidden_properties_from_not_schema(schema: &Value) -> Vec<String> {
169    // Only `required` (direct or inside anyOf/oneOf/allOf branches) forbids
170    // presence. A bare `properties` entry without `required` does not forbid
171    // the key, so it must not be reported as forbidden.
172    let mut out = Vec::new();
173    if let Some(required) = schema.get("required").and_then(Value::as_array) {
174        for entry in required {
175            if let Some(name) = entry.as_str() {
176                out.push(name.to_string());
177            }
178        }
179    }
180    for key in ["anyOf", "oneOf", "allOf"] {
181        if let Some(branches) = schema.get(key).and_then(Value::as_array) {
182            for branch in branches {
183                for name in forbidden_properties_from_not_schema(branch) {
184                    if !out.contains(&name) {
185                        out.push(name);
186                    }
187                }
188            }
189        }
190    }
191    out
192}
193
194fn present_forbidden_fields(instance: &Value, forbidden: &[String]) -> Vec<String> {
195    let Some(map) = instance.as_object() else {
196        return Vec::new();
197    };
198    forbidden.iter().filter(|name| map.contains_key(*name)).cloned().collect()
199}
200
201fn describe_combinator_error(
202    path_label: &str,
203    schema_path: &str,
204    keyword: &str,
205    context: &[Vec<ValidationError<'static>>],
206) -> String {
207    // Label branches so the model can tell variants apart, and render inner
208    // errors through the same path-aware describer (one level only: nested
209    // combinators fall back to their Display to avoid exponential expansion).
210    let mut variants: Vec<String> = Vec::new();
211    for (idx, branch) in context.iter().take(3).enumerate() {
212        let mut parts: Vec<String> = Vec::new();
213        for error in branch.iter().take(2) {
214            let rendered = match error.kind() {
215                ValidationErrorKind::AnyOf { .. }
216                | ValidationErrorKind::OneOfNotValid { .. }
217                | ValidationErrorKind::OneOfMultipleValid { .. } => truncate_for_error(&error.to_string(), 300),
218                _ => truncate_for_error(&describe_jsonschema_error(error), 300),
219            };
220            parts.push(rendered);
221        }
222        if parts.is_empty() {
223            continue;
224        }
225        variants.push(format!("variant {}: {}", idx + 1, parts.join(" + ")));
226    }
227    if variants.is_empty() {
228        format!(
229            "field '{path_label}' does not match any allowed shape (schema {schema_path} {keyword}); adjust the arguments to match one variant and retry"
230        )
231    } else {
232        format!(
233            "field '{path_label}' does not match any allowed shape (schema {schema_path} {keyword}): {}",
234            variants.join(" | ")
235        )
236    }
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242    use serde_json::json;
243
244    #[test]
245    fn enum_error_names_field_and_valid_options() {
246        let schema = json!({
247            "type": "object",
248            "properties": {
249                "format": {"type": "string", "enum": ["github", "sarif", "files_with_matches", "count"]}
250            }
251        });
252        let instance = json!({ "format": "content" });
253        let error = jsonschema::validate(&schema, &instance).unwrap_err();
254        let msg = describe_jsonschema_error(&error);
255        assert!(msg.contains("field '/format'"), "msg was: {msg}");
256        assert!(msg.contains("\"content\""), "msg was: {msg}");
257        assert!(msg.contains("github, sarif, files_with_matches, count"), "msg was: {msg}");
258    }
259
260    #[test]
261    fn multiple_errors_are_described_independently() {
262        // A single invalid call can violate several schema constraints at once.
263        // Each failure must be describable on its own so a caller can join them
264        // into one self-correction message.
265        let schema = json!({
266            "type": "object",
267            "required": ["action", "format"],
268            "properties": {
269                "action": {"type": "string"},
270                "format": {"type": "string", "enum": ["github", "sarif"]}
271            }
272        });
273        let instance = json!({ "format": "content" });
274        let validator = jsonschema::validator_for(&schema).expect("schema is valid");
275        let errors: Vec<_> = validator.iter_errors(&instance).collect();
276        assert!(errors.len() >= 2, "expected both missing-action and bad-format errors, got {}", errors.len());
277        let messages: Vec<String> = errors.iter().map(describe_jsonschema_error).collect();
278        assert!(messages.iter().any(|m| m.contains("missing required property 'action'")));
279        assert!(
280            messages
281                .iter()
282                .any(|m| m.contains("field '/format'") && m.contains("\"content\""))
283        );
284    }
285
286    #[test]
287    fn missing_required_names_property() {
288        let schema = json!({
289            "type": "object",
290            "required": ["action"],
291            "properties": { "action": {"type": "string"} }
292        });
293        let instance = json!({});
294        let error = jsonschema::validate(&schema, &instance).unwrap_err();
295        let msg = describe_jsonschema_error(&error);
296        assert!(msg.contains("missing required property 'action'"), "msg was: {msg}");
297    }
298
299    #[test]
300    fn numeric_bounds_are_included_in_schema_hint() {
301        let schema = json!({
302            "type": "object",
303            "properties": {
304                "max_results": {"type": "integer", "minimum": 1, "maximum": 100}
305            }
306        });
307
308        let hint = condensed_schema_hint(&schema).expect("object schema should produce a hint");
309        assert_eq!(hint["properties"]["max_results"], "integer(min=1,max=100)");
310    }
311
312    #[test]
313    fn not_error_names_forbidden_fields_and_schema_path() {
314        let schema = json!({
315            "type": "object",
316            "properties": {
317                "action": {"type": "string"},
318                "index": {"type": "integer"},
319                "index_path": {"type": "string"}
320            },
321            "required": ["action"],
322            "allOf": [
323                {
324                    "if": {"properties": {"action": {"const": "create"}}, "required": ["action"]},
325                    "then": {"not": {"anyOf": [{"required": ["index"]}, {"required": ["index_path"]}]}}
326                }
327            ]
328        });
329        let instance = json!({"action": "create", "title": "README plan", "index": 1, "index_path": "1"});
330        let validator = jsonschema::validator_for(&schema).expect("schema is valid");
331        let errors: Vec<_> = validator.iter_errors(&instance).collect();
332        assert!(!errors.is_empty(), "expected a not-violation");
333        let messages: Vec<String> = errors.iter().map(describe_jsonschema_error).collect();
334        let combined = messages.join("; ");
335        assert!(combined.contains("must not include"), "msg was: {combined}");
336        assert!(combined.contains("index"), "msg was: {combined}");
337        assert!(
338            !combined.contains("failed validation: {\"action\""),
339            "should not dump the whole object, got: {combined}"
340        );
341    }
342
343    #[test]
344    fn fallback_includes_schema_path_instead_of_bare_dump() {
345        let schema = json!({
346            "type": "object",
347            "properties": {"name": {"type": "string", "minLength": 5}}
348        });
349        let instance = json!({"name": "abc"});
350        let validator = jsonschema::validator_for(&schema).expect("schema is valid");
351        let errors: Vec<_> = validator.iter_errors(&instance).collect();
352        assert!(!errors.is_empty());
353        let msg = describe_jsonschema_error(&errors[0]);
354        assert!(msg.contains("field '"), "msg was: {msg}");
355        assert!(msg.contains("/properties/name"), "msg was: {msg}");
356    }
357
358    #[test]
359    fn required_error_includes_instance_and_schema_location() {
360        let schema = json!({
361            "type": "object",
362            "properties": {
363                "items": {
364                    "type": "array",
365                    "items": {
366                        "type": "object",
367                        "properties": {"description": {"type": "string"}},
368                        "required": ["description"]
369                    }
370                }
371            },
372            "required": ["items"]
373        });
374        // Asymmetric: first element valid, second missing description.
375        let instance = json!({"items": [{"description": "ok"}, {"status": "completed"}]});
376        let validator = jsonschema::validator_for(&schema).expect("schema is valid");
377        let errors: Vec<_> = validator.iter_errors(&instance).collect();
378        assert!(!errors.is_empty(), "expected a nested required violation");
379        let combined = errors.iter().map(describe_jsonschema_error).collect::<Vec<_>>().join("; ");
380        assert!(combined.contains("missing required property 'description'"), "msg was: {combined}");
381        assert!(combined.contains("/items/1"), "msg was: {combined}");
382    }
383
384    #[test]
385    fn combinator_error_labels_variants() {
386        let schema = json!({
387            "type": "object",
388            "properties": {"action": {"type": "string"}},
389            "required": ["action"],
390            "allOf": [
391                {
392                    "if": {"properties": {"action": {"const": "update"}}, "required": ["action"]},
393                    "then": {
394                        "anyOf": [
395                            {"required": ["index"]},
396                            {"required": ["index_path"]}
397                        ]
398                    }
399                }
400            ]
401        });
402        let instance = json!({"action": "update"});
403        let validator = jsonschema::validator_for(&schema).expect("schema is valid");
404        let errors: Vec<_> = validator.iter_errors(&instance).collect();
405        assert!(!errors.is_empty(), "expected an anyOf violation");
406        let combined = errors.iter().map(describe_jsonschema_error).collect::<Vec<_>>().join("; ");
407        assert!(combined.contains("does not match any allowed shape"), "msg was: {combined}");
408        assert!(combined.contains("variant 1"), "msg was: {combined}");
409    }
410}