Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 49 additions & 10 deletions src/chrome/src/agent/tool-call-parser.js
Original file line number Diff line number Diff line change
Expand Up @@ -87,13 +87,61 @@ 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.
*/
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 || '')
Expand Down Expand Up @@ -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);
}
59 changes: 49 additions & 10 deletions src/firefox/src/agent/tool-call-parser.js
Original file line number Diff line number Diff line change
Expand Up @@ -87,13 +87,61 @@ 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.
*/
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 || '')
Expand Down Expand Up @@ -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);
}
36 changes: 36 additions & 0 deletions test/run.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down Expand Up @@ -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),
Expand All @@ -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{}', '<tool_call>{"name":"navigate","arguments":{"url":"https://a.test"}}</tool_call>']),
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(
Expand Down
Loading