fix(web-tools): parse bare JSON tool calls (#3157)

Parse bare JSON tool calls for deepseek-web (#2820) + fuzzy tool-name matching. Integrated into release/v3.8.10.
This commit is contained in:
Wilson
2026-06-04 18:10:23 -03:00
committed by GitHub
parent 8fb51f44d0
commit afe8f11bf8
4 changed files with 439 additions and 22 deletions

View File

@@ -1031,7 +1031,8 @@ export class DeepSeekWebExecutor extends BaseExecutor {
await cleanupFn();
const { content: cleanedContent, toolCalls } = parseToolCallsFromText(
content,
`call-${Date.now()}`
`call-${Date.now()}`,
requestedTools
);
return buildToolAwareResult({
stream: stream !== false,

View File

@@ -23,6 +23,306 @@ interface OpenAIToolDef {
const TOOL_BLOCK_RE = /<tool>\s*([\s\S]*?)\s*<\/tool>/g;
interface ToolParseCandidate {
raw: string;
start: number;
end: number;
requireRequestedTool: boolean;
}
interface RequestedToolName {
original: string;
normalized: string;
}
function toRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function getRequestedToolNames(tools: unknown): RequestedToolName[] {
if (!Array.isArray(tools)) return [];
const names: RequestedToolName[] = [];
const seen = new Set<string>();
for (const tool of tools) {
const record = toRecord(tool);
const fn = toRecord(record?.function);
const name = typeof fn?.name === "string" ? fn.name.trim() : "";
if (!name || seen.has(name)) continue;
seen.add(name);
names.push({ original: name, normalized: normalizeToolName(name) });
}
return names;
}
function normalizeToolName(name: string): string {
return name.toLowerCase().replace(/[^a-z0-9]/g, "");
}
function levenshteinDistance(a: string, b: string): number {
if (a === b) return 0;
if (!a) return b.length;
if (!b) return a.length;
let previous = Array.from({ length: b.length + 1 }, (_, i) => i);
let current = Array<number>(b.length + 1);
for (let i = 1; i <= a.length; i += 1) {
current[0] = i;
for (let j = 1; j <= b.length; j += 1) {
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
current[j] = Math.min(current[j - 1] + 1, previous[j] + 1, previous[j - 1] + cost);
}
const temp = previous;
previous = current;
current = temp;
}
return previous[b.length];
}
function scoreToolName(emitted: string, requested: RequestedToolName): number {
if (emitted === requested.original) return 1;
const normalized = normalizeToolName(emitted);
if (!normalized || !requested.normalized) return 0;
if (normalized === requested.normalized) return 0.98;
const shorter = Math.min(normalized.length, requested.normalized.length);
const longer = Math.max(normalized.length, requested.normalized.length);
if (shorter >= 4) {
if (normalized.includes(requested.normalized) || requested.normalized.includes(normalized)) {
return 0.86 - (longer - shorter) / Math.max(longer, 1) / 4;
}
}
const distance = levenshteinDistance(normalized, requested.normalized);
const similarity = 1 - distance / Math.max(longer, 1);
return similarity >= 0.72 ? similarity : 0;
}
function resolveRequestedToolName(emitted: string, requestedTools: RequestedToolName[]): string | null {
if (requestedTools.length === 0) return emitted;
let best: { name: string; score: number } | null = null;
let secondBest = 0;
for (const requested of requestedTools) {
const score = scoreToolName(emitted, requested);
if (!best || score > best.score) {
secondBest = best?.score ?? 0;
best = { name: requested.original, score };
} else if (score > secondBest) {
secondBest = score;
}
}
if (!best || best.score < 0.72) return null;
// Avoid correcting to an arbitrary tool when the fuzzy match is ambiguous.
if (best.score < 0.98 && best.score - secondBest < 0.08) return null;
return best.name;
}
function stripCodeFence(value: string): string {
return value
.trim()
.replace(/^```(?:json|javascript|js|python)?\s*/i, "")
.replace(/\s*```$/i, "")
.trim();
}
function convertSingleQuotedStrings(value: string): string {
let result = "";
let inSingle = false;
let inDouble = false;
let escaped = false;
for (const ch of value) {
if (escaped) {
result += ch === '"' && inSingle ? '\\"' : ch;
escaped = false;
continue;
}
if (ch === "\\") {
result += ch;
escaped = true;
continue;
}
if (ch === '"') {
if (inSingle) {
result += '\\"';
} else {
inDouble = !inDouble;
result += ch;
}
continue;
}
if (ch === "'" && !inDouble) {
inSingle = !inSingle;
result += '"';
continue;
}
result += ch;
}
return result;
}
function replacePythonLiterals(value: string): string {
let result = "";
let inString = false;
let escaped = false;
let token = "";
const flushToken = () => {
if (token === "True") result += "true";
else if (token === "False") result += "false";
else if (token === "None") result += "null";
else result += token;
token = "";
};
for (const ch of value) {
if (escaped) {
if (token) flushToken();
result += ch;
escaped = false;
continue;
}
if (ch === "\\") {
if (token) flushToken();
result += ch;
escaped = inString;
continue;
}
if (ch === '"') {
if (token) flushToken();
inString = !inString;
result += ch;
continue;
}
if (!inString && /[A-Za-z]/.test(ch)) {
token += ch;
continue;
}
if (token) flushToken();
result += ch;
}
if (token) flushToken();
return result;
}
function normalizeLooseJson(value: string): string {
return replacePythonLiterals(convertSingleQuotedStrings(value))
.replace(/([{,]\s*)([A-Za-z_][A-Za-z0-9_-]*)(\s*:)/g, '$1"$2"$3')
.replace(/,\s*([}\]])/g, "$1");
}
function parseLooseJsonObject(raw: string): Record<string, unknown> | null {
const trimmed = stripCodeFence(raw);
for (const candidate of [trimmed, normalizeLooseJson(trimmed)]) {
try {
return toRecord(JSON.parse(candidate));
} catch {
// Try the next, more permissive form.
}
}
return null;
}
function findBareJsonCandidates(text: string): ToolParseCandidate[] {
const candidates: ToolParseCandidate[] = [];
let start = -1;
let depth = 0;
let quote: '"' | "'" | "" = "";
let escaped = false;
for (let i = 0; i < text.length; i += 1) {
const ch = text[i];
if (depth === 0 && ch !== "{") {
continue;
}
if (escaped) {
escaped = false;
continue;
}
if (quote) {
if (ch === "\\") {
escaped = true;
} else if (ch === quote) {
quote = "";
}
continue;
}
if (ch === '"' || ch === "'") {
quote = ch;
continue;
}
if (ch === "{") {
if (depth === 0) start = i;
depth += 1;
continue;
}
if (ch === "}" && depth > 0) {
depth -= 1;
if (depth === 0 && start >= 0) {
const raw = text.slice(start, i + 1);
if (/[{,]\s*["']?(name|command)["']?\s*:/i.test(raw) && /[{,]\s*["']?arguments["']?\s*:/i.test(raw)) {
candidates.push({ raw, start, end: i + 1, requireRequestedTool: true });
}
start = -1;
}
}
}
return candidates;
}
function rangesOverlap(a: { start: number; end: number }, b: { start: number; end: number }): boolean {
return a.start < b.end && b.start < a.end;
}
function stripRanges(text: string, ranges: Array<{ start: number; end: number }>): string {
let content = text;
const sorted = [...ranges].sort((a, b) => b.start - a.start);
for (const range of sorted) {
const lineStart = content.lastIndexOf("\n", range.start - 1) + 1;
const nextLineBreak = content.indexOf("\n", range.end);
const lineEnd = nextLineBreak === -1 ? content.length : nextLineBreak;
const beforeOnLine = content.slice(lineStart, range.start);
const afterOnLine = content.slice(range.end, lineEnd);
const removeWholeLine = beforeOnLine.trim() === "" && afterOnLine.trim() === "";
const start = removeWholeLine ? lineStart : range.start;
const end = removeWholeLine && nextLineBreak !== -1 ? nextLineBreak + 1 : removeWholeLine ? lineEnd : range.end;
content = `${content.slice(0, start)}${content.slice(end)}`;
}
return content.replace(/\n{3,}/g, "\n\n").trim();
}
function toArgumentsString(value: unknown): string {
if (value === undefined) return "{}";
if (typeof value === "string") {
const parsed = parseLooseJsonObject(value);
return parsed ? JSON.stringify(parsed) : value;
}
try {
return JSON.stringify(value);
} catch {
return "{}";
}
}
/**
* Serialize an OpenAI `tools` array into a system-prompt block that instructs the
* web UI model how to invoke a tool (emit a `<tool>{...}</tool>` block). Returns an
@@ -59,6 +359,8 @@ export function serializeToolsToPrompt(tools: unknown): string {
/**
* Parse `<tool>{...}</tool>` blocks out of upstream text into OpenAI `tool_calls`.
* When a requested `tools[]` set is provided, also accepts bare JSON tool-call
* objects emitted by web models that ignored the `<tool>` wrapper contract.
* Returns the content with the blocks stripped, plus the tool calls (or null when
* there are none). `arguments` is always a JSON *string*, matching the OpenAI API.
*
@@ -67,43 +369,69 @@ export function serializeToolsToPrompt(tools: unknown): string {
*/
export function parseToolCallsFromText(
text: string,
idSeed = "call"
idSeed = "call",
requestedTools?: unknown
): { content: string; toolCalls: OpenAIToolCall[] | null } {
if (typeof text !== "string" || !text.includes("<tool>")) {
const requestedToolNames = getRequestedToolNames(requestedTools);
const canParseBareJson = requestedToolNames.length > 0;
if (typeof text !== "string" || (!text.includes("<tool>") && !canParseBareJson)) {
return { content: text ?? "", toolCalls: null };
}
const toolCalls: OpenAIToolCall[] = [];
let match: RegExpExecArray | null;
const candidates: ToolParseCandidate[] = [];
const toolBlockRanges: Array<{ start: number; end: number }> = [];
let blockMatch: RegExpExecArray | null;
TOOL_BLOCK_RE.lastIndex = 0;
while ((match = TOOL_BLOCK_RE.exec(text)) !== null) {
const raw = match[1].trim();
let parsed: { name?: unknown; arguments?: unknown } | null = null;
try {
parsed = JSON.parse(raw);
} catch {
parsed = null;
}
const name = parsed && typeof parsed.name === "string" ? parsed.name : null;
if (!name) continue;
let args = "{}";
if (parsed && parsed.arguments !== undefined) {
args =
typeof parsed.arguments === "string"
? parsed.arguments
: JSON.stringify(parsed.arguments);
while ((blockMatch = TOOL_BLOCK_RE.exec(text)) !== null) {
const range = { start: blockMatch.index, end: TOOL_BLOCK_RE.lastIndex };
toolBlockRanges.push(range);
candidates.push({
raw: blockMatch[1].trim(),
start: range.start,
end: range.end,
requireRequestedTool: false,
});
}
if (canParseBareJson) {
for (const candidate of findBareJsonCandidates(text)) {
if (!toolBlockRanges.some((range) => rangesOverlap(range, candidate))) {
candidates.push(candidate);
}
}
}
candidates.sort((a, b) => a.start - b.start);
const toolCalls: OpenAIToolCall[] = [];
const acceptedRanges: Array<{ start: number; end: number }> = [];
for (const candidate of candidates) {
const parsed = parseLooseJsonObject(candidate.raw);
const emittedName =
parsed && typeof parsed.name === "string"
? parsed.name
: parsed && typeof parsed.command === "string"
? parsed.command
: null;
if (!emittedName) continue;
const name =
resolveRequestedToolName(emittedName, requestedToolNames) ||
(candidate.requireRequestedTool ? null : emittedName);
if (!name || (candidate.requireRequestedTool && requestedToolNames.length === 0)) continue;
const args = toArgumentsString(parsed?.arguments);
toolCalls.push({
id: `${idSeed}_${toolCalls.length}`,
type: "function",
function: { name, arguments: args },
});
acceptedRanges.push({ start: candidate.start, end: candidate.end });
}
if (toolCalls.length === 0) {
return { content: text, toolCalls: null };
}
const content = text.replace(TOOL_BLOCK_RE, "").replace(/\n{3,}/g, "\n\n").trim();
const content = stripRanges(text, acceptedRanges);
return { content, toolCalls };
}

View File

@@ -142,6 +142,30 @@ test("execute (non-stream) parses <tool> reply into OpenAI tool_calls", async ()
}
});
test("execute (non-stream) parses bare JSON reply into OpenAI tool_calls", async () => {
const mock = installMock('{"name":"getWeather","arguments":{"city":"Paris"}}');
try {
const executor = new DeepSeekWebExecutor();
const result = await executor.execute({
model: "default",
body: { messages: [{ role: "user", content: "weather?" }], tools: TOOLS },
stream: false,
credentials: { apiKey: "tkn-tools-bare-json" },
signal: AbortSignal.timeout(10000),
});
assert.ok(result.response.ok);
const json = JSON.parse(await result.response.text());
const choice = json.choices[0];
assert.equal(choice.finish_reason, "tool_calls");
assert.equal(choice.message.tool_calls.length, 1);
assert.equal(choice.message.tool_calls[0].function.name, "get_weather");
assert.deepEqual(JSON.parse(choice.message.tool_calls[0].function.arguments), { city: "Paris" });
assert.equal(choice.message.content, null, "bare JSON tool call is stripped from content");
} finally {
mock.restore();
}
});
test("execute (stream) emits tool_calls + finish_reason tool_calls in the SSE", async () => {
const mock = installMock(TOOL_REPLY);
try {

View File

@@ -58,6 +58,70 @@ test("parseToolCallsFromText returns null toolCalls when there is no tool block"
assert.equal(content, "just a normal answer");
});
test("parseToolCallsFromText detects bare JSON tool calls when requested tools are present", () => {
const text = '{"name":"get_weather","arguments":{"city":"Paris"}}';
const { content, toolCalls } = parseToolCallsFromText(text, "call", TOOLS);
assert.equal(content, "");
assert.equal(toolCalls?.length, 1);
assert.equal(toolCalls?.[0].function.name, "get_weather");
assert.deepEqual(JSON.parse(toolCalls?.[0].function.arguments || "{}"), { city: "Paris" });
});
test("parseToolCallsFromText does not parse bare JSON without requested tools", () => {
const text = '{"name":"get_weather","arguments":{"city":"Paris"}}';
const { content, toolCalls } = parseToolCallsFromText(text);
assert.equal(toolCalls, null);
assert.equal(content, text);
});
test("parseToolCallsFromText tolerates Python-dict-ish bare tool JSON", () => {
const text = "{'command': 'get_weather', 'arguments': {'city': 'Paris', 'units': 'metric', 'fresh': True}}";
const { toolCalls } = parseToolCallsFromText(text, "call", TOOLS);
assert.equal(toolCalls?.length, 1);
assert.equal(toolCalls?.[0].function.name, "get_weather");
assert.deepEqual(JSON.parse(toolCalls?.[0].function.arguments || "{}"), {
city: "Paris",
units: "metric",
fresh: true,
});
});
test("parseToolCallsFromText escapes double quotes inside single-quoted strings", () => {
const text = "{'command': 'get_weather', 'arguments': {'city': 'Paris \"City\"'}}";
const { toolCalls } = parseToolCallsFromText(text, "call", TOOLS);
assert.equal(toolCalls?.length, 1);
assert.deepEqual(JSON.parse(toolCalls?.[0].function.arguments || "{}"), { city: 'Paris "City"' });
});
test("parseToolCallsFromText fuzzy-matches emitted tool names to requested tools", () => {
const text = '{"name":"getWeather","arguments":{"city":"Paris"}}';
const { toolCalls } = parseToolCallsFromText(text, "call", TOOLS);
assert.equal(toolCalls?.length, 1);
assert.equal(toolCalls?.[0].function.name, "get_weather");
});
test("parseToolCallsFromText strips bare JSON while preserving surrounding text", () => {
const text = 'I will check now.\n{"name":"get_weather","arguments":"{\\"city\\":\\"Paris\\"}"}\nDone.';
const { content, toolCalls } = parseToolCallsFromText(text, "call", TOOLS);
assert.equal(toolCalls?.length, 1);
assert.deepEqual(JSON.parse(toolCalls?.[0].function.arguments || "{}"), { city: "Paris" });
assert.equal(content, "I will check now.\nDone.");
});
test("parseToolCallsFromText ignores bare JSON whose tool is not requested", () => {
const text = '{"name":"delete_everything","arguments":{"force":true}}';
const { content, toolCalls } = parseToolCallsFromText(text, "call", TOOLS);
assert.equal(toolCalls, null);
assert.equal(content, text);
});
test("parseToolCallsFromText parses multiple tool calls", () => {
const text =
'<tool>{"name": "a", "arguments": {"x": 1}}</tool>\n<tool>{"name": "b", "arguments": {}}</tool>';