[rig-sampler] fix: extract balanced JSON arrays from prose responses#31
Conversation
Previously extractBalancedJsonObject only scanned for '{...}' objects.
Agents with array output schemas (s.array(...)) would fail to parse
responses where the model embedded the JSON array in surrounding prose,
triggering an unnecessary repair turn.
Rename to extractBalancedJson and pick whichever container character
({ or [) appears first, so both objects and arrays are handled.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
🧠 Matt Pocock Skills Reviewer has completed the skills-based review. ✅ |
There was a problem hiding this comment.
Skills-Based Review 🧠
Applied /diagnosing-bugs and /tdd — requesting changes on one correctness issue.
📋 Key Themes & Highlights
Key Themes
- Correctness risk in start-position heuristic:
Math.min(objectStart, arrayStart)picks the first bracket character in the text. Prose responses that use[word]notation before the actual JSON (very common in LLM output:"Use tags [optional]: {...}") will causeextractBalancedJsonto latch onto the bracket span, return malformed text, and trip the JSON.parse error path — a regression relative to the old behaviour. - Missing regression test: no test locks in correct behaviour when prose contains a
[-delimited span before a JSON object.
Positive Highlights
- ✅ Rename
extractBalancedJsonObject→extractBalancedJsonaccurately reflects expanded scope - ✅ Fix for array extraction is clean and minimal
- ✅ New test mirrors the existing object-in-prose test nicely
🧠 Reviewed using Matt Pocock's skills by Matt Pocock Skills Reviewer · 33.4 AIC · ⌖ 4.23 AIC · ⊞ 6.3K
Comment /matt to run again
| const start = | ||
| objectStart === -1 ? arrayStart : | ||
| arrayStart === -1 ? objectStart : | ||
| Math.min(objectStart, arrayStart); |
There was a problem hiding this comment.
[/diagnosing-bugs] Picking the earlier of [ and { can silently break object extraction when prose contains a bracket-delimited span (e.g. "Use tags [optional]: {\"key\":1}") — [optional] would be extracted, fail JSON.parse, and return an error instead of finding the valid object.
💡 Safer approach: try both, return the one that parses
One robust fix is to try extracting from both candidate positions and return whichever one successfully parses:
function extractBalancedJson(text: string): string | undefined {
const candidates = [text.indexOf("{"), text.indexOf("[")]
.filter(i => i !== -1)
.sort((a, b) => a - b);
for (const start of candidates) {
const result = extractBalancedJsonFrom(text, start);
if (result !== undefined) {
try { JSON.parse(result); return result; } catch { /* try next */ }
}
}
return undefined;
}Alternatively, always prefer the earlier candidate but fall through to the other on parse failure — the current structure in parseJson already catches parse errors, so the fallback could live there.
The current heuristic works for the new test case but introduces a new failure mode for any prose that uses [...] notation before a JSON object.
| output: s.array(s.string), | ||
| }); | ||
|
|
||
| await expect(lister("go")).resolves.toEqual(["alpha", "beta"]); |
There was a problem hiding this comment.
[/tdd] The new test covers the happy path but misses the case where prose contains [markdown link] or [note] text before a JSON object — exactly the regression the Math.min heuristic can introduce.
💡 Suggested regression test
it("parses a JSON object when prose contains a bracket span before it", async () => {
mocks.setSendAndWaitImpl(async () => "Tags [optional]: {\"key\":\"value\"}");
const tagger = agent({
name: "tagger",
output: s.object({ key: s.string }),
});
await expect(tagger("go")).resolves.toEqual({ key: "value" });
});This test would currently fail, making the regression detectable before merging. Adding it alongside the implementation fix would lock in correctness for both types.
Sample run:
11-release-notes.tsThe harness ran sample 11 (
11-release-notes.ts) successfully in a single turn with no repair loops. The agent useds.enum,s.object,s.array, ands.string— all working correctly.What the run revealed
While analyzing the JSON parsing fallback path (
extractBalancedJsonObject), I found a gap: the function only scanned for{...}JSON objects. Agents whose output schema iss.array(...)could receive a response from the model where the JSON array is embedded in surrounding prose text (e.g.,"Here are the results:\n["alpha","beta"]\nDone."). In that case:JSON.parse(text)fails (not bare JSON)extractFencedJsonfails (not in a fenced block)extractBalancedJsonObjectfails to find{and returnsundefined"No JSON value found."triggers a repair turn — even though the array data was presentChange
Renamed
extractBalancedJsonObject→extractBalancedJsonand updated the logic to pick whichever of{or[appears first in the response, then balance the corresponding close character (}or]). This means prose-wrapped arrays are extracted correctly without a repair loop.A new test verifies the fix:
All 130 existing tests continue to pass.