Skip to content

Commit d3c0278

Browse files
fix(tools): tolerate smart/curly-quote JSON in tool-call arguments (no retry loop)
Device 2026-07-15: a local model (Qwythos GGUF) emitted tool-call arguments with CURLY double quotes — {“expression”:“7 * 7”} — which strict JSON.parse rejects. parseToolCall then fell back to args={}, so the calculator got an EMPTY call, schema validation failed, and the model retried the same call in a loop until it happened to emit straight quotes ('worked eventually'). Fix: parseToolArguments tries strict JSON first (unchanged for the common case), then retries once with curly double quotes normalized to straight — only the string DELIMITERS, never content. The real expression now reaches the tool on the first try. Tested through the real generateWithToolsImpl path (fake engine boundary emits the curly-quote tool_call), red-verified by reverting the normalization. NOTE: the raw <tool_call>…</tool_call> / [Tool Result] text seen in the reply was the model QUOTING its own calls inside its <think> reasoning (rendered as thinking text) — the display stripper already handles real tool-call blocks (messageContent.ts). That's the model narrating, not a strip miss.
1 parent a71871b commit d3c0278

2 files changed

Lines changed: 45 additions & 1 deletion

File tree

__tests__/unit/services/llmToolGeneration.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -406,6 +406,29 @@ describe('generateWithToolsImpl', () => {
406406
expect(result.toolCalls[0].arguments).toEqual({ expression: '3*3' });
407407
});
408408

409+
it('parses arguments that use smart/curly quotes as delimiters (no empty-args retry loop)', async () => {
410+
// Device 2026-07-15: a local model emitted `{“expression”:“7 * 7”}` with CURLY double quotes.
411+
// Strict JSON.parse throws → args became {} → the calculator got an empty call → schema
412+
// validation failed → the model retried the same call in a loop. The parser now normalizes the
413+
// curly delimiters, so the real expression reaches the tool on the first try.
414+
const completion = jest.fn(async (_params: any, cb: any) => {
415+
cb({
416+
tool_calls: [
417+
{ id: 'call_curly', function: { name: 'calculator', arguments: '{“expression”:“7 * 7”}' } },
418+
],
419+
});
420+
return {};
421+
});
422+
const deps = createMockDeps({ context: { completion } });
423+
424+
const result = await generateWithToolsImpl(deps, [createUserMessage('multiply 7 by 7')], {
425+
tools: SAMPLE_TOOLS,
426+
});
427+
428+
expect(result.toolCalls[0].name).toBe('calculator');
429+
expect(result.toolCalls[0].arguments).toEqual({ expression: '7 * 7' }); // NOT {} — curly quotes normalized
430+
});
431+
409432
it('handles tool call with missing function fields gracefully', async () => {
410433
const completion = jest.fn(async (_params: any, cb: any) => {
411434
cb({

src/services/llmToolGeneration.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,11 +91,32 @@ export class ToolCallTokenFilter {
9191
}
9292
}
9393

94+
/**
95+
* Parse a tool call's `arguments` JSON string into an object, tolerating the smart/curly quotes some
96+
* local models emit as string delimiters. Plain JSON.parse rejects `{"expression":"7 * 7"}` (curly
97+
* double quotes) → args became `{}` → the tool got an EMPTY call → schema validation failed → the
98+
* model retried the same call in a loop until it happened to emit straight quotes (device 2026-07-15).
99+
* We try strict JSON first (unchanged for the common case), then retry once with curly double quotes
100+
* normalized to straight. Zero-IO; exercised through generateWithToolsImpl in tests.
101+
*/
102+
function parseToolArguments(raw: string): Record<string, unknown> {
103+
const tryParse = (s: string): Record<string, unknown> | undefined => {
104+
try {
105+
const v = JSON.parse(s || '{}');
106+
return v && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, unknown>) : undefined;
107+
} catch {
108+
return undefined;
109+
}
110+
};
111+
// Normalize only the JSON string DELIMITERS (curly → straight double quotes); leave content alone.
112+
return tryParse(raw) ?? tryParse(raw.replace(/[]/g, '"')) ?? {};
113+
}
114+
94115
function parseToolCall(tc: any): ToolCall {
95116
const fn = tc.function || {};
96117
let args = fn.arguments || {};
97118
if (typeof args === 'string') {
98-
try { args = JSON.parse(args || '{}'); } catch { args = {}; }
119+
args = parseToolArguments(args);
99120
}
100121
return { id: tc.id, name: fn.name || '', arguments: args };
101122
}

0 commit comments

Comments
 (0)