Skip to main content

systemprompt_models/ai/
template_validation.rs

1//! Static validation of cross-tool template references in an execution plan.
2//!
3//! [`TemplateValidator`] walks each [`PlannedToolCall`]'s arguments for
4//! `$N.output.field` templates and checks them against the referenced tools'
5//! output schemas, surfacing each problem as a [`PlanValidationError`] tagged
6//! with a [`ValidationErrorKind`] (bad syntax, self/forward reference,
7//! out-of-bounds index, or a field absent from the target schema).
8//!
9//! Copyright (c) systemprompt.io — Business Source License 1.1.
10//! See <https://systemprompt.io> for licensing details.
11
12use serde::{Deserialize, Serialize};
13use serde_json::Value;
14
15use super::execution_plan::{PlannedToolCall, TemplateRef};
16use super::tools::McpTool;
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct PlanValidationError {
20    pub tool_index: usize,
21    pub argument: String,
22    pub template: String,
23    pub error: ValidationErrorKind,
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize)]
27#[serde(tag = "type", rename_all = "snake_case")]
28pub enum ValidationErrorKind {
29    InvalidTemplateSyntax,
30    IndexOutOfBounds {
31        referenced_index: usize,
32        max_valid_index: usize,
33    },
34    SelfReference,
35    ForwardReference {
36        referenced_index: usize,
37    },
38    FieldNotFound {
39        tool_name: String,
40        field: String,
41        available_fields: Vec<String>,
42    },
43    NoOutputSchema {
44        tool_name: String,
45    },
46}
47
48impl std::fmt::Display for PlanValidationError {
49    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50        match &self.error {
51            ValidationErrorKind::InvalidTemplateSyntax => {
52                write!(
53                    f,
54                    "Tool {}: Invalid template syntax '{}' for argument '{}'",
55                    self.tool_index, self.template, self.argument
56                )
57            },
58            ValidationErrorKind::IndexOutOfBounds {
59                referenced_index,
60                max_valid_index,
61            } => {
62                write!(
63                    f,
64                    "Tool {}: Template '{}' references tool {} but only tools 0-{} are available",
65                    self.tool_index, self.template, referenced_index, max_valid_index
66                )
67            },
68            ValidationErrorKind::SelfReference => {
69                write!(
70                    f,
71                    "Tool {}: Template '{}' cannot reference itself",
72                    self.tool_index, self.template
73                )
74            },
75            ValidationErrorKind::ForwardReference { referenced_index } => {
76                write!(
77                    f,
78                    "Tool {}: Template '{}' references tool {} which hasn't executed yet",
79                    self.tool_index, self.template, referenced_index
80                )
81            },
82            ValidationErrorKind::FieldNotFound {
83                tool_name,
84                field,
85                available_fields,
86            } => {
87                write!(
88                    f,
89                    "Tool {}: Template '{}' references field '{}' but tool '{}' outputs: [{}]",
90                    self.tool_index,
91                    self.template,
92                    field,
93                    tool_name,
94                    available_fields.join(", ")
95                )
96            },
97            ValidationErrorKind::NoOutputSchema { tool_name } => {
98                write!(
99                    f,
100                    "Tool {}: Template '{}' references '{}' which has no output schema",
101                    self.tool_index, self.template, tool_name
102                )
103            },
104        }
105    }
106}
107
108impl std::error::Error for PlanValidationError {}
109
110#[derive(Debug, Clone, Copy)]
111pub struct TemplateValidator;
112
113impl TemplateValidator {
114    pub fn get_tool_output_schemas(
115        calls: &[PlannedToolCall],
116        tools: &[McpTool],
117        // JSON: MCP tool-call arguments walked for `$N.output.path` templates.
118    ) -> Vec<(String, Option<Value>)> {
119        calls
120            .iter()
121            .map(|call| {
122                let output_schema = tools
123                    .iter()
124                    .find(|t| t.name == call.tool_name)
125                    .and_then(|t| t.output_schema.clone());
126                (call.tool_name.clone(), output_schema)
127            })
128            .collect()
129    }
130
131    // JSON: MCP tool-call arguments walked for `$N.output.path` templates.
132    pub fn find_templates_in_value(value: &Value) -> Vec<String> {
133        let mut templates = Vec::new();
134        Self::collect_templates(value, &mut templates);
135        templates
136    }
137
138    // JSON: MCP tool-call arguments walked for `$N.output.path` templates.
139    fn collect_templates(value: &Value, templates: &mut Vec<String>) {
140        match value {
141            Value::String(s) if s.starts_with('$') && s.contains(".output.") => {
142                templates.push(s.clone());
143            },
144            Value::Array(arr) => {
145                for v in arr {
146                    Self::collect_templates(v, templates);
147                }
148            },
149            Value::Object(obj) => {
150                for v in obj.values() {
151                    Self::collect_templates(v, templates);
152                }
153            },
154            Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => {},
155        }
156    }
157
158    // JSON: MCP tool output schema — arbitrary JSON Schema.
159    pub fn validate_plan(
160        calls: &[PlannedToolCall],
161        tool_output_schemas: &[(String, Option<Value>)],
162    ) -> Result<(), Vec<PlanValidationError>> {
163        let mut errors = Vec::new();
164
165        for (tool_index, call) in calls.iter().enumerate() {
166            for template in Self::find_templates_in_value(&call.arguments) {
167                if let Some(err) =
168                    Self::validate_template(tool_index, call, &template, tool_output_schemas)
169                {
170                    errors.push(err);
171                }
172            }
173        }
174
175        if errors.is_empty() {
176            Ok(())
177        } else {
178            Err(errors)
179        }
180    }
181
182    // JSON: MCP tool output schema — arbitrary JSON Schema.
183    fn validate_template(
184        tool_index: usize,
185        call: &PlannedToolCall,
186        template: &str,
187        tool_output_schemas: &[(String, Option<Value>)],
188    ) -> Option<PlanValidationError> {
189        let make_error = |error: ValidationErrorKind| PlanValidationError {
190            tool_index,
191            argument: Self::find_argument_for_template(&call.arguments, template),
192            template: template.to_owned(),
193            error,
194        };
195
196        let Some(template_ref) = TemplateRef::parse(template) else {
197            return Some(make_error(ValidationErrorKind::InvalidTemplateSyntax));
198        };
199
200        if template_ref.tool_index == tool_index {
201            return Some(make_error(ValidationErrorKind::SelfReference));
202        }
203        if template_ref.tool_index > tool_index {
204            return Some(make_error(ValidationErrorKind::ForwardReference {
205                referenced_index: template_ref.tool_index,
206            }));
207        }
208        if template_ref.tool_index >= tool_output_schemas.len() {
209            return Some(make_error(ValidationErrorKind::IndexOutOfBounds {
210                referenced_index: template_ref.tool_index,
211                max_valid_index: tool_output_schemas.len().saturating_sub(1),
212            }));
213        }
214
215        let (ref_tool_name, ref_output_schema) = &tool_output_schemas[template_ref.tool_index];
216
217        ref_output_schema.as_ref().map_or_else(
218            || {
219                Some(make_error(ValidationErrorKind::NoOutputSchema {
220                    tool_name: ref_tool_name.clone(),
221                }))
222            },
223            |schema| {
224                Self::validate_field_access(&template_ref, schema, ref_tool_name).map(make_error)
225            },
226        )
227    }
228
229    fn validate_field_access(
230        template_ref: &TemplateRef,
231        // JSON: JSON Schema document (the tool's `inputSchema`).
232        schema: &Value,
233        tool_name: &str,
234    ) -> Option<ValidationErrorKind> {
235        let first_field = template_ref.field_path.first()?;
236        let available_fields = Self::get_schema_fields(schema);
237
238        if available_fields.contains(first_field) {
239            None
240        } else {
241            Some(ValidationErrorKind::FieldNotFound {
242                tool_name: tool_name.to_owned(),
243                field: first_field.clone(),
244                available_fields,
245            })
246        }
247    }
248
249    // JSON: MCP tool-call arguments walked for `$N.output.path` templates.
250    fn find_argument_for_template(value: &Value, template: &str) -> String {
251        if let Value::Object(obj) = value {
252            for (key, val) in obj {
253                if let Value::String(s) = val
254                    && s == template
255                {
256                    return key.clone();
257                }
258                let nested = Self::find_argument_for_template(val, template);
259                if !nested.is_empty() {
260                    return format!("{key}.{nested}");
261                }
262            }
263        }
264        String::new()
265    }
266
267    // JSON: JSON Schema document (the tool's `inputSchema`).
268    fn get_schema_fields(schema: &Value) -> Vec<String> {
269        schema
270            .get("properties")
271            .and_then(|p| p.as_object())
272            .map_or_else(Vec::new, |obj| obj.keys().cloned().collect())
273    }
274}