Skip to main content

ag_harness/
schema_contract.rs

1use std::fmt;
2use std::sync::Arc;
3
4use jsonschema::error::ValidationErrorKind;
5use jsonschema::{Draft, PatternOptions, ReferencingError, Validator};
6use serde_json::Value;
7use thiserror::Error;
8
9const DIAGNOSTIC_LIMIT_CHARS: usize = 512;
10const REGEX_DFA_LIMIT_BYTES: usize = 2 * 1024 * 1024;
11const REGEX_SIZE_LIMIT_BYTES: usize = 256 * 1024;
12const SCHEMA_LIMIT_BYTES: usize = 256 * 1024;
13pub(crate) const RESPONSE_CONTENT_LIMIT_BYTES: usize = 2 * 1024 * 1024;
14
15/// A validated, provider-independent JSON Schema for model output.
16#[derive(Clone)]
17pub struct OutputSchema {
18    schema: Value,
19    validator: Arc<Validator>,
20}
21
22impl OutputSchema {
23    /// Validates and compiles a JSON Schema for structured model output.
24    ///
25    /// # Errors
26    ///
27    /// Returns [`OutputSchemaError`] when the schema is oversized, references
28    /// an external resource, or is outside the harness's JSON Schema Draft
29    /// 2020-12 safety profile.
30    pub fn new(schema: Value) -> Result<Self, OutputSchemaError> {
31        if schema.to_string().len() > SCHEMA_LIMIT_BYTES {
32            return Err(OutputSchemaError::TooLarge);
33        }
34
35        let validator = jsonschema::options()
36            .with_draft(Draft::Draft202012)
37            .with_pattern_options(
38                PatternOptions::regex()
39                    .size_limit(REGEX_SIZE_LIMIT_BYTES)
40                    .dfa_size_limit(REGEX_DFA_LIMIT_BYTES),
41            )
42            .build(&schema)
43            .map_err(|error| {
44                if matches!(
45                    error.kind(),
46                    ValidationErrorKind::Referencing(ReferencingError::Unretrievable { .. })
47                ) {
48                    return OutputSchemaError::ExternalReference;
49                }
50
51                OutputSchemaError::Invalid {
52                    reason: bounded_diagnostic(error),
53                }
54            })?;
55
56        Ok(Self {
57            schema,
58            validator: Arc::new(validator),
59        })
60    }
61
62    /// Returns the underlying JSON Schema document.
63    pub fn value(&self) -> &Value {
64        &self.schema
65    }
66
67    pub(crate) fn has_object_root(&self) -> bool {
68        let Some(schema_type) = self.schema.get("type") else {
69            return false;
70        };
71
72        schema_type == "object"
73            || schema_type
74                .as_array()
75                .is_some_and(|types| types.iter().any(|schema_type| schema_type == "object"))
76    }
77
78    pub(crate) fn parse_and_validate(&self, output: &str) -> Result<Value, OutputValidationError> {
79        ensure_content_size(output)?;
80
81        let value = serde_json::from_str(output)
82            .map_err(|error| OutputValidationError::InvalidJson(bounded_diagnostic(error)))?;
83        if let Err(error) = self.validator.validate(&value) {
84            let path = match error.instance_path().as_str() {
85                "" => "$".to_string(),
86                path => bounded_diagnostic(path),
87            };
88
89            return Err(OutputValidationError::SchemaViolation {
90                path,
91                reason: bounded_diagnostic(error),
92            });
93        }
94
95        Ok(value)
96    }
97}
98
99impl fmt::Debug for OutputSchema {
100    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
101        formatter
102            .debug_struct("OutputSchema")
103            .field("schema", &self.schema)
104            .finish_non_exhaustive()
105    }
106}
107
108impl PartialEq for OutputSchema {
109    fn eq(&self, other: &Self) -> bool {
110        self.schema == other.schema
111    }
112}
113
114impl Eq for OutputSchema {}
115
116/// Failure returned while constructing a structured-output schema.
117#[derive(Debug, Error, Eq, PartialEq)]
118pub enum OutputSchemaError {
119    /// The serialized schema exceeds the harness safety limit.
120    #[error("output schema exceeds the size limit")]
121    TooLarge,
122    /// The schema references a resource outside its own document.
123    #[error("output schema contains an external reference")]
124    ExternalReference,
125    /// The document is invalid or outside the harness safety profile.
126    #[error("invalid output schema: {reason}")]
127    Invalid {
128        /// Validator-provided reason the schema is invalid or unsupported.
129        reason: String,
130    },
131}
132
133#[derive(Debug, Eq, PartialEq)]
134pub(crate) enum OutputValidationError {
135    InvalidJson(String),
136    SchemaViolation { path: String, reason: String },
137    TooLarge,
138}
139
140pub(crate) fn ensure_content_size(output: &str) -> Result<(), OutputValidationError> {
141    if output.len() > RESPONSE_CONTENT_LIMIT_BYTES {
142        return Err(OutputValidationError::TooLarge);
143    }
144
145    Ok(())
146}
147
148pub(crate) fn bounded_diagnostic(reason: impl fmt::Display) -> String {
149    let reason = reason.to_string();
150    let mut characters = reason.chars();
151    let mut summary: String = characters.by_ref().take(DIAGNOSTIC_LIMIT_CHARS).collect();
152    if characters.next().is_some() {
153        summary.push_str(" ...");
154    }
155
156    summary
157}
158
159#[cfg(test)]
160mod tests {
161    use serde_json::json;
162
163    use super::*;
164
165    fn object_schema() -> Value {
166        json!({
167            "type": "object",
168            "properties": {
169                "name": { "type": "string" }
170            },
171            "required": ["name"],
172            "additionalProperties": false
173        })
174    }
175
176    #[test]
177    fn constructs_valid_schema() {
178        // Arrange
179        let value = object_schema();
180
181        // Act
182        let schema = OutputSchema::new(value.clone()).expect("schema should be valid");
183
184        // Assert
185        assert_eq!(schema.value(), &value);
186        assert!(schema.has_object_root());
187        assert_eq!(schema, schema.clone());
188        assert!(format!("{schema:?}").starts_with("OutputSchema"));
189    }
190
191    #[test]
192    fn identifies_object_root_in_type_array() {
193        // Arrange
194        let value = json!({ "type": ["object", "null"] });
195
196        // Act
197        let schema = OutputSchema::new(value).expect("schema should be valid");
198
199        // Assert
200        assert!(schema.has_object_root());
201    }
202
203    #[test]
204    fn identifies_schemas_without_explicit_object_root() {
205        // Arrange
206        let values = [
207            json!({ "type": "array" }),
208            json!({ "type": ["array", "null"] }),
209            json!({ "$ref": "#/$defs/result", "$defs": { "result": { "type": "object" } } }),
210        ];
211
212        // Act
213        let schemas = values.map(|value| OutputSchema::new(value).expect("schema should be valid"));
214
215        // Assert
216        assert!(schemas.iter().all(|schema| !schema.has_object_root()));
217    }
218
219    #[test]
220    fn rejects_oversized_schema() {
221        // Arrange
222        let value = json!({ "description": "x".repeat(SCHEMA_LIMIT_BYTES) });
223
224        // Act
225        let error = OutputSchema::new(value).expect_err("oversized schema should fail");
226
227        // Assert
228        assert_eq!(error, OutputSchemaError::TooLarge);
229        assert_eq!(error.to_string(), "output schema exceeds the size limit");
230    }
231
232    #[test]
233    fn rejects_invalid_schema() {
234        // Arrange
235        let value = json!({ "type": "not-a-json-type" });
236
237        // Act
238        let error = OutputSchema::new(value).expect_err("invalid schema should fail");
239
240        // Assert
241        assert!(matches!(error, OutputSchemaError::Invalid { .. }));
242        assert!(error.to_string().starts_with("invalid output schema:"));
243    }
244
245    #[test]
246    fn accepts_linear_regex_pattern() {
247        // Arrange
248        let value = json!({
249            "type": "string",
250            "pattern": "^(a+)+$"
251        });
252
253        // Act
254        let schema = OutputSchema::new(value).expect("linear regex should compile");
255
256        // Assert
257        assert!(schema.parse_and_validate(r#""aaaa""#).is_ok());
258    }
259
260    #[test]
261    fn rejects_backtracking_regex_pattern() {
262        // Arrange
263        let value = json!({
264            "type": "string",
265            "pattern": "(?=unsafe-lookaround)"
266        });
267
268        // Act
269        let error = OutputSchema::new(value).expect_err("lookaround should be rejected");
270
271        // Assert
272        assert!(matches!(
273            error,
274            OutputSchemaError::Invalid { reason } if reason.contains("regex")
275        ));
276    }
277
278    #[test]
279    fn rejects_regex_exceeding_compiled_size_limit() {
280        // Arrange
281        let value = json!({
282            "type": "string",
283            "pattern": "a{100000}"
284        });
285
286        // Act
287        let error = OutputSchema::new(value).expect_err("oversized regex should be rejected");
288
289        // Assert
290        assert!(matches!(error, OutputSchemaError::Invalid { .. }));
291    }
292
293    #[test]
294    fn rejects_nested_external_reference() {
295        // Arrange
296        let value = json!({
297            "allOf": [
298                { "$ref": "https://example.com/schema.json" }
299            ]
300        });
301
302        // Act
303        let error = OutputSchema::new(value).expect_err("external reference should fail");
304
305        // Assert
306        assert_eq!(error, OutputSchemaError::ExternalReference);
307        assert_eq!(
308            error.to_string(),
309            "output schema contains an external reference"
310        );
311    }
312
313    #[test]
314    fn rejects_external_dynamic_reference() {
315        // Arrange
316        let value = json!({
317            "$dynamicRef": "https://example.com/schema.json"
318        });
319
320        // Act
321        let error = OutputSchema::new(value).expect_err("external dynamic reference should fail");
322
323        // Assert
324        assert_eq!(error, OutputSchemaError::ExternalReference);
325    }
326
327    #[test]
328    fn accepts_external_reference_as_literal_instance_data() {
329        // Arrange
330        let literal = json!({ "$ref": "https://example.com/value" });
331        let value = json!({ "const": literal });
332
333        // Act
334        let schema = OutputSchema::new(value).expect("literal reference should be valid");
335        let output = schema
336            .parse_and_validate(r#"{"$ref":"https://example.com/value"}"#)
337            .expect("matching literal should validate");
338
339        // Assert
340        assert_eq!(output, literal);
341    }
342
343    #[test]
344    fn rejects_missing_local_reference_as_invalid() {
345        // Arrange
346        let value = json!({ "$ref": "#/$defs/missing" });
347
348        // Act
349        let error = OutputSchema::new(value).expect_err("missing reference should fail");
350
351        // Assert
352        assert!(matches!(error, OutputSchemaError::Invalid { .. }));
353    }
354
355    #[test]
356    fn accepts_nested_local_reference() {
357        // Arrange
358        let value = json!({
359            "$defs": {
360                "name": { "type": "string" }
361            },
362            "type": "object",
363            "properties": {
364                "name": { "$ref": "#/$defs/name" }
365            }
366        });
367
368        // Act
369        let schema = OutputSchema::new(value).expect("schema should be valid");
370
371        // Assert
372        assert!(schema.has_object_root());
373    }
374
375    #[test]
376    fn validates_root_local_reference() {
377        // Arrange
378        let value = json!({
379            "$defs": {
380                "result": {
381                    "type": "object",
382                    "required": ["name"],
383                    "properties": {
384                        "name": { "type": "string" }
385                    }
386                }
387            },
388            "$ref": "#/$defs/result"
389        });
390
391        // Act
392        let schema = OutputSchema::new(value).expect("schema should be valid");
393        let output = schema
394            .parse_and_validate(r#"{"name":"Ada"}"#)
395            .expect("output should be valid");
396
397        // Assert
398        assert_eq!(output, json!({ "name": "Ada" }));
399        assert!(!schema.has_object_root());
400    }
401
402    #[test]
403    fn parses_valid_structured_output() {
404        // Arrange
405        let schema = OutputSchema::new(object_schema()).expect("schema should be valid");
406
407        // Act
408        let value = schema
409            .parse_and_validate(r#"{"name":"Ada"}"#)
410            .expect("output should be valid");
411
412        // Assert
413        assert_eq!(value, json!({ "name": "Ada" }));
414    }
415
416    #[test]
417    fn rejects_malformed_structured_output() {
418        // Arrange
419        let schema = OutputSchema::new(object_schema()).expect("schema should be valid");
420
421        // Act
422        let error = schema
423            .parse_and_validate("not JSON")
424            .expect_err("malformed output should fail");
425
426        // Assert
427        assert!(matches!(error, OutputValidationError::InvalidJson(_)));
428    }
429
430    #[test]
431    fn rejects_oversized_structured_output() {
432        // Arrange
433        let schema = OutputSchema::new(object_schema()).expect("schema should be valid");
434        let output = "x".repeat(RESPONSE_CONTENT_LIMIT_BYTES + 1);
435
436        // Act
437        let error = schema
438            .parse_and_validate(&output)
439            .expect_err("oversized output should fail");
440
441        // Assert
442        assert_eq!(error, OutputValidationError::TooLarge);
443    }
444
445    #[test]
446    fn reports_nested_schema_violation() {
447        // Arrange
448        let schema = OutputSchema::new(object_schema()).expect("schema should be valid");
449
450        // Act
451        let error = schema
452            .parse_and_validate(r#"{"name":42}"#)
453            .expect_err("schema violation should fail");
454
455        // Assert
456        assert!(matches!(
457            error,
458            OutputValidationError::SchemaViolation { path, reason }
459                if path == "/name" && reason.contains("string")
460        ));
461    }
462
463    #[test]
464    fn bounds_long_schema_violation_path() {
465        // Arrange
466        let schema = OutputSchema::new(json!({
467            "type": "object",
468            "additionalProperties": { "type": "string" }
469        }))
470        .expect("schema should be valid");
471        let property = "x".repeat(DIAGNOSTIC_LIMIT_CHARS);
472        let output = format!(r#"{{"{property}":42}}"#);
473
474        // Act
475        let error = schema
476            .parse_and_validate(&output)
477            .expect_err("schema violation should fail");
478
479        // Assert
480        assert!(matches!(
481            error,
482            OutputValidationError::SchemaViolation { path, reason }
483                if path == format!("/{} ...", "x".repeat(DIAGNOSTIC_LIMIT_CHARS - 1))
484                    && reason.contains("string")
485        ));
486    }
487
488    #[test]
489    fn reports_root_schema_violation() {
490        // Arrange
491        let schema = OutputSchema::new(object_schema()).expect("schema should be valid");
492
493        // Act
494        let error = schema
495            .parse_and_validate("[]")
496            .expect_err("root schema violation should fail");
497
498        // Assert
499        assert!(matches!(
500            error,
501            OutputValidationError::SchemaViolation { path, .. } if path == "$"
502        ));
503    }
504
505    #[test]
506    fn reports_missing_required_property() {
507        // Arrange
508        let schema = OutputSchema::new(object_schema()).expect("schema should be valid");
509
510        // Act
511        let error = schema
512            .parse_and_validate("{}")
513            .expect_err("missing required property should fail");
514
515        // Assert
516        assert!(matches!(
517            error,
518            OutputValidationError::SchemaViolation { reason, .. }
519                if reason.contains("required")
520        ));
521    }
522
523    #[test]
524    fn reports_disallowed_additional_property() {
525        // Arrange
526        let schema = OutputSchema::new(object_schema()).expect("schema should be valid");
527
528        // Act
529        let error = schema
530            .parse_and_validate(r#"{"name":"Ada","role":"engineer"}"#)
531            .expect_err("additional property should fail");
532
533        // Assert
534        assert!(matches!(
535            error,
536            OutputValidationError::SchemaViolation { reason, .. }
537                if reason.contains("Additional properties")
538        ));
539    }
540
541    #[test]
542    fn bounds_long_diagnostic() {
543        // Arrange
544        let reason = "é".repeat(DIAGNOSTIC_LIMIT_CHARS + 1);
545
546        // Act
547        let summary = bounded_diagnostic(reason);
548
549        // Assert
550        assert_eq!(
551            summary,
552            format!("{} ...", "é".repeat(DIAGNOSTIC_LIMIT_CHARS))
553        );
554    }
555}