Skip to main content

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}