Skip to content

Fix nested output schema validation - #666

Merged
brynary merged 2 commits into
mainfrom
fix/output-schema-outermost-object
Jul 28, 2026
Merged

Fix nested output schema validation#666
brynary merged 2 commits into
mainfrom
fix/output-schema-outermost-object

Conversation

@brynary

@brynary brynary commented Jul 28, 2026

Copy link
Copy Markdown
Member

Summary

  • validate custom output schemas against the last outermost JSON object instead of the nested object that starts last
  • keep the existing nested-candidate behavior for routing and status extraction
  • cover nested responses at the end of the message and responses followed by trailing prose

Testing

  • cargo +nightly-2026-04-14 fmt --check --all
  • cargo +nightly-2026-04-14 clippy -p fabro-workflow --all-targets -- -D warnings
  • cargo nextest run -p fabro-workflow (1,245 passed, 30 skipped)

Copilot AI review requested due to automatic review settings July 28, 2026 11:50

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 shared find_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>
Copilot AI review requested due to automatic review settings July 28, 2026 20:10
@brynary

brynary commented Jul 28, 2026

Copy link
Copy Markdown
Member Author

Ran a simplification pass over this branch.

Removed the include_nested flag. The fix added find_json_objects_with_nested(text, include_nested: bool) plus two wrappers. No caller needs nested candidates, so there is now one outermost-only find_json_objects.

Routing extraction had the same bug. extract_status_fields and validate_routing_response_text used the nested scan with reverse iteration, so a routing object nested inside a wrapper was examined before its parent. For {"data":{"outcome":"failed"}} the inner object won. These now use the same outermost scan as custom schemas.

⚠️ This is a behavior change: routing fields nested under a non-routing wrapper key are no longer found. No existing test depends on it — the routing tests nest context_updates inside an object that already carries routing fields at the top level, so they are unaffected. Flagging it in case it is not wanted.

Fixed a regression the outermost scan introduced. validate_custom_response_text parsed only the last candidate. Once nested objects stop being candidates, trailing prose containing braces becomes the last candidate and shadows the real JSON:

{"passed":true}

Let me know if {this works} for you.

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 {, and braces inside strings — the new logic previously had no test that did not go through jsonschema.

Considered and rejected: replacing the hand-rolled brace scanner with serde_json::Deserializer::from_str(..).into_iter::<Value>(). It cannot skip prose on its own, so a brace scan is needed either way; and with restart-at-next-{ it hits serde's 128-level recursion limit on deeply nested input, retries, and succeeds on a nested fragment — reintroducing exactly the bug this PR fixes.

Follow-ups worth filing separately, both pre-existing:

  • read_last_file_routing_json (agent.rs:189-200) feeds an arbitrary agent-written file into the scanner with no size cap. A pathological file (unmatched { with odd quote counts) makes the scan run to EOF repeatedly; ~250 ms on 170 KB, worse on larger files. The function only wants an object at the end of the file, so a trailing window would do.
  • llm/api.rs:1141-1145 validates the response and discards the result (Ok(_) => None), then prompt.rs:196-198 recompiles the validator and re-validates the identical string.

cargo nextest run --workspace: 7404 passed. fmt and clippy clean.

@brynary
brynary marked this pull request as ready for review July 28, 2026 20:11

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_objects now intentionally skips nested {...} blocks (via i = j), but this helper is also used by extract_status_fields and validate_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;

@brynary
brynary merged commit 4ab959f into main Jul 28, 2026
15 checks passed
@brynary
brynary deleted the fix/output-schema-outermost-object branch July 28, 2026 20:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants