Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/tool-args-escape-repair.md
Original file line number Diff line number Diff line change
@@ -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.
66 changes: 66 additions & 0 deletions packages/agent-core/src/loop/tool-call.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
75 changes: 75 additions & 0 deletions packages/agent-core/test/loop/tool-call.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<ContentPart[]> {
const blocks = new ContentBlocksTool({ output });
const { context } = await runTurn({
Expand Down Expand Up @@ -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) });
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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[] = [];
Expand Down
Loading