From afe8f11bf80153692125bdb223dcb920e1b903bb Mon Sep 17 00:00:00 2001 From: Wilson Date: Thu, 4 Jun 2026 18:10:23 -0300 Subject: [PATCH] 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. --- open-sse/executors/deepseek-web.ts | 3 +- open-sse/translator/webTools.ts | 370 +++++++++++++++++- .../deepseek-web-tools-execute-2820.test.ts | 24 ++ tests/unit/web-tools-translation-2820.test.ts | 64 +++ 4 files changed, 439 insertions(+), 22 deletions(-) diff --git a/open-sse/executors/deepseek-web.ts b/open-sse/executors/deepseek-web.ts index 2cb4bd9423..55b10aa2da 100644 --- a/open-sse/executors/deepseek-web.ts +++ b/open-sse/executors/deepseek-web.ts @@ -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, diff --git a/open-sse/translator/webTools.ts b/open-sse/translator/webTools.ts index 78caf326c7..406ddaa490 100644 --- a/open-sse/translator/webTools.ts +++ b/open-sse/translator/webTools.ts @@ -23,6 +23,306 @@ interface OpenAIToolDef { const TOOL_BLOCK_RE = /\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 | null { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; +} + +function getRequestedToolNames(tools: unknown): RequestedToolName[] { + if (!Array.isArray(tools)) return []; + const names: RequestedToolName[] = []; + const seen = new Set(); + 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(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 | 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 `{...}` block). Returns an @@ -59,6 +359,8 @@ export function serializeToolsToPrompt(tools: unknown): string { /** * Parse `{...}` 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 `` 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("")) { + const requestedToolNames = getRequestedToolNames(requestedTools); + const canParseBareJson = requestedToolNames.length > 0; + if (typeof text !== "string" || (!text.includes("") && !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 }; } diff --git a/tests/unit/deepseek-web-tools-execute-2820.test.ts b/tests/unit/deepseek-web-tools-execute-2820.test.ts index 079fb1777a..49c01497d1 100644 --- a/tests/unit/deepseek-web-tools-execute-2820.test.ts +++ b/tests/unit/deepseek-web-tools-execute-2820.test.ts @@ -142,6 +142,30 @@ test("execute (non-stream) parses 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 { diff --git a/tests/unit/web-tools-translation-2820.test.ts b/tests/unit/web-tools-translation-2820.test.ts index d8d152a3d7..20ee1a048c 100644 --- a/tests/unit/web-tools-translation-2820.test.ts +++ b/tests/unit/web-tools-translation-2820.test.ts @@ -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 = '{"name": "a", "arguments": {"x": 1}}\n{"name": "b", "arguments": {}}';