Skip to content

Commit cab3a8c

Browse files
authored
Merge pull request #514 from alectimison-maker/refactor/extract-tool-call-parser
refactor(agent): extract text tool-call parser
2 parents ce408ea + f4fc92b commit cab3a8c

5 files changed

Lines changed: 353 additions & 248 deletions

File tree

src/chrome/src/agent/agent.js

Lines changed: 2 additions & 130 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { AGENT_TOOLS, AGENT_TOOL_NAMES, RESERVED_AGENT_TOOL_NAMES, getToolsForMode, SYSTEM_PROMPT_ASK, SYSTEM_PROMPT_ACT, SYSTEM_PROMPT_ACT_COMPACT, SYSTEM_PROMPT_ACT_MID, SYSTEM_PROMPT_DEV_APPENDIX, SYSTEM_PROMPT_WEBMCP_ASK, SYSTEM_PROMPT_WEBMCP_ACT } from './tools.js';
22
import { handleDoneJson } from './cloud-output.js';
33
import { LoopDetector } from './loop-detector.js';
4+
import { parseToolCallsFromText } from './tool-call-parser.js';
45
import { BROWSER_MUTATION_TOOLS, STATE_CHANGE_TOOLS as SHARED_STATE_CHANGE_TOOLS } from './mutation-tools.js';
56
import { isCredentialField, CREDENTIAL_NOTE_STRICT, STRICT_SECRET_SYSTEM_NOTE } from './credential-fields.js';
67
import { detectProgressAction, formatLedgerRow, formatLedgerSummary, isBlockedLedgerDowngrade, isTerminalLedgerStatus, isValidLedgerStatus, ledgerDoneBlock, ledgerRowKey, normalizeLedgerStatus, progressCounts, selectLedgerRows, unresolvedLedgerRows, upsertLedgerItems } from './progress-ledger.js';
@@ -18715,136 +18716,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d
1871518716
* smaller set (e.g. COMPACT_TOOL_NAMES) to restrict in compact mode.
1871618717
*/
1871718718
_tryParseToolCallsFromText(text, allowedNames = AGENT_TOOL_NAMES) {
18718-
if (!text || text.length > 10000) return [];
18719-
18720-
const results = [];
18721-
const parseXmlParamValue = (value) => {
18722-
const cleaned = String(value || '')
18723-
.replace(/<[^>]+>/g, '')
18724-
.trim();
18725-
if (!cleaned) return '';
18726-
try {
18727-
if (/^(?:"|'.*'|\{|\[|-?\d|true\b|false\b|null\b)/i.test(cleaned)) {
18728-
return JSON.parse(cleaned.replace(/^'([\s\S]*)'$/, '"$1"'));
18729-
}
18730-
} catch { /* fall through to string cleanup */ }
18731-
return cleaned.replace(/^["']+|["']+$/g, '');
18732-
};
18733-
18734-
// Collect candidate JSON strings from known wrapper patterns.
18735-
const patterns = [
18736-
// <tool_call>JSON</tool_call>
18737-
/<tool_call>\s*([\s\S]*?)\s*<\/tool_call>/gi,
18738-
// <|tool_call|>JSON<|/tool_call|> or <|tool_call>JSON<tool_call|>
18739-
/<\|tool_call\|?>\s*([\s\S]*?)\s*<\|?\/?tool_call\|?>/gi,
18740-
// <functioncall>JSON</functioncall>
18741-
/<functioncall>\s*([\s\S]*?)\s*<\/functioncall>/gi,
18742-
];
18743-
18744-
for (const re of patterns) {
18745-
let m;
18746-
while ((m = re.exec(text)) !== null) {
18747-
const inner = m[1].trim();
18748-
// Try JSON first (most common).
18749-
try {
18750-
const obj = JSON.parse(inner);
18751-
if (obj && obj.name && allowedNames.has(obj.name)) {
18752-
results.push(obj);
18753-
continue;
18754-
}
18755-
} catch { /* not JSON — try call:name{} format below */ }
18756-
18757-
// call:toolName{key:<|"|>value<|"|>, ...} format.
18758-
// Some local models use <|"|> as quote tokens and call:name as the
18759-
// invocation syntax. Normalize to JSON and parse.
18760-
const callMatch = /^call:(\w+)\s*\{([\s\S]*)\}$/.exec(inner);
18761-
if (callMatch && allowedNames.has(callMatch[1])) {
18762-
const toolName = callMatch[1];
18763-
let argsBody = callMatch[2]
18764-
.replace(/<\|"\|>/g, '"') // replace quote tokens with real quotes
18765-
.replace(/<\|'\\?\|>/g, "'"); // handle single-quote tokens if any
18766-
// argsBody is now like: url:"https://example.com",text:"hello"
18767-
// Wrap unquoted keys to make valid JSON: key:"val" → "key":"val"
18768-
argsBody = argsBody.replace(/(?<=^|,)\s*(\w+)\s*:/g, '"$1":');
18769-
try {
18770-
const args = JSON.parse(`{${argsBody}}`);
18771-
results.push({ name: toolName, arguments: args });
18772-
} catch {
18773-
// If JSON parse still fails, try treating entire body as single
18774-
// string argument for zero-arg or simple calls.
18775-
results.push({ name: toolName, arguments: {} });
18776-
}
18777-
continue;
18778-
}
18779-
}
18780-
}
18781-
18782-
// XML-ish tool-call format used by some local/chat-template models:
18783-
// <tool_call><function=click_ax><parameter=ref_id>ref_6</parameter>...
18784-
const xmlToolRe = /<tool_call>\s*<function(?:\s*=\s*["']?([A-Za-z_]\w*)["']?|\s+name\s*=\s*["']?([A-Za-z_]\w*)["']?)\s*>\s*([\s\S]*?)\s*<\/function>\s*<\/tool_call>/gi;
18785-
let xmlMatch;
18786-
while ((xmlMatch = xmlToolRe.exec(text)) !== null) {
18787-
const toolName = xmlMatch[1] || xmlMatch[2];
18788-
if (!allowedNames.has(toolName)) continue;
18789-
const body = xmlMatch[3] || '';
18790-
const args = {};
18791-
const paramRe = /<parameter(?:\s*=\s*["']?([A-Za-z_]\w*)["']?|\s+name\s*=\s*["']?([A-Za-z_]\w*)["']?)\s*>\s*([\s\S]*?)\s*<\/parameter>/gi;
18792-
let paramMatch;
18793-
while ((paramMatch = paramRe.exec(body)) !== null) {
18794-
const key = paramMatch[1] || paramMatch[2];
18795-
if (!key) continue;
18796-
args[key] = parseXmlParamValue(paramMatch[3]);
18797-
}
18798-
results.push({ name: toolName, arguments: args });
18799-
}
18800-
18801-
// Fallback: scan for bare JSON objects containing a "name" key with a
18802-
// known tool name. Only look for top-level objects (starts with {).
18803-
if (results.length === 0) {
18804-
const bareRe = /\{[^{}]*"name"\s*:\s*"(\w+)"[^{}]*\}/g;
18805-
let m;
18806-
while ((m = bareRe.exec(text)) !== null) {
18807-
if (!allowedNames.has(m[1])) continue;
18808-
try {
18809-
const obj = JSON.parse(m[0]);
18810-
if (obj && obj.name && allowedNames.has(obj.name)) {
18811-
results.push(obj);
18812-
}
18813-
} catch { /* skip */ }
18814-
}
18815-
}
18816-
18817-
// Last resort: call:toolName{...} outside of any wrapper tags.
18818-
if (results.length === 0) {
18819-
const callRe = /call:(\w+)\s*\{([\s\S]*?)\}/g;
18820-
let m;
18821-
while ((m = callRe.exec(text)) !== null) {
18822-
if (!allowedNames.has(m[1])) continue;
18823-
const toolName = m[1];
18824-
let argsBody = m[2]
18825-
.replace(/<\|"\|>/g, '"')
18826-
.replace(/<\|'\\?\|>/g, "'");
18827-
argsBody = argsBody.replace(/(?<=^|,)\s*(\w+)\s*:/g, '"$1":');
18828-
try {
18829-
const args = JSON.parse(`{${argsBody}}`);
18830-
results.push({ name: toolName, arguments: args });
18831-
} catch {
18832-
results.push({ name: toolName, arguments: {} });
18833-
}
18834-
}
18835-
}
18836-
18837-
// Convert to OpenAI tool_calls format.
18838-
return results.map((obj, i) => ({
18839-
id: `fallback_call_${Date.now()}_${i}`,
18840-
type: 'function',
18841-
function: {
18842-
name: obj.name,
18843-
arguments: typeof obj.arguments === 'string'
18844-
? obj.arguments
18845-
: JSON.stringify(obj.arguments || obj.parameters || {}),
18846-
},
18847-
}));
18719+
return parseToolCallsFromText(text, allowedNames);
1884818720
}
1884918721
// ─────────────────────────────────────────────────────────────────────
1885018722

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
// Browser-free fallback parser for local models that emit tool calls as text
2+
// instead of using the provider's structured tool_calls field. This file is
3+
// mirrored in the Firefox tree; keep both copies byte-identical.
4+
5+
/**
6+
* Parse common text tool-call formats into OpenAI-style tool call objects.
7+
* Only names in allowedNames are accepted.
8+
*/
9+
export function parseToolCallsFromText(text, allowedNames) {
10+
if (!text || text.length > 10000) return [];
11+
12+
const results = [];
13+
const parseXmlParamValue = (value) => {
14+
const cleaned = String(value || '')
15+
.replace(/<[^>]+>/g, '')
16+
.trim();
17+
if (!cleaned) return '';
18+
try {
19+
if (/^(?:"|'.*'|\{|\[|-?\d|true\b|false\b|null\b)/i.test(cleaned)) {
20+
return JSON.parse(cleaned.replace(/^'([\s\S]*)'$/, '"$1"'));
21+
}
22+
} catch { /* fall through to string cleanup */ }
23+
return cleaned.replace(/^["']+|["']+$/g, '');
24+
};
25+
26+
const patterns = [
27+
/<tool_call>\s*([\s\S]*?)\s*<\/tool_call>/gi,
28+
/<\|tool_call\|?>\s*([\s\S]*?)\s*<\|?\/?tool_call\|?>/gi,
29+
/<functioncall>\s*([\s\S]*?)\s*<\/functioncall>/gi,
30+
];
31+
32+
for (const re of patterns) {
33+
let match;
34+
while ((match = re.exec(text)) !== null) {
35+
const inner = match[1].trim();
36+
try {
37+
const obj = JSON.parse(inner);
38+
if (obj && obj.name && allowedNames.has(obj.name)) {
39+
results.push(obj);
40+
continue;
41+
}
42+
} catch { /* not JSON — try call:name{} format below */ }
43+
44+
const callMatch = /^call:(\w+)\s*\{([\s\S]*)\}$/.exec(inner);
45+
if (callMatch && allowedNames.has(callMatch[1])) {
46+
const toolName = callMatch[1];
47+
let argsBody = callMatch[2]
48+
.replace(/<\|"\|>/g, '"')
49+
.replace(/<\|'\\?\|>/g, "'");
50+
argsBody = argsBody.replace(/(?<=^|,)\s*(\w+)\s*:/g, '"$1":');
51+
try {
52+
const args = JSON.parse(`{${argsBody}}`);
53+
results.push({ name: toolName, arguments: args });
54+
} catch {
55+
results.push({ name: toolName, arguments: {} });
56+
}
57+
}
58+
}
59+
}
60+
61+
// XML-ish tool-call format used by some local/chat-template models:
62+
// <tool_call><function=click_ax><parameter=ref_id>ref_6</parameter>...
63+
const xmlToolRe = /<tool_call>\s*<function(?:\s*=\s*["']?([A-Za-z_]\w*)["']?|\s+name\s*=\s*["']?([A-Za-z_]\w*)["']?)\s*>\s*([\s\S]*?)\s*<\/function>\s*<\/tool_call>/gi;
64+
let xmlMatch;
65+
while ((xmlMatch = xmlToolRe.exec(text)) !== null) {
66+
const toolName = xmlMatch[1] || xmlMatch[2];
67+
if (!allowedNames.has(toolName)) continue;
68+
const body = xmlMatch[3] || '';
69+
const args = {};
70+
const paramRe = /<parameter(?:\s*=\s*["']?([A-Za-z_]\w*)["']?|\s+name\s*=\s*["']?([A-Za-z_]\w*)["']?)\s*>\s*([\s\S]*?)\s*<\/parameter>/gi;
71+
let paramMatch;
72+
while ((paramMatch = paramRe.exec(body)) !== null) {
73+
const key = paramMatch[1] || paramMatch[2];
74+
if (!key) continue;
75+
args[key] = parseXmlParamValue(paramMatch[3]);
76+
}
77+
results.push({ name: toolName, arguments: args });
78+
}
79+
80+
if (results.length === 0) {
81+
const bareRe = /\{[^{}]*"name"\s*:\s*"(\w+)"[^{}]*\}/g;
82+
let match;
83+
while ((match = bareRe.exec(text)) !== null) {
84+
if (!allowedNames.has(match[1])) continue;
85+
try {
86+
const obj = JSON.parse(match[0]);
87+
if (obj && obj.name && allowedNames.has(obj.name)) {
88+
results.push(obj);
89+
}
90+
} catch { /* skip */ }
91+
}
92+
}
93+
94+
if (results.length === 0) {
95+
const callRe = /call:(\w+)\s*\{([\s\S]*?)\}/g;
96+
let match;
97+
while ((match = callRe.exec(text)) !== null) {
98+
if (!allowedNames.has(match[1])) continue;
99+
const toolName = match[1];
100+
let argsBody = match[2]
101+
.replace(/<\|"\|>/g, '"')
102+
.replace(/<\|'\\?\|>/g, "'");
103+
argsBody = argsBody.replace(/(?<=^|,)\s*(\w+)\s*:/g, '"$1":');
104+
try {
105+
const args = JSON.parse(`{${argsBody}}`);
106+
results.push({ name: toolName, arguments: args });
107+
} catch {
108+
results.push({ name: toolName, arguments: {} });
109+
}
110+
}
111+
}
112+
113+
return results.map((obj, index) => ({
114+
id: `fallback_call_${Date.now()}_${index}`,
115+
type: 'function',
116+
function: {
117+
name: obj.name,
118+
arguments: typeof obj.arguments === 'string'
119+
? obj.arguments
120+
: JSON.stringify(obj.arguments || obj.parameters || {}),
121+
},
122+
}));
123+
}

0 commit comments

Comments
 (0)