molo_agent/agent/structured.rs
1//! Structured output validation components: schema validation of the model's
2//! answer + an independent retry budget + feedback messages.
3//!
4//! Used when wiring up typed output
5//! ([`TypedAgent`](crate::agent::TypedAgent)) — a self-implemented Agent
6//! calls [`StructuredValidator::validate`] each round inside its own
7//! reasoning loop, and a single `match` replaces the hand-written
8//! "validate → count → check limit → feedback message" boilerplate.
9
10use crate::message::Message;
11
12/// Structured output validation: the answer must parse as JSON and conform
13/// to the schema.
14///
15/// Used by self-implemented Agents wiring up typed output
16/// ([`TypedAgent`](crate::agent::TypedAgent)): on failure, record the
17/// feedback text from [`structured_retry_message`] as a User message so the
18/// model can correct and retry (the budget is defined by the assembler; use
19/// [`StructuredValidator`] when you want a state machine with the budget
20/// built in). Failures return **model-facing error text** (English — fed
21/// back to the model, which corrects based on it).
22///
23/// The schema is compiled on every call (jsonschema caches compiled results
24/// internally; regular schemas compile in under a millisecond, negligible
25/// relative to model latency).
26///
27/// # Examples
28///
29/// ```
30/// # extern crate molo_agent as molo;
31/// use molo::agent::validate_structured;
32///
33/// let schema = serde_json::json!({
34/// "type": "object",
35/// "properties": { "city": { "type": "string" } },
36/// "required": ["city"],
37/// });
38/// assert!(validate_structured(&schema, r#"{"city":"Beijing"}"#).is_ok());
39/// assert!(validate_structured(&schema, "not JSON").is_err());
40/// ```
41pub fn validate_structured(schema: &serde_json::Value, answer: &str) -> Result<(), String> {
42 let instance: serde_json::Value =
43 serde_json::from_str(answer).map_err(|e| format!("answer is not valid JSON: {e}"))?;
44 let validator =
45 jsonschema::validator_for(schema).map_err(|e| format!("invalid JSON schema: {e}"))?;
46 if let Err(e) = validator.validate(&instance) {
47 return Err(format!("answer does not match the JSON schema: {e}"));
48 }
49 Ok(())
50}
51
52/// Feedback message for structured output validation failure (recorded as a
53/// User message; the model retries based on it).
54///
55/// Complements [`validate_structured`]: record this message after a
56/// validation failure, and the model sees the error details in the next
57/// round and corrects its answer.
58pub fn structured_retry_message(error: &str) -> Message {
59 Message::user(format!(
60 "your previous answer failed JSON schema validation: {error}; \
61 please reply with a single JSON value conforming to the schema"
62 ))
63}
64
65/// The outcome of a single validation
66/// ([`StructuredValidator::validate`] return value).
67///
68/// - [`Passed`](StructuredOutcome::Passed): the answer conforms to the
69/// schema; the run wraps up;
70/// - [`Retry`](StructuredOutcome::Retry): validation failed, carrying a
71/// model-facing feedback message — recorded as a User message so the model
72/// corrects itself in the next round;
73/// - [`Exhausted`](StructuredOutcome::Exhausted): the retry budget is
74/// exhausted; the run fails.
75///
76/// The enum carries `#[non_exhaustive]`: future outcomes won't be a breaking
77/// change; matches should include a wildcard arm.
78#[derive(Debug, Clone, PartialEq, Eq)]
79#[non_exhaustive]
80pub enum StructuredOutcome {
81 /// Passed: the answer conforms to the schema.
82 Passed,
83 /// Validation failed, carrying a feedback message (model-facing,
84 /// recorded as a User message so the model corrects itself).
85 Retry {
86 /// Model-facing feedback message (English, recorded as a User
87 /// message).
88 message: Message,
89 },
90 /// Retry budget exhausted, carrying the limit.
91 Exhausted {
92 /// The retry budget limit (matches the `max_retries` used to
93 /// construct `StructuredValidator`).
94 max_retries: usize,
95 },
96}
97
98/// Structured output validator: schema validation of the model's answer +
99/// **independent retry budget** + feedback messages.
100///
101/// Used by self-implemented Agents wiring up typed output
102/// ([`TypedAgent`](crate::agent::TypedAgent)): call
103/// [`validate`](StructuredValidator::validate) each round inside the loop,
104/// and the component owns the budget counting — user code no longer writes
105/// the "validate → count → check limit" boilerplate.
106///
107/// Budget semantics: up to `max_retries` retries after a failed validation;
108/// the `max_retries + 1`-th failure returns
109/// [`Exhausted`](StructuredOutcome::Exhausted).
110///
111/// # Examples
112///
113/// ```
114/// # extern crate molo_agent as molo;
115/// use molo::agent::{StructuredOutcome, StructuredValidator};
116///
117/// let schema = serde_json::json!({
118/// "type": "object",
119/// "properties": { "city": { "type": "string" } },
120/// "required": ["city"],
121/// });
122/// let mut validator = StructuredValidator::new(schema, 3);
123///
124/// assert!(matches!(validator.validate("bad"), StructuredOutcome::Retry { .. }));
125/// assert!(matches!(
126/// validator.validate(r#"{"city":"Beijing"}"#),
127/// StructuredOutcome::Passed
128/// ));
129/// ```
130#[derive(Debug, Clone, PartialEq, Eq)]
131pub struct StructuredValidator {
132 schema: serde_json::Value,
133 /// Maximum number of retries after a validation failure.
134 max_retries: usize,
135 /// Number of retries already used.
136 retries_used: usize,
137}
138
139impl StructuredValidator {
140 /// Construct with a schema and a retry budget; `max_retries` has the
141 /// same semantics as
142 /// [`AgentConfig::max_structured_retries`](crate::agent::AgentConfig)
143 /// (the built-in assembly defaults to 3).
144 pub fn new(schema: serde_json::Value, max_retries: usize) -> Self {
145 Self {
146 schema,
147 max_retries,
148 retries_used: 0,
149 }
150 }
151
152 /// Validate one answer: passed →
153 /// [`Passed`](StructuredOutcome::Passed); failed with budget remaining →
154 /// [`Retry`](StructuredOutcome::Retry) (carrying a feedback message,
155 /// recorded so the model corrects itself); failed with budget exhausted
156 /// → [`Exhausted`](StructuredOutcome::Exhausted).
157 pub fn validate(&mut self, answer: &str) -> StructuredOutcome {
158 match validate_structured(&self.schema, answer) {
159 Ok(()) => StructuredOutcome::Passed,
160 Err(error) => {
161 self.retries_used += 1;
162 if self.retries_used > self.max_retries {
163 StructuredOutcome::Exhausted {
164 max_retries: self.max_retries,
165 }
166 } else {
167 StructuredOutcome::Retry {
168 message: structured_retry_message(&error),
169 }
170 }
171 }
172 }
173 }
174}
175
176#[cfg(test)]
177mod tests {
178 use super::*;
179 use crate::message::ContentBlock;
180
181 fn schema() -> serde_json::Value {
182 serde_json::json!({
183 "type": "object",
184 "properties": { "city": { "type": "string" } },
185 "required": ["city"],
186 })
187 }
188
189 /// Three-state outcomes: failure within budget → Retry (carrying a
190 /// feedback message), exhausted → Exhausted, passed → Passed.
191 #[test]
192 fn validator_three_outcomes() {
193 let mut validator = StructuredValidator::new(schema(), 1);
194 match validator.validate("bad") {
195 StructuredOutcome::Retry { message } => {
196 let Message::User(blocks) = message else {
197 panic!("retry message must be a user message")
198 };
199 assert!(blocks.iter().any(|b| matches!(
200 b,
201 ContentBlock::Text(t) if t.contains("JSON schema validation")
202 )));
203 }
204 other => panic!("expected Retry, got {other:?}"),
205 }
206 // Budget exhausted (didn't pass within 1 retry; the 2nd failure hits
207 // the limit).
208 assert!(matches!(
209 validator.validate("bad2"),
210 StructuredOutcome::Exhausted { max_retries: 1 }
211 ));
212 }
213
214 /// Passing does not consume budget; a failed answer retried after
215 /// correction → Passed.
216 #[test]
217 fn validator_passes_after_retry() {
218 let mut validator = StructuredValidator::new(schema(), 3);
219 assert!(matches!(
220 validator.validate("bad"),
221 StructuredOutcome::Retry { .. }
222 ));
223 assert!(matches!(
224 validator.validate(r#"{"city":"Beijing"}"#),
225 StructuredOutcome::Passed
226 ));
227 // Re-validating after a pass still passes (counting doesn't affect
228 // the success path).
229 assert!(matches!(
230 validator.validate(r#"{"city":"Shanghai"}"#),
231 StructuredOutcome::Passed
232 ));
233 }
234}