diff --git a/.changeset/tool-args-escape-repair.md b/.changeset/tool-args-escape-repair.md new file mode 100644 index 00000000..cd2f7192 --- /dev/null +++ b/.changeset/tool-args-escape-repair.md @@ -0,0 +1,5 @@ +--- +"@pythoughts/pythinker-code": patch +--- + +Repair invalid escape sequences and unescaped quotes in model-written tool arguments instead of failing the tool call. diff --git a/packages/agent-core/src/loop/tool-call.ts b/packages/agent-core/src/loop/tool-call.ts index 789432b9..367ff384 100644 --- a/packages/agent-core/src/loop/tool-call.ts +++ b/packages/agent-core/src/loop/tool-call.ts @@ -300,10 +300,76 @@ export function parseToolCallArguments( try { return { success: true, data: JSON.parse(raw) as unknown }; } catch (error) { + const repaired = repairInvalidStringEscapes(raw); + if (repaired !== null) { + try { + return { success: true, data: JSON.parse(repaired) as unknown }; + } catch { + // Report the original parse error below. + } + } return { success: false, error: errorMessage(error) }; } } +/** + * Models sometimes emit invalid escapes (\* \_ \[) or unescaped quotes inside + * JSON string values. Rewrite invalid escapes to a literal backslash + + * character and quotes that cannot terminate the string to escaped quotes. + * A content quote followed by a structural character is ambiguous and still + * closes the string; if reparsing fails, the original parse error is reported. + * Returns null when nothing was repaired. + */ +function repairInvalidStringEscapes(raw: string): string | null { + let result = ''; + let inString = false; + let repaired = false; + + for (let index = 0; index < raw.length; index += 1) { + const character = raw[index]; + if (character === '"') { + if (!inString) { + inString = true; + result += character; + continue; + } + + let lookahead = index + 1; + while (lookahead < raw.length && ' \t\n\r'.includes(raw[lookahead]!)) lookahead += 1; + const next = raw[lookahead]; + if (next === undefined || ',:}]'.includes(next)) { + inString = false; + result += character; + } else { + result += '\\"'; + repaired = true; + } + continue; + } + if (!inString || character !== '\\') { + result += character; + continue; + } + + const next = raw[index + 1]; + if (next !== undefined && '"\\/bfnrt'.includes(next)) { + result += character + next; + index += 1; + continue; + } + if (next === 'u' && /^[0-9a-fA-F]{4}$/u.test(raw.slice(index + 2, index + 6))) { + result += raw.slice(index, index + 6); + index += 5; + continue; + } + + result += '\\\\'; + repaired = true; + } + + return repaired ? result : null; +} + function validateExecutableToolArgs(tool: ExecutableTool, args: unknown): string | null { let validator = validators.get(tool); if (validator === undefined) { diff --git a/packages/agent-core/test/loop/tool-call.e2e.test.ts b/packages/agent-core/test/loop/tool-call.e2e.test.ts index 5e155e7c..5f9dc2f8 100644 --- a/packages/agent-core/test/loop/tool-call.e2e.test.ts +++ b/packages/agent-core/test/loop/tool-call.e2e.test.ts @@ -12,6 +12,7 @@ import type { ContentPart } from '@pythoughts/kosong'; import { describe, expect, it } from 'vitest'; import { createLoopEventDispatcher, runTurn as runTurnImpl, ToolAccesses } from '../../src/loop'; +import { parseToolCallArguments } from '../../src/loop/tool-call'; import type { Logger } from '../../src/logging'; import type { ExecutableTool, @@ -48,6 +49,15 @@ function expectTextOutput(output: unknown): string { return output as string; } +function parseErrorMessage(raw: string): string { + try { + JSON.parse(raw); + } catch (error) { + return error instanceof Error ? error.message : String(error); + } + throw new Error(`expected ${raw} to fail JSON.parse`); +} + async function contentBlockOutput(output: ContentPart[]): Promise { const blocks = new ContentBlocksTool({ output }); const { context } = await runTurn({ @@ -118,6 +128,71 @@ function makeTestLogger(): { return { log, entries }; } +describe('parseToolCallArguments', () => { + it('repairs markdown-style escapes inside string values', () => { + const result = parseToolCallArguments('{"a":"bold \\*text\\* and \\_x"}'); + + expect(result).toEqual({ success: true, data: { a: 'bold \\*text\\* and \\_x' } }); + }); + + it('leaves valid escapes unchanged', () => { + const raw = '{"a":"line\\nquote\\" uA slash\\\\/"}'; + + expect(parseToolCallArguments(raw)).toEqual({ success: true, data: JSON.parse(raw) }); + }); + + it('repairs a bad unicode escape inside a string value', () => { + const result = parseToolCallArguments('{"a":"\\u12ZZ"}'); + + expect(result).toEqual({ success: true, data: { a: '\\u12ZZ' } }); + }); + + it('repairs unescaped quotes inside items-array string values', () => { + const result = parseToolCallArguments( + '{"items":[{"prompt":"Review the "config" module carefully","i":"review config"}]}', + ); + + expect(result).toEqual({ + success: true, + data: { items: [{ prompt: 'Review the "config" module carefully', i: 'review config' }] }, + }); + }); + + it('leaves a quote before a structural character unchanged', () => { + const raw = '{"a":"done","b":1}'; + + expect(parseToolCallArguments(raw)).toEqual({ success: true, data: JSON.parse(raw) }); + }); + + it('repairs invalid escapes and unescaped quotes together', () => { + const result = parseToolCallArguments('{"a":"bold \\*x and a "quoted" word"}'); + + expect(result).toEqual({ success: true, data: { a: 'bold \\*x and a "quoted" word' } }); + }); + + it('recognizes a string terminator separated from structure by whitespace', () => { + const result = parseToolCallArguments('{"a":"text" , "b":"x "y" z"}'); + + expect(result).toEqual({ success: true, data: { a: 'text', b: 'x "y" z' } }); + }); + + it('returns the original parse error after quote repair still fails', () => { + const raw = String.raw`{"a":"bad \*","b":[}`; + + expect(parseToolCallArguments(raw)).toEqual({ success: false, error: parseErrorMessage(raw) }); + }); + + it('returns the original parse error for structurally broken input', () => { + const raw = '{"a":"truncated'; + + expect(parseToolCallArguments(raw)).toEqual({ success: false, error: parseErrorMessage(raw) }); + }); + + it('does not repair a backslash outside a string', () => { + expect(parseToolCallArguments('{\\*"a":1}').success).toBe(false); + }); +}); + describe('runTurn — tool-call behaviour', () => { it('strips enabled intent before hooks, validation, execution, and persistence', async () => { const hookArgs: unknown[] = [];