Skip to main content

ingot_runtime/
schema.rs

1//! Ingot types to JSON Schema.
2//!
3//! This is where the type system stops being decorative. `ask<string[]>` becomes
4//! a schema the provider constrains its output to, and a response that does not
5//! match is an error rather than a best-effort parse.
6//!
7//! Two rules shape the mapping:
8//!
9//! * **Prose is not constrained.** `text` and `markdown` ask for writing, so
10//!   they get a plain completion. Constraining them to a JSON string would mean
11//!   receiving markdown wrapped in JSON escaping — worse than not asking.
12//! * **Non-object schemas are wrapped.** Provider structured-output
13//!   implementations generally expect an object at the schema root, so a scalar
14//!   or array schema is nested under `value` and unwrapped on the way back.
15
16use std::collections::BTreeMap;
17
18use ingot_ir::RecordType;
19use serde_json::{json, Value};
20
21/// How a declared response type is requested from a provider.
22#[derive(Debug, Clone, PartialEq)]
23pub enum ResponseShape {
24    /// Take the completion text verbatim. Used for `text` and `markdown`.
25    Prose,
26    /// Parse the completion as JSON without constraining it. Used for `json`,
27    /// which by definition has no fixed shape to constrain to.
28    FreeJson,
29    /// Constrain the completion to a schema.
30    Schema {
31        /// The schema sent to the provider. Always an object at the root.
32        schema: Value,
33        /// True when the real type was nested under `value` and must be
34        /// unwrapped from the response.
35        wrapped: bool,
36    },
37}
38
39/// A response type that cannot be requested from a model.
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct UnsupportedResponseType {
42    pub ty: String,
43    pub reason: &'static str,
44}
45
46/// Work out how to request `ty`, resolving record types against `types`.
47pub fn response_shape(
48    ty: &str,
49    types: &BTreeMap<String, RecordType>,
50) -> Result<ResponseShape, UnsupportedResponseType> {
51    match ty {
52        "text" | "markdown" => return Ok(ResponseShape::Prose),
53        "json" => return Ok(ResponseShape::FreeJson),
54        "bytes" | "file" => {
55            return Err(UnsupportedResponseType {
56                ty: ty.to_string(),
57                reason: "a model cannot produce binary content directly; \
58                         use a tool that writes the file and return its handle",
59            })
60        }
61        _ => {}
62    }
63
64    let schema = type_schema(ty, types)?;
65    let is_object = schema.get("type").and_then(Value::as_str) == Some("object");
66    if is_object {
67        Ok(ResponseShape::Schema {
68            schema,
69            wrapped: false,
70        })
71    } else {
72        Ok(ResponseShape::Schema {
73            schema: json!({
74                "type": "object",
75                "properties": { "value": schema },
76                "required": ["value"],
77                "additionalProperties": false,
78            }),
79            wrapped: true,
80        })
81    }
82}
83
84/// The JSON Schema for one Ingot type.
85pub fn type_schema(
86    ty: &str,
87    types: &BTreeMap<String, RecordType>,
88) -> Result<Value, UnsupportedResponseType> {
89    if let Some(element) = ty.strip_suffix("[]") {
90        return Ok(json!({ "type": "array", "items": type_schema(element, types)? }));
91    }
92
93    // Content types a model cannot produce, wherever they appear — including as
94    // a field of a record it is being asked for.
95    if matches!(ty, "bytes" | "file") {
96        return Err(UnsupportedResponseType {
97            ty: ty.to_string(),
98            reason: "a model cannot produce binary content directly; \
99                     use a tool that writes the file and return its handle",
100        });
101    }
102
103    let scalar = match ty {
104        "string" | "text" | "markdown" => Some(json!({ "type": "string" })),
105        "int" => Some(json!({ "type": "integer" })),
106        "float" => Some(json!({ "type": "number" })),
107        "bool" => Some(json!({ "type": "boolean" })),
108        // A schema for `json` would have to permit anything, which is the same
109        // as not constraining at all.
110        "json" => Some(json!({})),
111        _ => None,
112    };
113    if let Some(scalar) = scalar {
114        return Ok(scalar);
115    }
116
117    let Some(record) = types.get(ty) else {
118        return Err(UnsupportedResponseType {
119            ty: ty.to_string(),
120            reason: "not a known type; the artifact does not declare this record",
121        });
122    };
123
124    let mut properties = serde_json::Map::new();
125    let mut required = Vec::new();
126    for field in &record.fields {
127        properties.insert(field.name.clone(), type_schema(&field.ty, types)?);
128        required.push(Value::String(field.name.clone()));
129    }
130    Ok(json!({
131        "type": "object",
132        "properties": Value::Object(properties),
133        "required": Value::Array(required),
134        // Required by provider structured-output implementations, and the right
135        // default anyway: an extra field is a model mistake, not a bonus.
136        "additionalProperties": false,
137    }))
138}
139
140/// The runtime representation of a `file`: a handle, never the content.
141///
142/// A tool that produces a file returns where it put it, so that a later tool can
143/// pick it up without the bytes travelling through the agent — or through a
144/// cassette. `path` is required; a producer may add anything else it finds
145/// useful, such as a media type or a size.
146pub const FILE_HANDLE_FIELD: &str = "path";
147
148/// Check a value against an Ingot type. Returns the first mismatch found.
149///
150/// Deliberately shallow-but-strict rather than a general JSON Schema validator:
151/// it only has to cover the types Ingot can express, and it produces messages
152/// naming the Ingot type rather than a schema path.
153pub fn validate(
154    value: &Value,
155    ty: &str,
156    types: &BTreeMap<String, RecordType>,
157) -> Result<(), String> {
158    if let Some(element) = ty.strip_suffix("[]") {
159        let Some(items) = value.as_array() else {
160            return Err(format!("expected `{ty}`, found {}", describe(value)));
161        };
162        for (index, item) in items.iter().enumerate() {
163            validate(item, element, types).map_err(|error| format!("at index {index}: {error}"))?;
164        }
165        return Ok(());
166    }
167
168    if ty == "file" {
169        return validate_file(value);
170    }
171
172    let ok = match ty {
173        "string" | "text" | "markdown" => value.is_string(),
174        "int" => value.is_i64() || value.is_u64(),
175        "float" => value.is_number(),
176        "bool" => value.is_boolean(),
177        // Binary content travels base64-encoded, because the IR, the event
178        // stream and cassettes are all JSON.
179        "bytes" => value.is_string(),
180        "json" => true,
181        _ => {
182            let Some(record) = types.get(ty) else {
183                return Err(format!("unknown type `{ty}`"));
184            };
185            let Some(object) = value.as_object() else {
186                return Err(format!("expected `{ty}`, found {}", describe(value)));
187            };
188            for field in &record.fields {
189                let Some(field_value) = object.get(&field.name) else {
190                    return Err(format!("`{ty}` is missing field `{}`", field.name));
191                };
192                validate(field_value, &field.ty, types)
193                    .map_err(|error| format!("in field `{}`: {error}", field.name))?;
194            }
195            return Ok(());
196        }
197    };
198
199    if ok {
200        Ok(())
201    } else {
202        Err(format!("expected `{ty}`, found {}", describe(value)))
203    }
204}
205
206fn validate_file(value: &Value) -> Result<(), String> {
207    let Some(object) = value.as_object() else {
208        return Err(format!(
209            "expected `file` (an object with a `{FILE_HANDLE_FIELD}`), found {}",
210            describe(value)
211        ));
212    };
213    match object.get(FILE_HANDLE_FIELD) {
214        Some(Value::String(_)) => Ok(()),
215        Some(other) => Err(format!(
216            "`file` has a `{FILE_HANDLE_FIELD}` that is {}, and it must be a string",
217            describe(other)
218        )),
219        None => Err(format!("`file` is missing field `{FILE_HANDLE_FIELD}`")),
220    }
221}
222
223fn describe(value: &Value) -> &'static str {
224    match value {
225        Value::Null => "null",
226        Value::Bool(_) => "a boolean",
227        Value::Number(_) => "a number",
228        Value::String(_) => "a string",
229        Value::Array(_) => "an array",
230        Value::Object(_) => "an object",
231    }
232}
233
234#[cfg(test)]
235mod tests {
236    use super::*;
237    use ingot_ir::FieldType;
238
239    fn record_types() -> BTreeMap<String, RecordType> {
240        [(
241            "search_result".to_string(),
242            RecordType {
243                fields: vec![
244                    FieldType {
245                        name: "title".into(),
246                        ty: "string".into(),
247                    },
248                    FieldType {
249                        name: "score".into(),
250                        ty: "int".into(),
251                    },
252                ],
253            },
254        )]
255        .into_iter()
256        .collect()
257    }
258
259    #[test]
260    fn prose_types_are_not_constrained() {
261        let types = BTreeMap::new();
262        assert_eq!(
263            response_shape("markdown", &types).unwrap(),
264            ResponseShape::Prose
265        );
266        assert_eq!(
267            response_shape("text", &types).unwrap(),
268            ResponseShape::Prose
269        );
270    }
271
272    #[test]
273    fn scalars_and_lists_are_wrapped() {
274        let types = BTreeMap::new();
275        let ResponseShape::Schema { schema, wrapped } = response_shape("string[]", &types).unwrap()
276        else {
277            panic!("expected a constrained shape");
278        };
279        assert!(wrapped);
280        assert_eq!(schema["properties"]["value"]["type"], "array");
281        assert_eq!(schema["properties"]["value"]["items"]["type"], "string");
282    }
283
284    #[test]
285    fn records_are_not_wrapped() {
286        let types = record_types();
287        let ResponseShape::Schema { schema, wrapped } =
288            response_shape("search_result", &types).unwrap()
289        else {
290            panic!("expected a constrained shape");
291        };
292        assert!(!wrapped, "an object schema is already valid at the root");
293        assert_eq!(schema["additionalProperties"], false);
294        assert_eq!(schema["required"], json!(["title", "score"]));
295    }
296
297    #[test]
298    fn binary_content_cannot_be_asked_for() {
299        let types = BTreeMap::new();
300        let error = response_shape("bytes", &types).unwrap_err();
301        assert_eq!(error.ty, "bytes");
302    }
303
304    #[test]
305    fn validation_accepts_matching_values() {
306        let types = record_types();
307        assert!(validate(&json!("hello"), "string", &types).is_ok());
308        assert!(validate(&json!([1, 2]), "int[]", &types).is_ok());
309        assert!(validate(&json!({"title": "t", "score": 3}), "search_result", &types).is_ok());
310    }
311
312    #[test]
313    fn validation_names_the_offending_field() {
314        let types = record_types();
315        let error = validate(
316            &json!({"title": "t", "score": "three"}),
317            "search_result",
318            &types,
319        )
320        .unwrap_err();
321        assert!(error.contains("score"), "{error}");
322        assert!(error.contains("expected `int`"), "{error}");
323    }
324
325    #[test]
326    fn validation_reports_the_offending_index() {
327        let types = BTreeMap::new();
328        let error = validate(&json!(["a", 2]), "string[]", &types).unwrap_err();
329        assert!(error.contains("at index 1"), "{error}");
330    }
331
332    #[test]
333    fn a_missing_field_is_reported_by_name() {
334        let types = record_types();
335        let error = validate(&json!({"title": "t"}), "search_result", &types).unwrap_err();
336        assert!(error.contains("missing field `score`"), "{error}");
337    }
338
339    #[test]
340    fn a_file_is_a_handle_with_a_path() {
341        let types = BTreeMap::new();
342        assert!(validate(&json!({"path": "out/report.md"}), "file", &types).is_ok());
343        // Extra fields are a producer's business, not a mismatch.
344        assert!(validate(&json!({"path": "a", "bytes": 12}), "file", &types).is_ok());
345    }
346
347    #[test]
348    fn a_file_without_a_path_is_reported_as_such() {
349        let types = BTreeMap::new();
350        let error = validate(&json!({"bytes": 12}), "file", &types).unwrap_err();
351        assert!(error.contains("missing field `path`"), "{error}");
352
353        // The old behaviour was "unknown type `file`", which sent the reader
354        // looking for a missing record declaration.
355        let error = validate(&json!("out/report.md"), "file", &types).unwrap_err();
356        assert!(error.contains("expected `file`"), "{error}");
357        assert!(!error.contains("unknown type"), "{error}");
358    }
359
360    #[test]
361    fn bytes_travel_as_a_base64_string() {
362        let types = BTreeMap::new();
363        assert!(validate(&json!("aGVsbG8="), "bytes", &types).is_ok());
364        assert!(validate(&json!([104, 105]), "bytes", &types).is_err());
365    }
366
367    #[test]
368    fn a_record_field_a_model_cannot_produce_says_why() {
369        let types: BTreeMap<String, RecordType> = [(
370            "attachment".to_string(),
371            RecordType {
372                fields: vec![FieldType {
373                    name: "body".into(),
374                    ty: "file".into(),
375                }],
376            },
377        )]
378        .into_iter()
379        .collect();
380
381        let error = response_shape("attachment", &types).unwrap_err();
382        assert_eq!(error.ty, "file");
383        assert!(error.reason.contains("tool"), "{}", error.reason);
384    }
385}