Skip to content

Commit 7bb7eb2

Browse files
committed
fix: repair invalid string escapes in tool-call arguments
1 parent eade981 commit 7bb7eb2

3 files changed

Lines changed: 93 additions & 0 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@pythoughts/pythinker-code": patch
3+
---
4+
5+
Repair invalid escape sequences in model-written tool arguments instead of failing the tool call.

packages/agent-core/src/loop/tool-call.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,10 +300,61 @@ export function parseToolCallArguments(
300300
try {
301301
return { success: true, data: JSON.parse(raw) as unknown };
302302
} catch (error) {
303+
const repaired = repairInvalidStringEscapes(raw);
304+
if (repaired !== null) {
305+
try {
306+
return { success: true, data: JSON.parse(repaired) as unknown };
307+
} catch {
308+
// Report the original parse error below.
309+
}
310+
}
303311
return { success: false, error: errorMessage(error) };
304312
}
305313
}
306314

315+
/**
316+
* Models sometimes emit markdown-style escapes (\* \_ \[) inside JSON string
317+
* values; strict JSON.parse rejects them while streaming previews tolerate
318+
* them, so the call dies only at preflight. Rewrite ONLY invalid escapes to a
319+
* literal backslash + character, leaving valid escapes and structure alone.
320+
* Returns null when nothing was repaired.
321+
*/
322+
function repairInvalidStringEscapes(raw: string): string | null {
323+
let result = '';
324+
let inString = false;
325+
let repaired = false;
326+
327+
for (let index = 0; index < raw.length; index += 1) {
328+
const character = raw[index];
329+
if (character === '"') {
330+
inString = !inString;
331+
result += character;
332+
continue;
333+
}
334+
if (!inString || character !== '\\') {
335+
result += character;
336+
continue;
337+
}
338+
339+
const next = raw[index + 1];
340+
if (next !== undefined && '"\\/bfnrt'.includes(next)) {
341+
result += character + next;
342+
index += 1;
343+
continue;
344+
}
345+
if (next === 'u' && /^[0-9a-fA-F]{4}$/.test(raw.slice(index + 2, index + 6))) {
346+
result += raw.slice(index, index + 6);
347+
index += 5;
348+
continue;
349+
}
350+
351+
result += '\\\\';
352+
repaired = true;
353+
}
354+
355+
return repaired ? result : null;
356+
}
357+
307358
function validateExecutableToolArgs(tool: ExecutableTool, args: unknown): string | null {
308359
let validator = validators.get(tool);
309360
if (validator === undefined) {

packages/agent-core/test/loop/tool-call.e2e.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import type { ContentPart } from '@pythoughts/kosong';
1212
import { describe, expect, it } from 'vitest';
1313

1414
import { createLoopEventDispatcher, runTurn as runTurnImpl, ToolAccesses } from '../../src/loop';
15+
import { parseToolCallArguments } from '../../src/loop/tool-call';
1516
import type { Logger } from '../../src/logging';
1617
import type {
1718
ExecutableTool,
@@ -118,6 +119,42 @@ function makeTestLogger(): {
118119
return { log, entries };
119120
}
120121

122+
describe('parseToolCallArguments', () => {
123+
it('repairs markdown-style escapes inside string values', () => {
124+
const result = parseToolCallArguments('{"a":"bold \\*text\\* and \\_x"}');
125+
126+
expect(result).toEqual({ success: true, data: { a: 'bold \\*text\\* and \\_x' } });
127+
});
128+
129+
it('leaves valid escapes unchanged', () => {
130+
const raw = '{"a":"line\\nquote\\" uA slash\\\\/"}';
131+
132+
expect(parseToolCallArguments(raw)).toEqual({ success: true, data: JSON.parse(raw) });
133+
});
134+
135+
it('repairs a bad unicode escape inside a string value', () => {
136+
const result = parseToolCallArguments('{"a":"\\u12ZZ"}');
137+
138+
expect(result).toEqual({ success: true, data: { a: '\\u12ZZ' } });
139+
});
140+
141+
it('returns the original parse error for structurally broken input', () => {
142+
const raw = '{"a":"truncated';
143+
let originalError = '';
144+
try {
145+
JSON.parse(raw);
146+
} catch (error) {
147+
originalError = error instanceof Error ? error.message : String(error);
148+
}
149+
150+
expect(parseToolCallArguments(raw)).toEqual({ success: false, error: originalError });
151+
});
152+
153+
it('does not repair a backslash outside a string', () => {
154+
expect(parseToolCallArguments('{\\*"a":1}').success).toBe(false);
155+
});
156+
});
157+
121158
describe('runTurn — tool-call behaviour', () => {
122159
it('strips enabled intent before hooks, validation, execution, and persistence', async () => {
123160
const hookArgs: unknown[] = [];

0 commit comments

Comments
 (0)