Skip to content

Commit 11992dd

Browse files
Lellansinclaude
andcommitted
fix: repair truncated/trailing-extra tool call arguments from streaming
Some models (e.g. Qwen3 via vLLM) occasionally emit extra closing braces at the end of streamed tool-call arguments, producing invalid JSON like `{...}}}`. When replayed in conversation history, vLLM's chat template calls json.loads() on the arguments and returns HTTP 400 "Extra data". repairToolCallArguments() strips the trailing garbage by finding the last balanced close of the top-level JSON value. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 3c5f04a commit 11992dd

1 file changed

Lines changed: 60 additions & 6 deletions

File tree

src/session.ts

Lines changed: 60 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,53 @@ export function stripModelContextSizeSuffix(model: string): string {
8080
return model.replace(CONTEXT_SIZE_SUFFIX_RE, "").trimEnd();
8181
}
8282

83+
function repairToolCallArguments(args: string): string {
84+
try {
85+
JSON.parse(args);
86+
return args;
87+
} catch (e) {
88+
if (e instanceof SyntaxError && e.message.includes("JSON") && "position" in (e as NodeJS.ErrnoException)) {
89+
// Node's JSON.parse error doesn't expose position directly; use a scan instead.
90+
}
91+
// Find the longest valid JSON prefix by scanning for the end of the top-level value.
92+
let depth = 0;
93+
let inString = false;
94+
let escaped = false;
95+
let validEnd = -1;
96+
for (let i = 0; i < args.length; i++) {
97+
const ch = args[i];
98+
if (escaped) {
99+
escaped = false;
100+
continue;
101+
}
102+
if (ch === "\\") {
103+
escaped = true;
104+
continue;
105+
}
106+
if (ch === '"') {
107+
inString = !inString;
108+
continue;
109+
}
110+
if (inString) continue;
111+
if (ch === "{" || ch === "[") depth++;
112+
else if (ch === "}" || ch === "]") {
113+
depth--;
114+
if (depth === 0) validEnd = i;
115+
}
116+
}
117+
if (validEnd >= 0 && validEnd < args.length - 1) {
118+
const candidate = args.slice(0, validEnd + 1);
119+
try {
120+
JSON.parse(candidate);
121+
return candidate;
122+
} catch {
123+
// fall through
124+
}
125+
}
126+
return args;
127+
}
128+
}
129+
83130
function parseContextSizeSuffix(model: string): number | undefined {
84131
const match = CONTEXT_SIZE_SUFFIX_RE.exec(model);
85132
if (!match) {
@@ -2239,14 +2286,21 @@ ${agentInstructions}
22392286

22402287
const record = toolCall as Record<string, unknown>;
22412288
const id = typeof record.id === "string" ? record.id.trim() : "";
2242-
if (id) {
2243-
return toolCall;
2289+
const withId = id ? record : { ...record, id: this.generateToolCallId() };
2290+
2291+
const fn = withId.function;
2292+
if (fn && typeof fn === "object" && !Array.isArray(fn)) {
2293+
const fnRecord = fn as Record<string, unknown>;
2294+
const args = fnRecord.arguments;
2295+
if (typeof args === "string") {
2296+
const fixedArgs = repairToolCallArguments(args);
2297+
if (fixedArgs !== args) {
2298+
return { ...withId, function: { ...fnRecord, arguments: fixedArgs } };
2299+
}
2300+
}
22442301
}
22452302

2246-
return {
2247-
...record,
2248-
id: this.generateToolCallId(),
2249-
};
2303+
return withId;
22502304
});
22512305
}
22522306

0 commit comments

Comments
 (0)