1use car_ir::precondition::{self, StateView};
4use car_ir::{Action, Precondition, ToolSchema};
5use std::collections::HashMap;
6
7#[derive(Debug, Clone)]
9pub struct ValidationError {
10 pub action_id: String,
11 pub reason: String,
12}
13
14#[derive(Debug)]
16pub struct ValidationResult {
17 pub action_id: String,
18 pub errors: Vec<ValidationError>,
19}
20
21impl ValidationResult {
22 pub fn valid(&self) -> bool {
23 self.errors.is_empty()
24 }
25}
26
27pub fn check_precondition(pre: &Precondition, state: &dyn StateView) -> Option<String> {
29 precondition::check_precondition(pre, state)
30}
31
32fn validate_parameters(
51 action: &car_ir::Action,
52 tool: &str,
53 schema: &ToolSchema,
54 result: &mut ValidationResult,
55) {
56 if let Some(required) = schema.parameters.get("required") {
59 if let Some(required_arr) = required.as_array() {
60 for req in required_arr {
61 if let Some(param_name) = req.as_str() {
62 if !action.parameters.contains_key(param_name) {
63 result.errors.push(ValidationError {
64 action_id: action.id.clone(),
65 reason: format!(
66 "missing required parameter '{}' for tool '{}'",
67 param_name, tool
68 ),
69 });
70 }
71 }
72 }
73 }
74 }
75
76 if !schema_is_empty_object(&schema.parameters) {
80 let params_value = parameters_to_value(&action.parameters);
81 match jsonschema::validator_for(&schema.parameters) {
82 Ok(validator) => {
83 for err in validator.iter_errors(¶ms_value) {
84 result.errors.push(ValidationError {
85 action_id: action.id.clone(),
86 reason: format!(
87 "tool '{}' parameter validation: {} (at {})",
88 tool, err, err.instance_path
89 ),
90 });
91 }
92 }
93 Err(e) => {
94 result.errors.push(ValidationError {
95 action_id: action.id.clone(),
96 reason: format!(
97 "tool '{}' has an invalid registered JSON Schema: {}",
98 tool, e
99 ),
100 });
101 }
102 }
103 }
104}
105
106fn schema_is_empty_object(schema: &serde_json::Value) -> bool {
107 match schema {
108 serde_json::Value::Object(map) => map.is_empty(),
109 serde_json::Value::Bool(_) => false,
110 _ => true,
111 }
112}
113
114fn parameters_to_value(params: &HashMap<String, serde_json::Value>) -> serde_json::Value {
115 let map: serde_json::Map<String, serde_json::Value> =
116 params.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
117 serde_json::Value::Object(map)
118}
119
120pub fn validate_tool_output(
127 tool: &str,
128 schema: &serde_json::Value,
129 output: &serde_json::Value,
130) -> Result<(), String> {
131 if schema_is_empty_object(schema) {
132 return Ok(());
133 }
134 let validator = jsonschema::validator_for(schema)
135 .map_err(|error| format!("tool '{tool}' has an invalid return JSON Schema: {error}"))?;
136 let errors: Vec<String> = validator
137 .iter_errors(output)
138 .map(|error| format!("{error} (at {})", error.instance_path))
139 .collect();
140 if errors.is_empty() {
141 Ok(())
142 } else {
143 Err(format!(
144 "tool '{tool}' output validation: {}",
145 errors.join("; ")
146 ))
147 }
148}
149
150pub fn validate_action(
152 action: &Action,
153 state: &dyn StateView,
154 registered_tools: &HashMap<String, ToolSchema>,
155) -> ValidationResult {
156 let mut result = ValidationResult {
157 action_id: action.id.clone(),
158 errors: Vec::new(),
159 };
160
161 if let Some(ref tool) = action.tool {
163 if let Some(schema) = registered_tools.get(tool) {
164 validate_parameters(action, tool, schema, &mut result);
165 } else {
166 result.errors.push(ValidationError {
167 action_id: action.id.clone(),
168 reason: format!("tool '{}' is not registered", tool),
169 });
170 }
171 }
172
173 if let Some(car_ir::Compensation::Tool { tool, .. }) = &action.compensation {
180 if !registered_tools.contains_key(tool) {
181 result.errors.push(ValidationError {
182 action_id: action.id.clone(),
183 reason: format!("compensation tool '{}' is not registered", tool),
184 });
185 }
186 }
187
188 for pre in &action.preconditions {
190 if let Some(error) = check_precondition(pre, state) {
191 result.errors.push(ValidationError {
192 action_id: action.id.clone(),
193 reason: error,
194 });
195 }
196 }
197
198 for dep in &action.state_dependencies {
200 if !state.key_exists(dep) {
201 result.errors.push(ValidationError {
202 action_id: action.id.clone(),
203 reason: format!("state dependency '{}' not found", dep),
204 });
205 }
206 }
207
208 result
209}
210
211#[cfg(test)]
212mod tests {
213 use super::*;
214 use car_ir::{ActionType, Precondition, ToolSchema};
215 use car_state::StateStore;
216 use serde_json::Value;
217 use std::collections::HashMap;
218
219 fn make_tool_call(tool: &str) -> Action {
220 {
221 let mut a = Action::new(ActionType::ToolCall);
222 a.id = "test".to_string();
223 a.tool = Some(tool.to_string());
224 a
225 }
226 }
227
228 fn simple_schema(name: &str) -> ToolSchema {
229 ToolSchema {
230 name: name.to_string(),
231 source: car_ir::ToolSourceKind::UserDefined,
232 description: String::new(),
233 parameters: Value::Object(Default::default()),
234 returns: None,
235 idempotent: false,
236 cache_ttl_secs: None,
237 rate_limit: None,
238 }
239 }
240
241 fn tools_map(names: &[&str]) -> HashMap<String, ToolSchema> {
242 names
243 .iter()
244 .map(|n| (n.to_string(), simple_schema(n)))
245 .collect()
246 }
247
248 #[test]
249 fn compensation_tool_is_resolved_against_the_registry() {
250 let state = StateStore::new();
254 let tools = tools_map(&["echo"]);
255 let mut action = make_tool_call("echo");
256 action.reversibility = car_ir::Reversibility::Compensable;
257 action.compensation = Some(car_ir::Compensation::Tool {
258 tool: "unecho".to_string(),
259 parameters: HashMap::new(),
260 });
261 let result = validate_action(&action, &state, &tools);
262 assert!(!result.valid());
263 assert!(result.errors.iter().any(|e| e
264 .reason
265 .contains("compensation tool 'unecho' is not registered")));
266
267 let tools = tools_map(&["echo", "unecho"]);
269 assert!(validate_action(&action, &state, &tools).valid());
270
271 let mut action = make_tool_call("echo");
274 action.compensation = Some(car_ir::Compensation::ActionRef {
275 action_id: "whatever".to_string(),
276 });
277 assert!(validate_action(&action, &state, &tools_map(&["echo"])).valid());
278 }
279
280 #[test]
281 fn unknown_tool_rejected() {
282 let state = StateStore::new();
283 let tools = tools_map(&["echo"]);
284 let action = make_tool_call("nonexistent");
285 let result = validate_action(&action, &state, &tools);
286 assert!(!result.valid());
287 assert!(result.errors[0].reason.contains("not registered"));
288 }
289
290 #[test]
291 fn known_tool_passes() {
292 let state = StateStore::new();
293 let tools = tools_map(&["echo"]);
294 let action = make_tool_call("echo");
295 let result = validate_action(&action, &state, &tools);
296 assert!(result.valid());
297 }
298
299 #[test]
300 fn precondition_eq_fails() {
301 let state = StateStore::new();
302 let pre = Precondition {
303 key: "auth".to_string(),
304 operator: "eq".to_string(),
305 value: Value::Bool(true),
306 description: String::new(),
307 };
308 assert!(check_precondition(&pre, &state).is_some());
309 }
310
311 #[test]
312 fn precondition_eq_passes() {
313 let state = StateStore::new();
314 state.set("auth", Value::Bool(true), "setup");
315 let pre = Precondition {
316 key: "auth".to_string(),
317 operator: "eq".to_string(),
318 value: Value::Bool(true),
319 description: String::new(),
320 };
321 assert!(check_precondition(&pre, &state).is_none());
322 }
323
324 #[test]
325 fn precondition_exists() {
326 let state = StateStore::new();
327 let pre = Precondition {
328 key: "x".to_string(),
329 operator: "exists".to_string(),
330 value: Value::Null,
331 description: String::new(),
332 };
333 assert!(check_precondition(&pre, &state).is_some());
334
335 state.set("x", Value::from(1), "setup");
336 assert!(check_precondition(&pre, &state).is_none());
337 }
338
339 #[test]
340 fn state_dependency_missing() {
341 let state = StateStore::new();
342 let tools: HashMap<String, ToolSchema> = HashMap::new();
343 let mut action = make_tool_call("echo");
344 action.tool = None;
345 action.action_type = ActionType::StateRead;
346 action.state_dependencies = vec!["missing".to_string()];
347 let result = validate_action(&action, &state, &tools);
348 assert!(!result.valid());
349 assert!(result.errors[0].reason.contains("not found"));
350 }
351
352 #[test]
353 fn precondition_gt() {
354 let state = StateStore::new();
355 state.set("count", Value::from(10), "setup");
356 let pre = Precondition {
357 key: "count".to_string(),
358 operator: "gt".to_string(),
359 value: Value::from(5),
360 description: String::new(),
361 };
362 assert!(check_precondition(&pre, &state).is_none());
363
364 let pre_fail = Precondition {
365 key: "count".to_string(),
366 operator: "gt".to_string(),
367 value: Value::from(20),
368 description: String::new(),
369 };
370 assert!(check_precondition(&pre_fail, &state).is_some());
371 }
372
373 #[test]
374 fn missing_required_parameter_rejected() {
375 let state = StateStore::new();
376 let mut schema = simple_schema("add");
377 schema.parameters = serde_json::json!({
378 "type": "object",
379 "properties": {
380 "a": {"type": "number"},
381 "b": {"type": "number"}
382 },
383 "required": ["a", "b"]
384 });
385 let tools: HashMap<String, ToolSchema> =
386 [("add".to_string(), schema)].into_iter().collect();
387
388 let action = make_tool_call("add"); let result = validate_action(&action, &state, &tools);
390 assert!(!result.valid());
391 assert!(result
392 .errors
393 .iter()
394 .any(|e| e.reason.contains("missing required parameter 'a'")));
395 assert!(result
396 .errors
397 .iter()
398 .any(|e| e.reason.contains("missing required parameter 'b'")));
399 }
400
401 #[test]
402 fn required_parameters_provided_passes() {
403 let state = StateStore::new();
404 let mut schema = simple_schema("add");
405 schema.parameters = serde_json::json!({
406 "type": "object",
407 "properties": {
408 "a": {"type": "number"},
409 "b": {"type": "number"}
410 },
411 "required": ["a", "b"]
412 });
413 let tools: HashMap<String, ToolSchema> =
414 [("add".to_string(), schema)].into_iter().collect();
415
416 let mut action = make_tool_call("add");
417 action.parameters = [
418 ("a".to_string(), Value::from(1)),
419 ("b".to_string(), Value::from(2)),
420 ]
421 .into();
422 let result = validate_action(&action, &state, &tools);
423 assert!(result.valid());
424 }
425
426 #[test]
427 fn type_mismatch_rejected_when_schema_registered() {
428 let state = StateStore::new();
429 let mut schema = simple_schema("read");
430 schema.parameters = serde_json::json!({
431 "type": "object",
432 "properties": {
433 "path": {"type": "string"}
434 },
435 "required": ["path"]
436 });
437 let tools: HashMap<String, ToolSchema> =
438 [("read".to_string(), schema)].into_iter().collect();
439
440 let mut action = make_tool_call("read");
441 action.parameters = [("path".to_string(), Value::from(42))].into();
442 let result = validate_action(&action, &state, &tools);
443 assert!(!result.valid(), "type mismatch should be rejected");
444 assert!(
445 result
446 .errors
447 .iter()
448 .any(|e| e.reason.contains("parameter validation")),
449 "expected jsonschema parameter validation failure, got: {:?}",
450 result.errors
451 );
452 }
453
454 #[test]
455 fn empty_object_schema_is_treated_as_legacy() {
456 assert!(schema_is_empty_object(&Value::Object(Default::default())));
460 }
461
462 #[test]
463 fn tool_output_uses_declared_return_schema() {
464 let schema = serde_json::json!({
465 "type":"object",
466 "properties":{"report_id":{"type":"string"}},
467 "required":["report_id"],
468 "additionalProperties":false
469 });
470 assert!(validate_tool_output(
471 "report_source",
472 &schema,
473 &serde_json::json!({"report_id":"r-1"})
474 )
475 .is_ok());
476 let error = validate_tool_output(
477 "report_source",
478 &schema,
479 &serde_json::json!({"report_id":42}),
480 )
481 .expect_err("wrong return type must fail");
482 assert!(error.contains("output validation"), "{error}");
483 }
484
485 #[test]
486 fn empty_return_schema_preserves_legacy_output() {
487 assert!(validate_tool_output(
488 "legacy",
489 &serde_json::json!({}),
490 &serde_json::json!({"anything":true})
491 )
492 .is_ok());
493 }
494
495 #[test]
496 fn legacy_schemaless_tool_accepts_any_parameters() {
497 let state = StateStore::new();
498 let tools = tools_map(&["echo"]); let mut action = make_tool_call("echo");
500 action.parameters = [
501 ("anything".to_string(), Value::from(42)),
502 ("else".to_string(), Value::from("string")),
503 ]
504 .into();
505 let result = validate_action(&action, &state, &tools);
506 assert!(
507 result.valid(),
508 "schemaless registration must accept anything"
509 );
510 }
511
512 #[test]
513 fn extra_parameters_allowed() {
514 let state = StateStore::new();
515 let mut schema = simple_schema("echo");
516 schema.parameters = serde_json::json!({
517 "type": "object",
518 "properties": {
519 "message": {"type": "string"}
520 },
521 "required": ["message"]
522 });
523 let tools: HashMap<String, ToolSchema> =
524 [("echo".to_string(), schema)].into_iter().collect();
525
526 let mut action = make_tool_call("echo");
527 action.parameters = [
528 ("message".to_string(), Value::from("hi")),
529 ("unexpected_extra".to_string(), Value::from(true)),
530 ]
531 .into();
532 let result = validate_action(&action, &state, &tools);
533 assert!(result.valid()); }
535 #[test]
536 fn full_terminal_schemas_reject_constraints_before_dispatch() {
537 let state = StateStore::new();
538 for (schema, invalid, valid) in [
539 (
540 serde_json::json!({"properties":{"reason":{"enum":["yes"]}}}),
541 serde_json::json!({"reason":"no"}),
542 serde_json::json!({"reason":"yes"}),
543 ),
544 (
545 serde_json::json!({"properties":{"nested":{"type":"object","required":["reason"]}}}),
546 serde_json::json!({"nested":{}}),
547 serde_json::json!({"nested":{"reason":"yes"}}),
548 ),
549 (
550 serde_json::json!({"additionalProperties":false,"properties":{"reason":{}}}),
551 serde_json::json!({"extra":1}),
552 serde_json::json!({"reason":"yes"}),
553 ),
554 (
555 serde_json::json!({"properties":{"items":{"type":"array","items":{"type":"string"}}}}),
556 serde_json::json!({"items":[1]}),
557 serde_json::json!({"items":["yes"]}),
558 ),
559 (
560 serde_json::json!({"properties":{"reason":{"minLength":3}}}),
561 serde_json::json!({"reason":"x"}),
562 serde_json::json!({"reason":"yes"}),
563 ),
564 ] {
565 let mut tool = simple_schema("end");
566 tool.parameters = schema;
567 let tools = [("end".to_string(), tool)].into_iter().collect();
568 let mut action = make_tool_call("end");
569 action.parameters = serde_json::from_value(invalid).unwrap();
570 assert!(!validate_action(&action, &state, &tools).valid());
571 action.parameters = serde_json::from_value(valid).unwrap();
572 assert!(validate_action(&action, &state, &tools).valid());
573 }
574 }
575
576 #[test]
577 fn boolean_parameter_schemas_are_not_schemaless() {
578 let state = StateStore::new();
579 for allowed in [false, true] {
580 let mut tool = simple_schema("end");
581 tool.parameters = serde_json::Value::Bool(allowed);
582 let tools = [("end".to_string(), tool)].into_iter().collect();
583 assert_eq!(
584 validate_action(&make_tool_call("end"), &state, &tools).valid(),
585 allowed
586 );
587 }
588 }
589}