Skip to content

fix(structured): recover most-complete object + unwrap string-literal output in coerceJsonToSchema - #1088

Merged
murdore merged 1 commit into
releasefrom
fix/structured-output-recovery
Jun 13, 2026
Merged

murdore merged 1 commit into
releasefrom
fix/structured-output-recovery

Conversation

@murdore

@murdore murdore commented Jun 13, 2026 •

Copy link
Copy Markdown
Contributor

Problem

coerceJsonToSchema() is NeuroLink's fallback that recovers a schema-valid object from imperfect model text when AI-SDK experimental_output yields nothing. It already scans balanced spans + jsonrepairs, but two robustness gaps forced every downstream consumer to hand-roll its own extractor on top:

  1. First-match vs. most-complete. It broke on the first schema-valid candidate. With a nullable-field schema (e.g. { summary, attachment: z.object(...).nullable() }), a lean preamble {"summary":"working…","attachment":null} validates alongside the real {"summary":"done","attachment":{…}}. First-match returned the preamble and discarded the payload — the classic "model streamed a short preamble then the real answer, and the file never got delivered" symptom (seen in production traces).
  2. No JSON-string-literal unwrap. Some providers double-encode and return the object as a JSON string ("{\"k\":1}"); the balanced scan over the escaped text can't recover it.

Fix (additive, low-risk)

In src/lib/utils/json/coerce.ts:

  • Most-complete selection. Instead of breaking on the first schema-valid candidate, collect all schema-valid candidates and pick the one whose serialized form carries the most content. No behavior change when 0 or 1 candidate validates, or when no schema is supplied (first parseable object still wins).
  • String-literal unwrap. If the whole text is a JSON string literal, unwrap one layer and add the inner text's balanced spans as candidates.

Why this belongs here

This is generic structured-output robustness — the same logic was duplicated in at least one downstream app (Tara/curator). Centralizing it means every NeuroLink consumer gets correct multi-candidate recovery and the app-side extractor can be deleted.

Verification

  • New deterministic suite test/continuous-test-suite-structured-coerce.ts (7/7): preamble-vs-payload both orders, fenced-in-prose, string-literal wrapper, clean object, no-schema first-wins, and no-object→null.
  • pnpm build clean (lint + typecheck pass).

Scope

Single file + test. Behavior is unchanged for the common single-candidate path; only the ambiguous multi-valid-candidate and double-encoded cases change (both strictly toward recovering the intended object).

Summary by CodeRabbit

  • Bug Fixes

    • Improved JSON extraction to handle double-encoded JSON strings
    • Enhanced JSON candidate selection to prioritize more complete results when multiple valid options exist
  • Tests

    • Added comprehensive test coverage for JSON parsing edge cases

…eral output

coerceJsonToSchema() is the fallback that recovers a schema-valid object from
imperfect model text when AI-SDK experimental_output yields nothing. Two
robustness gaps closed (previously each consumer hand-rolled this):

- Among MULTIPLE schema-valid candidates, prefer the most COMPLETE one instead
  of breaking on the first match. With nullable fields a lean preamble
  ({summary, attachment:null}) validates alongside the real
  {summary, attachment:{...}}; first-match returned the preamble and dropped the
  payload (the classic 'no file delivered' production symptom).
- Unwrap a JSON-string-literal wrapper (providers that double-encode the object
  as a JSON string) and scan the inner text for balanced spans.

No behavior change when 0 or 1 candidate validates, or when no schema is given
(first parseable object still wins). Adds a deterministic test suite.
Copilot AI review requested due to automatic review settings June 13, 2026 12:09
@vercel

vercel Bot commented Jun 13, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
neurolink Building Building Preview Jun 13, 2026 12:09pm

@coderabbitai

coderabbitai Bot commented Jun 13, 2026 •

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

coerceJsonToSchema now unwraps double-encoded JSON strings and selects the most complete schema-valid candidate instead of the first match. The change includes detection of JSON string literals, accumulation of all valid candidates, and comprehensive test coverage for these behaviors.

Changes

JSON Coercion Enhancement

Layer / File(s) Summary
JSON String Literal Unwrapping
src/lib/utils/json/coerce.ts
Detects and unwraps JSON string literals where the entire model output is a quoted JSON object or array. Parses the outer string, scans the inner text for balanced JSON spans, and appends them as additional parse candidates.
Most-Complete Candidate Selection
src/lib/utils/json/coerce.ts
Updates schema-valid candidate selection from stopping at the first match to accumulating all matches, then selecting the one with the longest JSON.stringify representation. Replaces single schemaMatch tracking with a schemaValid collection.
Comprehensive Test Suite
test/continuous-test-suite-structured-coerce.ts
Validates candidate selection prefers most-complete match independent of order, extracts JSON from fenced code blocks, unwraps double-encoded strings, preserves clean objects, falls back to first match when no schema is provided, and returns null when no JSON is present.

🎯 3 (Moderate) | ⏱️ ~20 minutes

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title precisely captures both main changes (most-complete object recovery and string-literal unwrapping) in a concise, specific manner that clearly conveys the PR's primary purpose.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/structured-output-recovery

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions

Copy link
Copy Markdown
Contributor

✅ Single Commit Policy - COMPLIANT

Status: Policy requirements met • 1 commit • Valid format • Ready for merge

📊 View validation details

📝 Commit Details

  • Hash: 2adb0a2a768bbc4a657fa40b87972350b26eff30
  • Message: fix(structured): recover the most-complete object + unwrap string-literal output
  • Author: Sachin Sharma

✅ Validation Results

  • Single commit requirement met
  • No merge commits in branch
  • Semantic commit message format verified
  • Ready for squash merge to release branch

🤖 Automated validation by NeuroLink Single Commit Enforcement

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@github-actions

Copy link
Copy Markdown
Contributor

🤖 AI Review & Build Compliance ✅

Status: AI analysis complete • Build rules validated • Ready for review

📊 View detailed analysis results

🛡️ Analysis Complete

  • ✅ Security scan (vulnerabilities, API keys)
  • ✅ TypeScript safety & code quality
  • ✅ Error handling & best practices
  • ✅ Build rule enforcement validated
  • ✅ Commit format & compliance checks

📋 Ready for Merge When

  • All CI checks passing
  • Manual review approved
  • Any AI-flagged issues resolved

🤖 AI analysis complete - check individual code comments for specific feedback

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
src/lib/utils/json/coerce.ts (1)

176-188: ⚡ Quick win

Consider caching JSON.stringify lengths to avoid redundant serialization.

The reduce calls JSON.stringify(cur.value).length and JSON.stringify(best.value).length on each comparison. For N schema-valid candidates, this results in 2*(N-1) stringify calls. Pre-computing and storing the stringified length for each candidate would reduce this to N calls, which could meaningfully improve performance when objects are large or many candidates are present.

⚡ Proposed optimization
+ // Pre-compute stringify lengths for schema-valid candidates
+ const schemaValidWithSize = schemaValid.map((record) => ({
+   ...record,
+   size: JSON.stringify(record.value).length,
+ }));
+
  const schemaMatch =
-   schemaValid.length > 0
-     ? schemaValid.reduce((best, cur) =>
-         JSON.stringify(cur.value).length > JSON.stringify(best.value).length
-           ? cur
-           : best,
-       )
+   schemaValidWithSize.length > 0
+     ? schemaValidWithSize.reduce((best, cur) =>
+         cur.size > best.size ? cur : best,
+       )
      : undefined;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/utils/json/coerce.ts` around lines 176 - 188, The reduce that selects
schemaMatch repeatedly calls JSON.stringify on cur.value and best.value causing
redundant serializations; fix by precomputing and caching each candidate's
serialized length (e.g., map schemaValid to include a computed length for each
entry) and then use those cached lengths inside the reduce to compare instead of
re-stringifying cur.value/best.value, updating any references from
cur.value/best.value length checks to the new cachedLength field on the
candidate objects.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@src/lib/utils/json/coerce.ts`:
- Around line 176-188: The reduce that selects schemaMatch repeatedly calls
JSON.stringify on cur.value and best.value causing redundant serializations; fix
by precomputing and caching each candidate's serialized length (e.g., map
schemaValid to include a computed length for each entry) and then use those
cached lengths inside the reduce to compare instead of re-stringifying
cur.value/best.value, updating any references from cur.value/best.value length
checks to the new cachedLength field on the candidate objects.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 598d4fd9-1e87-4138-a0d2-234cae209a57

📥 Commits

Reviewing files that changed from the base of the PR and between f47b657 and 2adb0a2.

📒 Files selected for processing (2)
  • src/lib/utils/json/coerce.ts
  • test/continuous-test-suite-structured-coerce.ts

@Tara-ag Tara-ag 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.

Review Summary

Files reviewed: 2
New issues raised: 2 (both MINOR)
Blocking issues: 0

Assessment

This PR is approved for merge. The changes are well-designed, thoroughly tested, and address real production issues.

What Changed

  1. src/lib/utils/json/coerce.ts - Enhanced JSON coercion with two robustness improvements:

    • Most-complete selection: When multiple schema-valid candidates exist, picks the one with the most content (serialized length) instead of first-match. This fixes the "preamble vs payload" issue where a lean {summary, attachment: null} was incorrectly selected over the full object.
    • String-literal unwrapping: Handles double-encoded JSON from providers that return objects as JSON strings ("{\"k\":1}").
  2. test/continuous-test-suite-structured-coerce.ts - New deterministic test suite with 7 test cases covering edge cases.

Verification

✅ Security: No hardcoded secrets, no injection risks, no unsafe eval
✅ Architecture: Changes localized to utility function; no circular dependencies introduced
✅ Backward compatibility: Behavior unchanged for single-candidate path (the common case)
✅ Type safety: Proper types used, no any or @ts-ignore
✅ Testing: Comprehensive test coverage for new functionality
✅ CLAUDE.md compliance: No violations of critical rules

Minor Suggestions (non-blocking)

  1. Performance: The reduce callback could cache JSON.stringify() results to avoid redundant serialization (2×(N-1) calls for N candidates)
  2. Developer experience: Consider adding "test:structured-coerce": "npx tsx test/continuous-test-suite-structured-coerce.ts" to package.json scripts

Code Quality Notes

  • Clean implementation with clear comments explaining the rationale
  • Good use of existing patterns (nextBalancedJsonSpan, hasSafeParse)
  • Proper handling of edge cases (empty input, no schema, parse failures)
  • Test assertions are deterministic and don't rely on live API calls

The PR is ready to merge. 🚀


// Among schema-valid candidates prefer the MOST COMPLETE one. With nullable
// fields a lean object (e.g. `{summary, attachment: null}`) validates
// alongside the full object, so breaking on the first match would drop the

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.

💡 MINOR: Performance optimization opportunity

The reduce callback calls JSON.stringify() on both cur.value and best.value for every comparison. For N schema-valid candidates, this results in 2*(N-1) serializations.

Suggestion: Cache the serialized length to avoid redundant work:

const schemaMatch =
  schemaValid.length > 0
    ? schemaValid
        .map((s) => ({ ...s, serializedLength: JSON.stringify(s.value).length }))
        .reduce((best, cur) =>
          cur.serializedLength > best.serializedLength ? cur : best
        )
    : undefined;

This is a minor optimization since schemaValid is typically small, but worth considering for consistency with the codebase's performance-conscious patterns.

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.

💡 MINOR: Test script not registered in package.json

The new test file test/continuous-test-suite-structured-coerce.ts exists but there's no corresponding npm script to run it conveniently. Consider adding to package.json:

"test:structured-coerce": "npx tsx test/continuous-test-suite-structured-coerce.ts"

This follows the pattern of other test suites like test:json, test:workflow, etc.

@murdore
murdore merged commit 72e6d96 into release Jun 13, 2026
17 of 18 checks passed
@murdore
murdore deleted the fix/structured-output-recovery branch June 13, 2026 12:40
@github-actions

Copy link
Copy Markdown
Contributor

🎉 This PR is included in version 9.70.4 🎉

The release is available on:

Your semantic-release bot 📦🚀

This branch was successfully deployed

1 active deployment
Preview — 2adb0a2a Deployed Jun 13, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants