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 _ => true,
110 }
111}
112
113fn parameters_to_value(params: &HashMap<String, serde_json::Value>) -> serde_json::Value {
114 let map: serde_json::Map<String, serde_json::Value> =
115 params.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
116 serde_json::Value::Object(map)
117}
118
119pub fn validate_tool_output(
126 tool: &str,
127 schema: &serde_json::Value,
128 output: &serde_json::Value,
129) -> Result<(), String> {
130 if schema_is_empty_object(schema) {
131 return Ok(());
132 }
133 let validator = jsonschema::validator_for(schema)
134 .map_err(|error| format!("tool '{tool}' has an invalid return JSON Schema: {error}"))?;
135 let errors: Vec<String> = validator
136 .iter_errors(output)
137 .map(|error| format!("{error} (at {})", error.instance_path))
138 .collect();
139 if errors.is_empty() {
140 Ok(())
141 } else {
142 Err(format!(
143 "tool '{tool}' output validation: {}",
144 errors.join("; ")
145 ))
146 }
147}
148
149pub fn validate_action(
151 action: &Action,
152 state: &dyn StateView,
153 registered_tools: &HashMap<String, ToolSchema>,
154) -> ValidationResult {
155 let mut result = ValidationResult {
156 action_id: action.id.clone(),
157 errors: Vec::new(),
158 };
159
160 if let Some(ref tool) = action.tool {
162 if let Some(schema) = registered_tools.get(tool) {
163 validate_parameters(action, tool, schema, &mut result);
164 } else {
165 result.errors.push(ValidationError {
166 action_id: action.id.clone(),
167 reason: format!("tool '{}' is not registered", tool),
168 });
169 }
170 }
171
172 if let Some(car_ir::Compensation::Tool { tool, .. }) = &action.compensation {
179 if !registered_tools.contains_key(tool) {
180 result.errors.push(ValidationError {
181 action_id: action.id.clone(),
182 reason: format!("compensation tool '{}' is not registered", tool),
183 });
184 }
185 }
186
187 for pre in &action.preconditions {
189 if let Some(error) = check_precondition(pre, state) {
190 result.errors.push(ValidationError {
191 action_id: action.id.clone(),
192 reason: error,
193 });
194 }
195 }
196
197 for dep in &action.state_dependencies {
199 if !state.key_exists(dep) {
200 result.errors.push(ValidationError {
201 action_id: action.id.clone(),
202 reason: format!("state dependency '{}' not found", dep),
203 });
204 }
205 }
206
207 result
208}
209
210#[cfg(test)]
211mod tests {
212 use super::*;
213 use car_ir::{ActionType, Precondition, ToolSchema};
214 use car_state::StateStore;
215 use serde_json::Value;
216 use std::collections::HashMap;
217
218 fn make_tool_call(tool: &str) -> Action {
219 {
220 let mut a = Action::new(ActionType::ToolCall);
221 a.id = "test".to_string();
222 a.tool = Some(tool.to_string());
223 a
224 }
225 }
226
227 fn simple_schema(name: &str) -> ToolSchema {
228 ToolSchema {
229 name: name.to_string(),
230 source: car_ir::ToolSourceKind::UserDefined,
231 description: String::new(),
232 parameters: Value::Object(Default::default()),
233 returns: None,
234 idempotent: false,
235 cache_ttl_secs: None,
236 rate_limit: None,
237 }
238 }
239
240 fn tools_map(names: &[&str]) -> HashMap<String, ToolSchema> {
241 names
242 .iter()
243 .map(|n| (n.to_string(), simple_schema(n)))
244 .collect()
245 }
246
247 #[test]
248 fn compensation_tool_is_resolved_against_the_registry() {
249 let state = StateStore::new();
253 let tools = tools_map(&["echo"]);
254 let mut action = make_tool_call("echo");
255 action.reversibility = car_ir::Reversibility::Compensable;
256 action.compensation = Some(car_ir::Compensation::Tool {
257 tool: "unecho".to_string(),
258 parameters: HashMap::new(),
259 });
260 let result = validate_action(&action, &state, &tools);
261 assert!(!result.valid());
262 assert!(result.errors.iter().any(|e| e
263 .reason
264 .contains("compensation tool 'unecho' is not registered")));
265
266 let tools = tools_map(&["echo", "unecho"]);
268 assert!(validate_action(&action, &state, &tools).valid());
269
270 let mut action = make_tool_call("echo");
273 action.compensation = Some(car_ir::Compensation::ActionRef {
274 action_id: "whatever".to_string(),
275 });
276 assert!(validate_action(&action, &state, &tools_map(&["echo"])).valid());
277 }
278
279 #[test]
280 fn unknown_tool_rejected() {
281 let state = StateStore::new();
282 let tools = tools_map(&["echo"]);
283 let action = make_tool_call("nonexistent");
284 let result = validate_action(&action, &state, &tools);
285 assert!(!result.valid());
286 assert!(result.errors[0].reason.contains("not registered"));
287 }
288
289 #[test]
290 fn known_tool_passes() {
291 let state = StateStore::new();
292 let tools = tools_map(&["echo"]);
293 let action = make_tool_call("echo");
294 let result = validate_action(&action, &state, &tools);
295 assert!(result.valid());
296 }
297
298 #[test]
299 fn precondition_eq_fails() {
300 let state = StateStore::new();
301 let pre = Precondition {
302 key: "auth".to_string(),
303 operator: "eq".to_string(),
304 value: Value::Bool(true),
305 description: String::new(),
306 };
307 assert!(check_precondition(&pre, &state).is_some());
308 }
309
310 #[test]
311 fn precondition_eq_passes() {
312 let state = StateStore::new();
313 state.set("auth", Value::Bool(true), "setup");
314 let pre = Precondition {
315 key: "auth".to_string(),
316 operator: "eq".to_string(),
317 value: Value::Bool(true),
318 description: String::new(),
319 };
320 assert!(check_precondition(&pre, &state).is_none());
321 }
322
323 #[test]
324 fn precondition_exists() {
325 let state = StateStore::new();
326 let pre = Precondition {
327 key: "x".to_string(),
328 operator: "exists".to_string(),
329 value: Value::Null,
330 description: String::new(),
331 };
332 assert!(check_precondition(&pre, &state).is_some());
333
334 state.set("x", Value::from(1), "setup");
335 assert!(check_precondition(&pre, &state).is_none());
336 }
337
338 #[test]
339 fn state_dependency_missing() {
340 let state = StateStore::new();
341 let tools: HashMap<String, ToolSchema> = HashMap::new();
342 let mut action = make_tool_call("echo");
343 action.tool = None;
344 action.action_type = ActionType::StateRead;
345 action.state_dependencies = vec!["missing".to_string()];
346 let result = validate_action(&action, &state, &tools);
347 assert!(!result.valid());
348 assert!(result.errors[0].reason.contains("not found"));
349 }
350
351 #[test]
352 fn precondition_gt() {
353 let state = StateStore::new();
354 state.set("count", Value::from(10), "setup");
355 let pre = Precondition {
356 key: "count".to_string(),
357 operator: "gt".to_string(),
358 value: Value::from(5),
359 description: String::new(),
360 };
361 assert!(check_precondition(&pre, &state).is_none());
362
363 let pre_fail = Precondition {
364 key: "count".to_string(),
365 operator: "gt".to_string(),
366 value: Value::from(20),
367 description: String::new(),
368 };
369 assert!(check_precondition(&pre_fail, &state).is_some());
370 }
371
372 #[test]
373 fn missing_required_parameter_rejected() {
374 let state = StateStore::new();
375 let mut schema = simple_schema("add");
376 schema.parameters = serde_json::json!({
377 "type": "object",
378 "properties": {
379 "a": {"type": "number"},
380 "b": {"type": "number"}
381 },
382 "required": ["a", "b"]
383 });
384 let tools: HashMap<String, ToolSchema> =
385 [("add".to_string(), schema)].into_iter().collect();
386
387 let action = make_tool_call("add"); let result = validate_action(&action, &state, &tools);
389 assert!(!result.valid());
390 assert!(result
391 .errors
392 .iter()
393 .any(|e| e.reason.contains("missing required parameter 'a'")));
394 assert!(result
395 .errors
396 .iter()
397 .any(|e| e.reason.contains("missing required parameter 'b'")));
398 }
399
400 #[test]
401 fn required_parameters_provided_passes() {
402 let state = StateStore::new();
403 let mut schema = simple_schema("add");
404 schema.parameters = serde_json::json!({
405 "type": "object",
406 "properties": {
407 "a": {"type": "number"},
408 "b": {"type": "number"}
409 },
410 "required": ["a", "b"]
411 });
412 let tools: HashMap<String, ToolSchema> =
413 [("add".to_string(), schema)].into_iter().collect();
414
415 let mut action = make_tool_call("add");
416 action.parameters = [
417 ("a".to_string(), Value::from(1)),
418 ("b".to_string(), Value::from(2)),
419 ]
420 .into();
421 let result = validate_action(&action, &state, &tools);
422 assert!(result.valid());
423 }
424
425 #[test]
426 fn type_mismatch_rejected_when_schema_registered() {
427 let state = StateStore::new();
428 let mut schema = simple_schema("read");
429 schema.parameters = serde_json::json!({
430 "type": "object",
431 "properties": {
432 "path": {"type": "string"}
433 },
434 "required": ["path"]
435 });
436 let tools: HashMap<String, ToolSchema> =
437 [("read".to_string(), schema)].into_iter().collect();
438
439 let mut action = make_tool_call("read");
440 action.parameters = [("path".to_string(), Value::from(42))].into();
441 let result = validate_action(&action, &state, &tools);
442 assert!(!result.valid(), "type mismatch should be rejected");
443 assert!(
444 result
445 .errors
446 .iter()
447 .any(|e| e.reason.contains("parameter validation")),
448 "expected jsonschema parameter validation failure, got: {:?}",
449 result.errors
450 );
451 }
452
453 #[test]
454 fn empty_object_schema_is_treated_as_legacy() {
455 assert!(schema_is_empty_object(&Value::Object(Default::default())));
459 }
460
461 #[test]
462 fn tool_output_uses_declared_return_schema() {
463 let schema = serde_json::json!({
464 "type":"object",
465 "properties":{"report_id":{"type":"string"}},
466 "required":["report_id"],
467 "additionalProperties":false
468 });
469 assert!(validate_tool_output(
470 "report_source",
471 &schema,
472 &serde_json::json!({"report_id":"r-1"})
473 )
474 .is_ok());
475 let error = validate_tool_output(
476 "report_source",
477 &schema,
478 &serde_json::json!({"report_id":42}),
479 )
480 .expect_err("wrong return type must fail");
481 assert!(error.contains("output validation"), "{error}");
482 }
483
484 #[test]
485 fn empty_return_schema_preserves_legacy_output() {
486 assert!(validate_tool_output(
487 "legacy",
488 &serde_json::json!({}),
489 &serde_json::json!({"anything":true})
490 )
491 .is_ok());
492 }
493
494 #[test]
495 fn legacy_schemaless_tool_accepts_any_parameters() {
496 let state = StateStore::new();
497 let tools = tools_map(&["echo"]); let mut action = make_tool_call("echo");
499 action.parameters = [
500 ("anything".to_string(), Value::from(42)),
501 ("else".to_string(), Value::from("string")),
502 ]
503 .into();
504 let result = validate_action(&action, &state, &tools);
505 assert!(
506 result.valid(),
507 "schemaless registration must accept anything"
508 );
509 }
510
511 #[test]
512 fn extra_parameters_allowed() {
513 let state = StateStore::new();
514 let mut schema = simple_schema("echo");
515 schema.parameters = serde_json::json!({
516 "type": "object",
517 "properties": {
518 "message": {"type": "string"}
519 },
520 "required": ["message"]
521 });
522 let tools: HashMap<String, ToolSchema> =
523 [("echo".to_string(), schema)].into_iter().collect();
524
525 let mut action = make_tool_call("echo");
526 action.parameters = [
527 ("message".to_string(), Value::from("hi")),
528 ("unexpected_extra".to_string(), Value::from(true)),
529 ]
530 .into();
531 let result = validate_action(&action, &state, &tools);
532 assert!(result.valid()); }
534}