ag_harness/
schema_contract.rs1use 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#[derive(Clone)]
17pub struct OutputSchema {
18 schema: Value,
19 validator: Arc<Validator>,
20}
21
22impl OutputSchema {
23 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 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#[derive(Debug, Error, Eq, PartialEq)]
118pub enum OutputSchemaError {
119 #[error("output schema exceeds the size limit")]
121 TooLarge,
122 #[error("output schema contains an external reference")]
124 ExternalReference,
125 #[error("invalid output schema: {reason}")]
127 Invalid {
128 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 let value = object_schema();
180
181 let schema = OutputSchema::new(value.clone()).expect("schema should be valid");
183
184 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 let value = json!({ "type": ["object", "null"] });
195
196 let schema = OutputSchema::new(value).expect("schema should be valid");
198
199 assert!(schema.has_object_root());
201 }
202
203 #[test]
204 fn identifies_schemas_without_explicit_object_root() {
205 let values = [
207 json!({ "type": "array" }),
208 json!({ "type": ["array", "null"] }),
209 json!({ "$ref": "#/$defs/result", "$defs": { "result": { "type": "object" } } }),
210 ];
211
212 let schemas = values.map(|value| OutputSchema::new(value).expect("schema should be valid"));
214
215 assert!(schemas.iter().all(|schema| !schema.has_object_root()));
217 }
218
219 #[test]
220 fn rejects_oversized_schema() {
221 let value = json!({ "description": "x".repeat(SCHEMA_LIMIT_BYTES) });
223
224 let error = OutputSchema::new(value).expect_err("oversized schema should fail");
226
227 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 let value = json!({ "type": "not-a-json-type" });
236
237 let error = OutputSchema::new(value).expect_err("invalid schema should fail");
239
240 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 let value = json!({
249 "type": "string",
250 "pattern": "^(a+)+$"
251 });
252
253 let schema = OutputSchema::new(value).expect("linear regex should compile");
255
256 assert!(schema.parse_and_validate(r#""aaaa""#).is_ok());
258 }
259
260 #[test]
261 fn rejects_backtracking_regex_pattern() {
262 let value = json!({
264 "type": "string",
265 "pattern": "(?=unsafe-lookaround)"
266 });
267
268 let error = OutputSchema::new(value).expect_err("lookaround should be rejected");
270
271 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 let value = json!({
282 "type": "string",
283 "pattern": "a{100000}"
284 });
285
286 let error = OutputSchema::new(value).expect_err("oversized regex should be rejected");
288
289 assert!(matches!(error, OutputSchemaError::Invalid { .. }));
291 }
292
293 #[test]
294 fn rejects_nested_external_reference() {
295 let value = json!({
297 "allOf": [
298 { "$ref": "https://example.com/schema.json" }
299 ]
300 });
301
302 let error = OutputSchema::new(value).expect_err("external reference should fail");
304
305 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 let value = json!({
317 "$dynamicRef": "https://example.com/schema.json"
318 });
319
320 let error = OutputSchema::new(value).expect_err("external dynamic reference should fail");
322
323 assert_eq!(error, OutputSchemaError::ExternalReference);
325 }
326
327 #[test]
328 fn accepts_external_reference_as_literal_instance_data() {
329 let literal = json!({ "$ref": "https://example.com/value" });
331 let value = json!({ "const": literal });
332
333 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_eq!(output, literal);
341 }
342
343 #[test]
344 fn rejects_missing_local_reference_as_invalid() {
345 let value = json!({ "$ref": "#/$defs/missing" });
347
348 let error = OutputSchema::new(value).expect_err("missing reference should fail");
350
351 assert!(matches!(error, OutputSchemaError::Invalid { .. }));
353 }
354
355 #[test]
356 fn accepts_nested_local_reference() {
357 let value = json!({
359 "$defs": {
360 "name": { "type": "string" }
361 },
362 "type": "object",
363 "properties": {
364 "name": { "$ref": "#/$defs/name" }
365 }
366 });
367
368 let schema = OutputSchema::new(value).expect("schema should be valid");
370
371 assert!(schema.has_object_root());
373 }
374
375 #[test]
376 fn validates_root_local_reference() {
377 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 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_eq!(output, json!({ "name": "Ada" }));
399 assert!(!schema.has_object_root());
400 }
401
402 #[test]
403 fn parses_valid_structured_output() {
404 let schema = OutputSchema::new(object_schema()).expect("schema should be valid");
406
407 let value = schema
409 .parse_and_validate(r#"{"name":"Ada"}"#)
410 .expect("output should be valid");
411
412 assert_eq!(value, json!({ "name": "Ada" }));
414 }
415
416 #[test]
417 fn rejects_malformed_structured_output() {
418 let schema = OutputSchema::new(object_schema()).expect("schema should be valid");
420
421 let error = schema
423 .parse_and_validate("not JSON")
424 .expect_err("malformed output should fail");
425
426 assert!(matches!(error, OutputValidationError::InvalidJson(_)));
428 }
429
430 #[test]
431 fn rejects_oversized_structured_output() {
432 let schema = OutputSchema::new(object_schema()).expect("schema should be valid");
434 let output = "x".repeat(RESPONSE_CONTENT_LIMIT_BYTES + 1);
435
436 let error = schema
438 .parse_and_validate(&output)
439 .expect_err("oversized output should fail");
440
441 assert_eq!(error, OutputValidationError::TooLarge);
443 }
444
445 #[test]
446 fn reports_nested_schema_violation() {
447 let schema = OutputSchema::new(object_schema()).expect("schema should be valid");
449
450 let error = schema
452 .parse_and_validate(r#"{"name":42}"#)
453 .expect_err("schema violation should fail");
454
455 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 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 let error = schema
476 .parse_and_validate(&output)
477 .expect_err("schema violation should fail");
478
479 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 let schema = OutputSchema::new(object_schema()).expect("schema should be valid");
492
493 let error = schema
495 .parse_and_validate("[]")
496 .expect_err("root schema violation should fail");
497
498 assert!(matches!(
500 error,
501 OutputValidationError::SchemaViolation { path, .. } if path == "$"
502 ));
503 }
504
505 #[test]
506 fn reports_missing_required_property() {
507 let schema = OutputSchema::new(object_schema()).expect("schema should be valid");
509
510 let error = schema
512 .parse_and_validate("{}")
513 .expect_err("missing required property should fail");
514
515 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 let schema = OutputSchema::new(object_schema()).expect("schema should be valid");
527
528 let error = schema
530 .parse_and_validate(r#"{"name":"Ada","role":"engineer"}"#)
531 .expect_err("additional property should fail");
532
533 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 let reason = "é".repeat(DIAGNOSTIC_LIMIT_CHARS + 1);
545
546 let summary = bounded_diagnostic(reason);
548
549 assert_eq!(
551 summary,
552 format!("{} ...", "é".repeat(DIAGNOSTIC_LIMIT_CHARS))
553 );
554 }
555}