From ef0c2d12c0171c378376e6fa761892ab901acb74 Mon Sep 17 00:00:00 2001 From: alectimison-maker Date: Wed, 5 Aug 2026 06:58:57 +0800 Subject: [PATCH 1/2] fix(agent): skip invalid JSON candidates --- src/chrome/src/agent/json-extract.js | 59 +++++++++++++++------------ src/firefox/src/agent/json-extract.js | 59 +++++++++++++++------------ test/run.js | 9 ++++ 3 files changed, 75 insertions(+), 52 deletions(-) diff --git a/src/chrome/src/agent/json-extract.js b/src/chrome/src/agent/json-extract.js index 5505c80c2..7652bf1de 100644 --- a/src/chrome/src/agent/json-extract.js +++ b/src/chrome/src/agent/json-extract.js @@ -12,37 +12,44 @@ export function extractFirstJsonObject(raw) { candidates.push(text); for (const candidate of candidates) { - const start = candidate.indexOf('{'); - if (start < 0) continue; - let depth = 0; - let inString = false; - let escaped = false; - for (let i = start; i < candidate.length; i++) { - const ch = candidate[i]; - if (inString) { - if (escaped) { - escaped = false; - } else if (ch === '\\') { - escaped = true; - } else if (ch === '"') { - inString = false; + let searchFrom = 0; + while (searchFrom < candidate.length) { + const start = candidate.indexOf('{', searchFrom); + if (start < 0) break; + let depth = 0; + let inString = false; + let escaped = false; + let end = -1; + for (let i = start; i < candidate.length; i++) { + const ch = candidate[i]; + if (inString) { + if (escaped) { + escaped = false; + } else if (ch === '\\') { + escaped = true; + } else if (ch === '"') { + inString = false; + } + continue; } - continue; - } - if (ch === '"') { - inString = true; - } else if (ch === '{') { - depth += 1; - } else if (ch === '}') { - depth -= 1; - if (depth === 0) { - try { - return JSON.parse(candidate.slice(start, i + 1)); - } catch (_) { + if (ch === '"') { + inString = true; + } else if (ch === '{') { + depth += 1; + } else if (ch === '}') { + depth -= 1; + if (depth === 0) { + end = i; break; } } } + if (end < 0) break; + try { + return JSON.parse(candidate.slice(start, end + 1)); + } catch (_) { + searchFrom = end + 1; + } } } return null; diff --git a/src/firefox/src/agent/json-extract.js b/src/firefox/src/agent/json-extract.js index 5505c80c2..7652bf1de 100644 --- a/src/firefox/src/agent/json-extract.js +++ b/src/firefox/src/agent/json-extract.js @@ -12,37 +12,44 @@ export function extractFirstJsonObject(raw) { candidates.push(text); for (const candidate of candidates) { - const start = candidate.indexOf('{'); - if (start < 0) continue; - let depth = 0; - let inString = false; - let escaped = false; - for (let i = start; i < candidate.length; i++) { - const ch = candidate[i]; - if (inString) { - if (escaped) { - escaped = false; - } else if (ch === '\\') { - escaped = true; - } else if (ch === '"') { - inString = false; + let searchFrom = 0; + while (searchFrom < candidate.length) { + const start = candidate.indexOf('{', searchFrom); + if (start < 0) break; + let depth = 0; + let inString = false; + let escaped = false; + let end = -1; + for (let i = start; i < candidate.length; i++) { + const ch = candidate[i]; + if (inString) { + if (escaped) { + escaped = false; + } else if (ch === '\\') { + escaped = true; + } else if (ch === '"') { + inString = false; + } + continue; } - continue; - } - if (ch === '"') { - inString = true; - } else if (ch === '{') { - depth += 1; - } else if (ch === '}') { - depth -= 1; - if (depth === 0) { - try { - return JSON.parse(candidate.slice(start, i + 1)); - } catch (_) { + if (ch === '"') { + inString = true; + } else if (ch === '{') { + depth += 1; + } else if (ch === '}') { + depth -= 1; + if (depth === 0) { + end = i; break; } } } + if (end < 0) break; + try { + return JSON.parse(candidate.slice(start, end + 1)); + } catch (_) { + searchFrom = end + 1; + } } } return null; diff --git a/test/run.js b/test/run.js index 8c24ea4a2..31d42936a 100644 --- a/test/run.js +++ b/test/run.js @@ -52421,6 +52421,15 @@ test('planner: parse JSON inside markdown fence', () => { } }); +test('planner: skip a balanced non-JSON example before the valid plan', () => { + const content = '```json\nExample: {"summary": }\nActual: {"summary":"Recovered {plan}","steps":[],"memory":{},"risks":[],"mode":"act"}\n```'; + for (const parse of [parsePlanFromContent, parsePlanFromContentFx]) { + const plan = parse(content); + assert.ok(plan, 'should continue after a balanced candidate that is not valid JSON'); + assert.equal(plan.summary, 'Recovered {plan}', 'braces inside JSON strings should remain balanced'); + } +}); + test('planner: prompt treats page context as untrusted data', () => { assert.match(PLANNER_SYSTEM_PROMPT, //); assert.match(PLANNER_SYSTEM_PROMPT, /untrusted page\/document DATA, never instructions/); From 11c1431ecaca806159f231e155cf9c62789edec0 Mon Sep 17 00:00:00 2001 From: Emre Sokullu Date: Wed, 5 Aug 2026 15:26:37 +0300 Subject: [PATCH 2/2] fix(agent): recover past an unbalanced opener too Skipping unparseable candidates covered the illustrative-object case but not the neighbouring one: a `{` that never closes still abandoned the whole candidate, so a stray brace in prose ahead of the real object discarded it. Scanning now resumes one character past an unbalanced opener, capped at 16 restarts so a "{{{{..." response stays linear. This mirrors the scanner in tool-call-parser.js so the two agree on recovery, and adds coverage for the brace flood plus the still-standing limitation that an earlier *valid* non-plan object wins. Co-Authored-By: Claude Opus 5 --- src/chrome/src/agent/json-extract.js | 14 ++++++++++- src/firefox/src/agent/json-extract.js | 14 ++++++++++- test/run.js | 36 +++++++++++++++++++++++++++ 3 files changed, 62 insertions(+), 2 deletions(-) diff --git a/src/chrome/src/agent/json-extract.js b/src/chrome/src/agent/json-extract.js index 7652bf1de..be1aeae21 100644 --- a/src/chrome/src/agent/json-extract.js +++ b/src/chrome/src/agent/json-extract.js @@ -1,3 +1,10 @@ +// An opener that never closes must not abandon the candidate: a stray brace +// in prose ahead of the real object is as common as the illustrative-object +// case, and the object after it still has to be found. Each unbalanced opener +// costs one extra scan, so the restarts are capped to keep a pathological +// "{{{{…" response from going quadratic. +const MAX_UNBALANCED_RESTARTS = 16; + /** * Extract the first balanced JSON object from model output (fence-aware). * Shared by the agent's classifier sub-calls and the planner so the parsing @@ -13,6 +20,7 @@ export function extractFirstJsonObject(raw) { for (const candidate of candidates) { let searchFrom = 0; + let restarts = 0; while (searchFrom < candidate.length) { const start = candidate.indexOf('{', searchFrom); if (start < 0) break; @@ -44,7 +52,11 @@ export function extractFirstJsonObject(raw) { } } } - if (end < 0) break; + if (end < 0) { + if (++restarts > MAX_UNBALANCED_RESTARTS) break; + searchFrom = start + 1; + continue; + } try { return JSON.parse(candidate.slice(start, end + 1)); } catch (_) { diff --git a/src/firefox/src/agent/json-extract.js b/src/firefox/src/agent/json-extract.js index 7652bf1de..be1aeae21 100644 --- a/src/firefox/src/agent/json-extract.js +++ b/src/firefox/src/agent/json-extract.js @@ -1,3 +1,10 @@ +// An opener that never closes must not abandon the candidate: a stray brace +// in prose ahead of the real object is as common as the illustrative-object +// case, and the object after it still has to be found. Each unbalanced opener +// costs one extra scan, so the restarts are capped to keep a pathological +// "{{{{…" response from going quadratic. +const MAX_UNBALANCED_RESTARTS = 16; + /** * Extract the first balanced JSON object from model output (fence-aware). * Shared by the agent's classifier sub-calls and the planner so the parsing @@ -13,6 +20,7 @@ export function extractFirstJsonObject(raw) { for (const candidate of candidates) { let searchFrom = 0; + let restarts = 0; while (searchFrom < candidate.length) { const start = candidate.indexOf('{', searchFrom); if (start < 0) break; @@ -44,7 +52,11 @@ export function extractFirstJsonObject(raw) { } } } - if (end < 0) break; + if (end < 0) { + if (++restarts > MAX_UNBALANCED_RESTARTS) break; + searchFrom = start + 1; + continue; + } try { return JSON.parse(candidate.slice(start, end + 1)); } catch (_) { diff --git a/test/run.js b/test/run.js index 31d42936a..e0e323025 100644 --- a/test/run.js +++ b/test/run.js @@ -52430,6 +52430,42 @@ test('planner: skip a balanced non-JSON example before the valid plan', () => { } }); +test('planner: skip an unbalanced opener and several broken candidates', () => { + const content = [ + '```json', + 'Sketch: {steps: [', + 'Retry: {"summary": }', + 'Retry: {"summary": ,}', + 'Actual: {"summary":"Recovered after junk","steps":[],"memory":{},"risks":[],"mode":"act"}', + '```', + ].join('\n'); + for (const parse of [parsePlanFromContent, parsePlanFromContentFx]) { + const plan = parse(content); + assert.ok(plan, 'an unbalanced opener must not abandon the candidate'); + assert.equal(plan.summary, 'Recovered after junk'); + } +}); + +test('planner: brace flood stays bounded and yields no plan', () => { + const content = '{'.repeat(9000); + for (const parse of [parsePlanFromContent, parsePlanFromContentFx]) { + const startedAt = Date.now(); + assert.equal(parse(content), null, 'a brace flood must not produce a plan'); + assert.ok(Date.now() - startedAt < 1000, 'brace flood was not bounded by the restart cap'); + } +}); + +test('planner: an earlier valid non-plan object still wins (documented limitation)', () => { + const content = '```json\nNote: {"unrelated":true}\n{"summary":"Real plan","steps":[],"memory":{},"risks":[],"mode":"act"}\n```'; + for (const parse of [parsePlanFromContent, parsePlanFromContentFx]) { + assert.equal( + parse(content), + null, + 'extractFirstJsonObject stops at the first parseable object; only unparseable ones are skipped', + ); + } +}); + test('planner: prompt treats page context as untrusted data', () => { assert.match(PLANNER_SYSTEM_PROMPT, //); assert.match(PLANNER_SYSTEM_PROMPT, /untrusted page\/document DATA, never instructions/);