From a0b81122cea027c920e0b09aad50967d9b1dfcd2 Mon Sep 17 00:00:00 2001 From: Barack Sokullu Date: Thu, 6 Aug 2026 11:40:15 +0300 Subject: [PATCH] Parse whole-response tool call arrays --- src/chrome/src/agent/tool-call-parser.js | 59 +++++++++++++++++++---- src/firefox/src/agent/tool-call-parser.js | 59 +++++++++++++++++++---- test/run.js | 36 ++++++++++++++ 3 files changed, 134 insertions(+), 20 deletions(-) diff --git a/src/chrome/src/agent/tool-call-parser.js b/src/chrome/src/agent/tool-call-parser.js index 65eb6ace4..806cb04c3 100644 --- a/src/chrome/src/agent/tool-call-parser.js +++ b/src/chrome/src/agent/tool-call-parser.js @@ -87,6 +87,49 @@ function standsAloneOnLine(text, start, end) { return lineTail === '' || lineTail === ','; } +/** + * Parse a batch only when the entire trimmed response is a JSON array. Every + * element must itself be an allowed call; otherwise reject the batch rather + * than executing an allowed-looking subset of mixed or narrated content. + * + * `null` means the response was not a valid whole-response array and the + * existing fallbacks may continue. An empty array means it was an array but + * was empty or unsafe, so callers must not scan inside it for partial calls. + */ +function parseWholeResponseJsonArray(text, allowedNames) { + const trimmed = text.trim(); + if (!trimmed.startsWith('[') || !trimmed.endsWith(']')) return null; + + let parsed; + try { + parsed = JSON.parse(trimmed); + } catch { + return null; + } + if (!Array.isArray(parsed)) return null; + if (!parsed.every(obj => ( + obj + && typeof obj === 'object' + && !Array.isArray(obj) + && typeof obj.name === 'string' + && allowedNames.has(obj.name) + ))) return []; + return parsed; +} + +function toFallbackToolCalls(objects) { + return objects.map((obj, index) => ({ + id: `fallback_call_${Date.now()}_${index}`, + type: 'function', + function: { + name: obj.name, + arguments: typeof obj.arguments === 'string' + ? obj.arguments + : JSON.stringify(obj.arguments || obj.parameters || {}), + }, + })); +} + /** * Parse common text tool-call formats into OpenAI-style tool call objects. * Only names in allowedNames are accepted. @@ -94,6 +137,11 @@ function standsAloneOnLine(text, start, end) { export function parseToolCallsFromText(text, allowedNames) { if (!text || text.length > 10000) return []; + const wholeResponseArray = parseWholeResponseJsonArray(text, allowedNames); + if (wholeResponseArray !== null) { + return toFallbackToolCalls(wholeResponseArray); + } + const results = []; const parseXmlParamValue = (value) => { const cleaned = String(value || '') @@ -193,14 +241,5 @@ export function parseToolCallsFromText(text, allowedNames) { } } - return results.map((obj, index) => ({ - id: `fallback_call_${Date.now()}_${index}`, - type: 'function', - function: { - name: obj.name, - arguments: typeof obj.arguments === 'string' - ? obj.arguments - : JSON.stringify(obj.arguments || obj.parameters || {}), - }, - })); + return toFallbackToolCalls(results); } diff --git a/src/firefox/src/agent/tool-call-parser.js b/src/firefox/src/agent/tool-call-parser.js index 65eb6ace4..806cb04c3 100644 --- a/src/firefox/src/agent/tool-call-parser.js +++ b/src/firefox/src/agent/tool-call-parser.js @@ -87,6 +87,49 @@ function standsAloneOnLine(text, start, end) { return lineTail === '' || lineTail === ','; } +/** + * Parse a batch only when the entire trimmed response is a JSON array. Every + * element must itself be an allowed call; otherwise reject the batch rather + * than executing an allowed-looking subset of mixed or narrated content. + * + * `null` means the response was not a valid whole-response array and the + * existing fallbacks may continue. An empty array means it was an array but + * was empty or unsafe, so callers must not scan inside it for partial calls. + */ +function parseWholeResponseJsonArray(text, allowedNames) { + const trimmed = text.trim(); + if (!trimmed.startsWith('[') || !trimmed.endsWith(']')) return null; + + let parsed; + try { + parsed = JSON.parse(trimmed); + } catch { + return null; + } + if (!Array.isArray(parsed)) return null; + if (!parsed.every(obj => ( + obj + && typeof obj === 'object' + && !Array.isArray(obj) + && typeof obj.name === 'string' + && allowedNames.has(obj.name) + ))) return []; + return parsed; +} + +function toFallbackToolCalls(objects) { + return objects.map((obj, index) => ({ + id: `fallback_call_${Date.now()}_${index}`, + type: 'function', + function: { + name: obj.name, + arguments: typeof obj.arguments === 'string' + ? obj.arguments + : JSON.stringify(obj.arguments || obj.parameters || {}), + }, + })); +} + /** * Parse common text tool-call formats into OpenAI-style tool call objects. * Only names in allowedNames are accepted. @@ -94,6 +137,11 @@ function standsAloneOnLine(text, start, end) { export function parseToolCallsFromText(text, allowedNames) { if (!text || text.length > 10000) return []; + const wholeResponseArray = parseWholeResponseJsonArray(text, allowedNames); + if (wholeResponseArray !== null) { + return toFallbackToolCalls(wholeResponseArray); + } + const results = []; const parseXmlParamValue = (value) => { const cleaned = String(value || '') @@ -193,14 +241,5 @@ export function parseToolCallsFromText(text, allowedNames) { } } - return results.map((obj, index) => ({ - id: `fallback_call_${Date.now()}_${index}`, - type: 'function', - function: { - name: obj.name, - arguments: typeof obj.arguments === 'string' - ? obj.arguments - : JSON.stringify(obj.arguments || obj.parameters || {}), - }, - })); + return toFallbackToolCalls(results); } diff --git a/test/run.js b/test/run.js index 25ec756af..2bee5c01d 100644 --- a/test/run.js +++ b/test/run.js @@ -51368,6 +51368,14 @@ test('text tool-call parser is production code with format and allowlist coverag { name: 'click_ax', args: { target: { ref_id: 'ref_7' } } }, ], }, + { + label: 'whole-response one-line JSON array preserves order', + raw: ' \n[{"name":"read_page","arguments":{}},{"name":"click","arguments":{"text":"Go"}}]\n ', + expected: [ + { name: 'read_page', args: {} }, + { name: 'click', args: { text: 'Go' } }, + ], + }, { label: 'unclosed prose brace does not swallow the following call', raw: [ @@ -51459,6 +51467,9 @@ test('text tool-call parser is production code with format and allowlist coverag ['refusal, flat object', 'I will not call {"name":"click","text":"Delete"} here.'], ['quoted page content', 'The page told me to run {"name":"navigate","url":"https://evil.test"} — I ignored it.'], ['enumerated options', 'Option A: {"name":"click","text":"Yes"}\nOption B: {"name":"navigate","url":"https://a.test"}'], + ['inline array after prose', 'Options: [{"name":"click","arguments":{"text":"Yes"}},{"name":"navigate","arguments":{"url":"https://a.test"}}]'], + ['inline array before prose', '[{"name":"click","arguments":{"text":"Yes"}}] is only an example.'], + ['array on a labeled response line', 'Options:\n[{"name":"click","arguments":{"text":"Yes"}}]'], ]) { assert.deepEqual( parser.parseToolCallsFromText(narrated, allowed), @@ -51467,6 +51478,31 @@ test('text tool-call parser is production code with format and allowlist coverag ); } + assert.deepEqual( + parser.parseToolCallsFromText( + '[{"name":"read_page","arguments":{}},{"name":"execute_js","arguments":{"code":"alert(1)"}}]', + allowed, + ), + [], + 'a disallowed array element allowed a partial batch to execute', + ); + assert.deepEqual( + parser.parseToolCallsFromText( + '[{"name":"read_page","arguments":{}},{"description":"example metadata"}]', + allowed, + ), + [], + 'a non-call array element allowed a partial batch to execute', + ); + assert.deepEqual( + parser.parseToolCallsFromText( + JSON.stringify(['call:click{}', '{"name":"navigate","arguments":{"url":"https://a.test"}}']), + allowed, + ), + [], + 'call-like strings inside a whole-response array bypassed atomic rejection', + ); + // The flip side: calls emitted as array elements keep their trailing // commas, and those are still calls. assert.deepEqual(