From d92d179f3b872d7eed1e2ab81e1b56c757900c66 Mon Sep 17 00:00:00 2001 From: elkaix Date: Tue, 11 Aug 2026 21:57:43 -0400 Subject: [PATCH 1/4] fix: repair invalid string escapes in tool-call arguments --- .changeset/tool-args-escape-repair.md | 5 ++ packages/agent-core/src/loop/tool-call.ts | 51 +++++++++++++++++++ .../test/loop/tool-call.e2e.test.ts | 37 ++++++++++++++ 3 files changed, 93 insertions(+) create mode 100644 .changeset/tool-args-escape-repair.md diff --git a/.changeset/tool-args-escape-repair.md b/.changeset/tool-args-escape-repair.md new file mode 100644 index 00000000..9154d138 --- /dev/null +++ b/.changeset/tool-args-escape-repair.md @@ -0,0 +1,5 @@ +--- +"@pythoughts/pythinker-code": patch +--- + +Repair invalid escape sequences 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..9403c77e 100644 --- a/packages/agent-core/src/loop/tool-call.ts +++ b/packages/agent-core/src/loop/tool-call.ts @@ -300,10 +300,61 @@ 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 markdown-style escapes (\* \_ \[) inside JSON string + * values; strict JSON.parse rejects them while streaming previews tolerate + * them, so the call dies only at preflight. Rewrite ONLY invalid escapes to a + * literal backslash + character, leaving valid escapes and structure alone. + * 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 === '"') { + inString = !inString; + result += character; + 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}$/.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..2490a02c 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, @@ -118,6 +119,42 @@ 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('returns the original parse error for structurally broken input', () => { + const raw = '{"a":"truncated'; + let originalError = ''; + try { + JSON.parse(raw); + } catch (error) { + originalError = error instanceof Error ? error.message : String(error); + } + + expect(parseToolCallArguments(raw)).toEqual({ success: false, error: originalError }); + }); + + 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[] = []; From 1807f6333778f62f91023ceb71d7293bd4e4dac3 Mon Sep 17 00:00:00 2001 From: elkaix Date: Tue, 11 Aug 2026 22:26:54 -0400 Subject: [PATCH 2/4] fix: repair unescaped quotes in tool-call arguments --- .changeset/tool-args-escape-repair.md | 2 +- packages/agent-core/src/loop/tool-call.ts | 27 +++++++++--- .../test/loop/tool-call.e2e.test.ts | 41 +++++++++++++++++++ 3 files changed, 63 insertions(+), 7 deletions(-) diff --git a/.changeset/tool-args-escape-repair.md b/.changeset/tool-args-escape-repair.md index 9154d138..cd2f7192 100644 --- a/.changeset/tool-args-escape-repair.md +++ b/.changeset/tool-args-escape-repair.md @@ -2,4 +2,4 @@ "@pythoughts/pythinker-code": patch --- -Repair invalid escape sequences in model-written tool arguments instead of failing the tool call. +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 9403c77e..c04d45dc 100644 --- a/packages/agent-core/src/loop/tool-call.ts +++ b/packages/agent-core/src/loop/tool-call.ts @@ -313,10 +313,11 @@ export function parseToolCallArguments( } /** - * Models sometimes emit markdown-style escapes (\* \_ \[) inside JSON string - * values; strict JSON.parse rejects them while streaming previews tolerate - * them, so the call dies only at preflight. Rewrite ONLY invalid escapes to a - * literal backslash + character, leaving valid escapes and structure alone. + * 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 { @@ -327,8 +328,22 @@ function repairInvalidStringEscapes(raw: string): string | null { for (let index = 0; index < raw.length; index += 1) { const character = raw[index]; if (character === '"') { - inString = !inString; - result += 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 !== '\\') { 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 2490a02c..df15ce6d 100644 --- a/packages/agent-core/test/loop/tool-call.e2e.test.ts +++ b/packages/agent-core/test/loop/tool-call.e2e.test.ts @@ -138,6 +138,47 @@ describe('parseToolCallArguments', () => { 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 = '{"a":[1,}'; + let originalError = ''; + try { + JSON.parse(raw); + } catch (error) { + originalError = error instanceof Error ? error.message : String(error); + } + + expect(parseToolCallArguments(raw)).toEqual({ success: false, error: originalError }); + }); + it('returns the original parse error for structurally broken input', () => { const raw = '{"a":"truncated'; let originalError = ''; From 83663b645f468310e8b823e858ad2abe9d93f840 Mon Sep 17 00:00:00 2001 From: elkaix Date: Wed, 12 Aug 2026 03:25:53 -0400 Subject: [PATCH 3/4] test: share the expected parse error and flag the hex escape regex The helper throws when its input parses cleanly, so the two error assertions cannot silently compare against an empty string. --- packages/agent-core/src/loop/tool-call.ts | 2 +- .../test/loop/tool-call.e2e.test.ts | 25 ++++++++----------- 2 files changed, 12 insertions(+), 15 deletions(-) diff --git a/packages/agent-core/src/loop/tool-call.ts b/packages/agent-core/src/loop/tool-call.ts index c04d45dc..367ff384 100644 --- a/packages/agent-core/src/loop/tool-call.ts +++ b/packages/agent-core/src/loop/tool-call.ts @@ -357,7 +357,7 @@ function repairInvalidStringEscapes(raw: string): string | null { index += 1; continue; } - if (next === 'u' && /^[0-9a-fA-F]{4}$/.test(raw.slice(index + 2, index + 6))) { + 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; 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 df15ce6d..33df489c 100644 --- a/packages/agent-core/test/loop/tool-call.e2e.test.ts +++ b/packages/agent-core/test/loop/tool-call.e2e.test.ts @@ -49,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({ @@ -169,26 +178,14 @@ describe('parseToolCallArguments', () => { it('returns the original parse error after quote repair still fails', () => { const raw = '{"a":[1,}'; - let originalError = ''; - try { - JSON.parse(raw); - } catch (error) { - originalError = error instanceof Error ? error.message : String(error); - } - expect(parseToolCallArguments(raw)).toEqual({ success: false, error: originalError }); + expect(parseToolCallArguments(raw)).toEqual({ success: false, error: parseErrorMessage(raw) }); }); it('returns the original parse error for structurally broken input', () => { const raw = '{"a":"truncated'; - let originalError = ''; - try { - JSON.parse(raw); - } catch (error) { - originalError = error instanceof Error ? error.message : String(error); - } - expect(parseToolCallArguments(raw)).toEqual({ success: false, error: originalError }); + expect(parseToolCallArguments(raw)).toEqual({ success: false, error: parseErrorMessage(raw) }); }); it('does not repair a backslash outside a string', () => { From 8ef6d30bcc4be589a9614c8c836c1ecdb3e75960 Mon Sep 17 00:00:00 2001 From: elkaix Date: Wed, 12 Aug 2026 03:40:23 -0400 Subject: [PATCH 4/4] test: exercise the repair-then-reparse-fails path The previous input never triggered a repair, so the fallback that reports the original error after a failed reparse was untested. --- packages/agent-core/test/loop/tool-call.e2e.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 33df489c..5f9dc2f8 100644 --- a/packages/agent-core/test/loop/tool-call.e2e.test.ts +++ b/packages/agent-core/test/loop/tool-call.e2e.test.ts @@ -177,7 +177,7 @@ describe('parseToolCallArguments', () => { }); it('returns the original parse error after quote repair still fails', () => { - const raw = '{"a":[1,}'; + const raw = String.raw`{"a":"bad \*","b":[}`; expect(parseToolCallArguments(raw)).toEqual({ success: false, error: parseErrorMessage(raw) }); });