Skip to content

Commit 1807f63

Browse files
committed
fix: repair unescaped quotes in tool-call arguments
1 parent d92d179 commit 1807f63

3 files changed

Lines changed: 63 additions & 7 deletions

File tree

.changeset/tool-args-escape-repair.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22
"@pythoughts/pythinker-code": patch
33
---
44

5-
Repair invalid escape sequences in model-written tool arguments instead of failing the tool call.
5+
Repair invalid escape sequences and unescaped quotes in model-written tool arguments instead of failing the tool call.

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

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -313,10 +313,11 @@ export function parseToolCallArguments(
313313
}
314314

315315
/**
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.
316+
* Models sometimes emit invalid escapes (\* \_ \[) or unescaped quotes inside
317+
* JSON string values. Rewrite invalid escapes to a literal backslash +
318+
* character and quotes that cannot terminate the string to escaped quotes.
319+
* A content quote followed by a structural character is ambiguous and still
320+
* closes the string; if reparsing fails, the original parse error is reported.
320321
* Returns null when nothing was repaired.
321322
*/
322323
function repairInvalidStringEscapes(raw: string): string | null {
@@ -327,8 +328,22 @@ function repairInvalidStringEscapes(raw: string): string | null {
327328
for (let index = 0; index < raw.length; index += 1) {
328329
const character = raw[index];
329330
if (character === '"') {
330-
inString = !inString;
331-
result += character;
331+
if (!inString) {
332+
inString = true;
333+
result += character;
334+
continue;
335+
}
336+
337+
let lookahead = index + 1;
338+
while (lookahead < raw.length && ' \t\n\r'.includes(raw[lookahead]!)) lookahead += 1;
339+
const next = raw[lookahead];
340+
if (next === undefined || ',:}]'.includes(next)) {
341+
inString = false;
342+
result += character;
343+
} else {
344+
result += '\\"';
345+
repaired = true;
346+
}
332347
continue;
333348
}
334349
if (!inString || character !== '\\') {

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

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,47 @@ describe('parseToolCallArguments', () => {
138138
expect(result).toEqual({ success: true, data: { a: '\\u12ZZ' } });
139139
});
140140

141+
it('repairs unescaped quotes inside items-array string values', () => {
142+
const result = parseToolCallArguments(
143+
'{"items":[{"prompt":"Review the "config" module carefully","i":"review config"}]}',
144+
);
145+
146+
expect(result).toEqual({
147+
success: true,
148+
data: { items: [{ prompt: 'Review the "config" module carefully', i: 'review config' }] },
149+
});
150+
});
151+
152+
it('leaves a quote before a structural character unchanged', () => {
153+
const raw = '{"a":"done","b":1}';
154+
155+
expect(parseToolCallArguments(raw)).toEqual({ success: true, data: JSON.parse(raw) });
156+
});
157+
158+
it('repairs invalid escapes and unescaped quotes together', () => {
159+
const result = parseToolCallArguments('{"a":"bold \\*x and a "quoted" word"}');
160+
161+
expect(result).toEqual({ success: true, data: { a: 'bold \\*x and a "quoted" word' } });
162+
});
163+
164+
it('recognizes a string terminator separated from structure by whitespace', () => {
165+
const result = parseToolCallArguments('{"a":"text" , "b":"x "y" z"}');
166+
167+
expect(result).toEqual({ success: true, data: { a: 'text', b: 'x "y" z' } });
168+
});
169+
170+
it('returns the original parse error after quote repair still fails', () => {
171+
const raw = '{"a":[1,}';
172+
let originalError = '';
173+
try {
174+
JSON.parse(raw);
175+
} catch (error) {
176+
originalError = error instanceof Error ? error.message : String(error);
177+
}
178+
179+
expect(parseToolCallArguments(raw)).toEqual({ success: false, error: originalError });
180+
});
181+
141182
it('returns the original parse error for structurally broken input', () => {
142183
const raw = '{"a":"truncated';
143184
let originalError = '';

0 commit comments

Comments
 (0)