Skip to content
This repository was archived by the owner on May 15, 2026. It is now read-only.

Commit 09e8f31

Browse files
committed
fix: coerce write_to_file content to string when model sends object
Some models (e.g., GLM 4.7 via OpenAI Compatible API) incorrectly pass the content parameter as a JSON object instead of a string when calling the write_to_file tool. This causes processing to break silently. This fix adds defensive type coercion in NativeToolCallParser to: 1. Detect when content is not a string 2. Convert it to a formatted JSON string using JSON.stringify 3. Log a clear warning message so users understand what happened This follows the existing pattern for type coercion (e.g., coerceOptionalBoolean for booleans). Fixes #11040
1 parent e7965d9 commit 09e8f31

2 files changed

Lines changed: 188 additions & 3 deletions

File tree

src/core/assistant-message/NativeToolCallParser.ts

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,25 @@ export class NativeToolCallParser {
8989
return undefined
9090
}
9191

92+
/**
93+
* Coerce content to a string for write_to_file tool.
94+
* Some models (e.g., GLM 4.7 via OpenAI Compatible API) incorrectly pass content
95+
* as a JSON object instead of a string. This handles that case gracefully.
96+
*/
97+
private static coerceContentToString(value: unknown, toolName: string): string {
98+
if (typeof value === "string") {
99+
return value
100+
}
101+
102+
// Value is not a string - convert it and log a warning
103+
console.warn(
104+
`[NativeToolCallParser] Model sent non-string content for '${toolName}' tool. ` +
105+
`Expected string, received ${typeof value}. Converting to JSON string. ` +
106+
`This may indicate the model is not following the tool schema correctly.`,
107+
)
108+
return JSON.stringify(value, null, 2)
109+
}
110+
92111
/**
93112
* Process a raw tool call chunk from the API stream.
94113
* Handles tracking, buffering, and emits start/delta/end events.
@@ -401,10 +420,13 @@ export class NativeToolCallParser {
401420
break
402421

403422
case "write_to_file":
404-
if (partialArgs.path || partialArgs.content) {
423+
if (partialArgs.path || partialArgs.content !== undefined) {
405424
nativeArgs = {
406425
path: partialArgs.path,
407-
content: partialArgs.content,
426+
content:
427+
partialArgs.content !== undefined
428+
? this.coerceContentToString(partialArgs.content, "write_to_file")
429+
: undefined,
408430
}
409431
}
410432
break
@@ -805,7 +827,7 @@ export class NativeToolCallParser {
805827
if (args.path !== undefined && args.content !== undefined) {
806828
nativeArgs = {
807829
path: args.path,
808-
content: args.content,
830+
content: this.coerceContentToString(args.content, "write_to_file"),
809831
} as NativeArgsFor<TName>
810832
}
811833
break

src/core/assistant-message/__tests__/NativeToolCallParser.spec.ts

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,4 +238,167 @@ describe("NativeToolCallParser", () => {
238238
})
239239
})
240240
})
241+
242+
describe("write_to_file tool - content type coercion", () => {
243+
describe("parseToolCall", () => {
244+
it("should handle content as a string (normal case)", () => {
245+
const toolCall = {
246+
id: "toolu_123",
247+
name: "write_to_file" as const,
248+
arguments: JSON.stringify({
249+
path: "package.json",
250+
content: '{\n "name": "test"\n}',
251+
}),
252+
}
253+
254+
const result = NativeToolCallParser.parseToolCall(toolCall)
255+
256+
expect(result).not.toBeNull()
257+
expect(result?.type).toBe("tool_use")
258+
if (result?.type === "tool_use") {
259+
const nativeArgs = result.nativeArgs as { path: string; content: string }
260+
expect(nativeArgs.path).toBe("package.json")
261+
expect(nativeArgs.content).toBe('{\n "name": "test"\n}')
262+
}
263+
})
264+
265+
it("should coerce content from object to JSON string", () => {
266+
// This simulates the bug where models like GLM 4.7 pass content as an object
267+
const toolCall = {
268+
id: "toolu_456",
269+
name: "write_to_file" as const,
270+
arguments: JSON.stringify({
271+
path: "package.json",
272+
content: { name: "sample-project", version: "1.0.0" },
273+
}),
274+
}
275+
276+
const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
277+
278+
const result = NativeToolCallParser.parseToolCall(toolCall)
279+
280+
expect(result).not.toBeNull()
281+
expect(result?.type).toBe("tool_use")
282+
if (result?.type === "tool_use") {
283+
const nativeArgs = result.nativeArgs as { path: string; content: string }
284+
expect(nativeArgs.path).toBe("package.json")
285+
// Content should be converted to a formatted JSON string
286+
expect(nativeArgs.content).toBe(
287+
JSON.stringify({ name: "sample-project", version: "1.0.0" }, null, 2),
288+
)
289+
}
290+
291+
// Should log a warning about the type coercion
292+
expect(consoleSpy).toHaveBeenCalledWith(
293+
expect.stringContaining("Model sent non-string content for 'write_to_file' tool"),
294+
)
295+
296+
consoleSpy.mockRestore()
297+
})
298+
299+
it("should coerce content from array to JSON string", () => {
300+
const toolCall = {
301+
id: "toolu_789",
302+
name: "write_to_file" as const,
303+
arguments: JSON.stringify({
304+
path: "data.json",
305+
content: [1, 2, 3, { key: "value" }],
306+
}),
307+
}
308+
309+
const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
310+
311+
const result = NativeToolCallParser.parseToolCall(toolCall)
312+
313+
expect(result).not.toBeNull()
314+
if (result?.type === "tool_use") {
315+
const nativeArgs = result.nativeArgs as { path: string; content: string }
316+
expect(nativeArgs.content).toBe(JSON.stringify([1, 2, 3, { key: "value" }], null, 2))
317+
}
318+
319+
expect(consoleSpy).toHaveBeenCalled()
320+
consoleSpy.mockRestore()
321+
})
322+
323+
it("should coerce content from number to string", () => {
324+
const toolCall = {
325+
id: "toolu_num",
326+
name: "write_to_file" as const,
327+
arguments: JSON.stringify({
328+
path: "number.txt",
329+
content: 42,
330+
}),
331+
}
332+
333+
const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
334+
335+
const result = NativeToolCallParser.parseToolCall(toolCall)
336+
337+
expect(result).not.toBeNull()
338+
if (result?.type === "tool_use") {
339+
const nativeArgs = result.nativeArgs as { path: string; content: string }
340+
expect(nativeArgs.content).toBe("42")
341+
}
342+
343+
expect(consoleSpy).toHaveBeenCalled()
344+
consoleSpy.mockRestore()
345+
})
346+
})
347+
348+
describe("processStreamingChunk", () => {
349+
it("should coerce content from object to JSON string during streaming", () => {
350+
const id = "toolu_streaming_write"
351+
NativeToolCallParser.startStreamingToolCall(id, "write_to_file")
352+
353+
const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
354+
355+
const fullArgs = JSON.stringify({
356+
path: "config.json",
357+
content: { setting: true, value: 123 },
358+
})
359+
360+
const result = NativeToolCallParser.processStreamingChunk(id, fullArgs)
361+
362+
expect(result).not.toBeNull()
363+
if (result?.nativeArgs) {
364+
const nativeArgs = result.nativeArgs as { path: string; content: string }
365+
expect(nativeArgs.path).toBe("config.json")
366+
expect(nativeArgs.content).toBe(JSON.stringify({ setting: true, value: 123 }, null, 2))
367+
}
368+
369+
expect(consoleSpy).toHaveBeenCalled()
370+
consoleSpy.mockRestore()
371+
})
372+
})
373+
374+
describe("finalizeStreamingToolCall", () => {
375+
it("should coerce content from object to JSON string on finalize", () => {
376+
const id = "toolu_finalize_write"
377+
NativeToolCallParser.startStreamingToolCall(id, "write_to_file")
378+
379+
const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
380+
381+
NativeToolCallParser.processStreamingChunk(
382+
id,
383+
JSON.stringify({
384+
path: "tsconfig.json",
385+
content: { compilerOptions: { strict: true } },
386+
}),
387+
)
388+
389+
const result = NativeToolCallParser.finalizeStreamingToolCall(id)
390+
391+
expect(result).not.toBeNull()
392+
expect(result?.type).toBe("tool_use")
393+
if (result?.type === "tool_use") {
394+
const nativeArgs = result.nativeArgs as { path: string; content: string }
395+
expect(nativeArgs.path).toBe("tsconfig.json")
396+
expect(nativeArgs.content).toBe(JSON.stringify({ compilerOptions: { strict: true } }, null, 2))
397+
}
398+
399+
expect(consoleSpy).toHaveBeenCalled()
400+
consoleSpy.mockRestore()
401+
})
402+
})
403+
})
241404
})

0 commit comments

Comments
 (0)