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    pub fn validate_plan(
159        calls: &[PlannedToolCall],
160        tool_output_schemas: &[(String, Option<Value>)],
161    ) -> Result<(), Vec<PlanValidationError>> {
162        let mut errors = Vec::new();
163
164        for (tool_index, call) in calls.iter().enumerate() {
165            for template in Self::find_templates_in_value(&call.arguments) {
166                if let Some(err) =
167                    Self::validate_template(tool_index, call, &template, tool_output_schemas)
168                {
169                    errors.push(err);
170                }
171            }
172        }
173
174        if errors.is_empty() {
175            Ok(())
176        } else {
177            Err(errors)
178        }
179    }
180
181    fn validate_template(
182        tool_index: usize,
183        call: &PlannedToolCall,
184        template: &str,
185        tool_output_schemas: &[(String, Option<Value>)],
186    ) -> Option<PlanValidationError> {
187        let make_error = |error: ValidationErrorKind| PlanValidationError {
188            tool_index,
189            argument: Self::find_argument_for_template(&call.arguments, template),
190            template: template.to_owned(),
191            error,
192        };
193
194        let Some(template_ref) = TemplateRef::parse(template) else {
195            return Some(make_error(ValidationErrorKind::InvalidTemplateSyntax));
196        };
197
198        if template_ref.tool_index == tool_index {
199            return Some(make_error(ValidationErrorKind::SelfReference));
200        }
201        if template_ref.tool_index > tool_index {
202            return Some(make_error(ValidationErrorKind::ForwardReference {
203                referenced_index: template_ref.tool_index,
204            }));
205        }
206        if template_ref.tool_index >= tool_output_schemas.len() {
207            return Some(make_error(ValidationErrorKind::IndexOutOfBounds {
208                referenced_index: template_ref.tool_index,
209                max_valid_index: tool_output_schemas.len().saturating_sub(1),
210            }));
211        }
212
213        let (ref_tool_name, ref_output_schema) = &tool_output_schemas[template_ref.tool_index];
214
215        ref_output_schema.as_ref().map_or_else(
216            || {
217                Some(make_error(ValidationErrorKind::NoOutputSchema {
218                    tool_name: ref_tool_name.clone(),
219                }))
220            },
221            |schema| {
222                Self::validate_field_access(&template_ref, schema, ref_tool_name).map(make_error)
223            },
224        )
225    }
226
227    fn validate_field_access(
228        template_ref: &TemplateRef,
229        // JSON: JSON Schema document (the tool's `inputSchema`).
230        schema: &Value,
231        tool_name: &str,
232    ) -> Option<ValidationErrorKind> {
233        let first_field = template_ref.field_path.first()?;
234        let available_fields = Self::get_schema_fields(schema);
235
236        if available_fields.contains(first_field) {
237            None
238        } else {
239            Some(ValidationErrorKind::FieldNotFound {
240                tool_name: tool_name.to_owned(),
241                field: first_field.clone(),
242                available_fields,
243            })
244        }
245    }
246
247    // JSON: MCP tool-call arguments walked for `$N.output.path` templates.
248    fn find_argument_for_template(value: &Value, template: &str) -> String {
249        if let Value::Object(obj) = value {
250            for (key, val) in obj {
251                if let Value::String(s) = val
252                    && s == template
253                {
254                    return key.clone();
255                }
256                let nested = Self::find_argument_for_template(val, template);
257                if !nested.is_empty() {
258                    return format!("{key}.{nested}");
259                }
260            }
261        }
262        String::new()
263    }
264
265    // JSON: JSON Schema document (the tool's `inputSchema`).
266    fn get_schema_fields(schema: &Value) -> Vec<String> {
267        schema
268            .get("properties")
269            .and_then(|p| p.as_object())
270            .map_or_else(Vec::new, |obj| obj.keys().cloned().collect())
271    }
272}