Fix nested output schema validation - #666
Conversation
There was a problem hiding this comment.
Pull request overview
This PR fixes how custom JSON-schema structured output chooses which JSON object in an LLM response to validate: it now validates against the last outermost {...} object (rather than potentially selecting a later-starting nested object). This aligns custom-schema validation with how responses commonly embed nested objects and/or include trailing prose, while preserving the existing nested-candidate behavior used for routing/status extraction.
Changes:
- Added an “outermost-only” JSON object extractor (
find_outermost_json_objects) implemented via a sharedfind_json_objects_with_nested(..., include_nested)helper. - Updated custom schema validation to select the last outermost JSON object before parsing/validating.
- Added tests covering nested-object scenarios and responses with trailing prose.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Replace the include_nested flag and its two wrapper functions with a single outermost-only scanner. Routing extraction used the nested scan and reverse iteration, so a routing object nested inside a wrapper could win over its parent -- the same bug class this branch fixes for custom schemas. No caller needs nested candidates. Custom schema validation now walks candidates from the end and takes the last one that parses, instead of parsing only the final candidate. Prose after the object can contain braces, and outermost-only scanning made that trailing text a candidate that shadowed the real JSON. Schema errors are still reported from the last parsable object, so an earlier object that happens to validate cannot mask a later violation. Add direct scanner coverage for nesting, adjacent objects, unclosed braces, and braces inside strings. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Ran a simplification pass over this branch. Removed the Routing extraction had the same bug.
Fixed a regression the outermost scan introduced. This validated before the branch and failed after it. Validation now walks candidates from the end and takes the last one that parses. Schema errors are still reported from that object, so an earlier object that happens to validate cannot mask a later violation. Tests. Hoisted the schema literal that was duplicated between the two new tests. Added direct scanner coverage for nesting, adjacent objects, an unclosed outer brace around a complete object, a lone trailing Considered and rejected: replacing the hand-rolled brace scanner with Follow-ups worth filing separately, both pre-existing:
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 1 out of 1 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (1)
lib/components/fabro-workflow/src/handler/structured_output.rs:260
find_json_objectsnow intentionally skips nested{...}blocks (viai = j), but this helper is also used byextract_status_fieldsandvalidate_routing_response_text. That changes routing/status extraction behavior to no longer consider nested JSON objects, which contradicts the PR description (“keep the existing nested-candidate behavior for routing and status extraction”) and could be a behavior regression.
Consider splitting this into two helpers: one that returns all balanced object substrings (including nested) for routing/status extraction, and a second that returns only outermost matches for custom schema validation / terminal_json_object. Alternatively, keep the nested behavior here and make the callers that need “outermost only” do their own filtering.
fn find_json_objects(text: &str) -> Vec<&str> {
let mut results = Vec::new();
let bytes = text.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'{' {
let start = i;
let mut depth = 0;
let mut in_string = false;
let mut escape = false;
let mut j = i;
while j < bytes.len() {
let c = bytes[j];
if escape {
escape = false;
} else if c == b'\\' && in_string {
escape = true;
} else if c == b'"' {
in_string = !in_string;
} else if !in_string {
if c == b'{' {
depth += 1;
} else if c == b'}' {
depth -= 1;
if depth == 0 {
results.push(&text[start..=j]);
i = j;
break;
Summary
Testing
cargo +nightly-2026-04-14 fmt --check --allcargo +nightly-2026-04-14 clippy -p fabro-workflow --all-targets -- -D warningscargo nextest run -p fabro-workflow(1,245 passed, 30 skipped)