Summary
When a streamed tool call's arguments are cut off mid-value (e.g. the model hits max_tokens while still writing a string argument), the value that was only ever meant for live streaming-progress display can end up used as the final, executed value with no error or diagnostic indicating it was incomplete.
Where
Observed at commit abaf732bb7a96848a7c6f5dd2ce485b86bff086d.
src/core/assistant-message/NativeToolCallParser.ts: processStreamingChunk() uses the partial-json package (^0.1.7 in src/package.json) to extract a best-effort value from the JSON accumulated so far, purely to show progress during streaming - createPartialToolUse()'s write_to_file case builds nativeArgs.content directly from that lenient parse.
- When the stream ends,
finalizeStreamingToolCall() calls the strict parseToolCall() (JSON.parse on the full accumulated text). A genuinely truncated payload correctly throws and the function returns null.
src/core/task/Task.ts (search for finalizeStreamingToolCall returned null): when finalization returns null, the code reuses the existing tool-use object - the one built during streaming from the partial-json output - and just sets .partial = false. It does not clear nativeArgs.
src/core/assistant-message/presentAssistantMessage.ts: the guard here is !block.nativeArgs. Since nativeArgs was already populated during streaming, this guard does not trip.
src/core/tools/WriteToFileTool.ts: the only content check is newContent === undefined. A truncated-but-present string passes this too.
Repro / PoC
Given a write_to_file call whose content argument is cut off mid-value:
{"path":"config/database.yml","content":"production:\n host: db.prod.internal\n password: correct-horse-battery-sta
Running the actual partial-json package (v0.1.7, the version pinned in src/package.json) on this string:
parse(text) -> { path: "config/database.yml",
content: "production:\n host: db.prod.internal\n password: correct-horse-battery-sta" }
No error. content is present and looks like a normal, complete string - exactly what nativeArgs.content would still hold when finalization fails and the stale streaming-phase object is reused, so WriteToFileTool would write this content to disk exactly as shown, silently.
For contrast, running the same input through prefix-safe-json (an incremental JSON parser I maintain, built specifically around never exposing a value until it's unambiguously complete):
outcome: "truncated", executable: false
stableValue: { path: "config/database.yml" } // content is absent, not guessed
Impact
Any tool whose schema includes a free-form string argument (file content, shell command, etc.) can have that argument silently truncated-but-executed when the model's response is cut off mid-argument - a data-integrity issue for any tool with a real side effect.
Context
This codebase shares this exact code path (including comments) with RooCodeInc/Roo-Code, which this appears to be derived from - I reported the same issue there as well, in case it's useful for cross-referencing a fix.
Suggested direction (not prescriptive)
When finalizeStreamingToolCall() returns null, clear/discard the reused tool-use object's nativeArgs (and ideally params) rather than only marking it non-partial - falling back to the existing !block.nativeArgs guard at that point instead of leaving stale partial-parse data in place.
I found this by cloning the repository and reading the real source (paths/lines above), then verified the specific claim about partial-json's output by running the actual package version you depend on - not speculation. Happy to answer questions or share the small repro script if useful.
Summary
When a streamed tool call's arguments are cut off mid-value (e.g. the model hits
max_tokenswhile still writing a string argument), the value that was only ever meant for live streaming-progress display can end up used as the final, executed value with no error or diagnostic indicating it was incomplete.Where
Observed at commit
abaf732bb7a96848a7c6f5dd2ce485b86bff086d.src/core/assistant-message/NativeToolCallParser.ts:processStreamingChunk()uses thepartial-jsonpackage (^0.1.7insrc/package.json) to extract a best-effort value from the JSON accumulated so far, purely to show progress during streaming -createPartialToolUse()'swrite_to_filecase buildsnativeArgs.contentdirectly from that lenient parse.finalizeStreamingToolCall()calls the strictparseToolCall()(JSON.parseon the full accumulated text). A genuinely truncated payload correctly throws and the function returnsnull.src/core/task/Task.ts(search forfinalizeStreamingToolCall returned null): when finalization returnsnull, the code reuses the existing tool-use object - the one built during streaming from thepartial-jsonoutput - and just sets.partial = false. It does not clearnativeArgs.src/core/assistant-message/presentAssistantMessage.ts: the guard here is!block.nativeArgs. SincenativeArgswas already populated during streaming, this guard does not trip.src/core/tools/WriteToFileTool.ts: the only content check isnewContent === undefined. A truncated-but-present string passes this too.Repro / PoC
Given a
write_to_filecall whosecontentargument is cut off mid-value:Running the actual
partial-jsonpackage (v0.1.7, the version pinned insrc/package.json) on this string:No error.
contentis present and looks like a normal, complete string - exactly whatnativeArgs.contentwould still hold when finalization fails and the stale streaming-phase object is reused, soWriteToFileToolwould write this content to disk exactly as shown, silently.For contrast, running the same input through prefix-safe-json (an incremental JSON parser I maintain, built specifically around never exposing a value until it's unambiguously complete):
Impact
Any tool whose schema includes a free-form string argument (file content, shell command, etc.) can have that argument silently truncated-but-executed when the model's response is cut off mid-argument - a data-integrity issue for any tool with a real side effect.
Context
This codebase shares this exact code path (including comments) with
RooCodeInc/Roo-Code, which this appears to be derived from - I reported the same issue there as well, in case it's useful for cross-referencing a fix.Suggested direction (not prescriptive)
When
finalizeStreamingToolCall()returnsnull, clear/discard the reused tool-use object'snativeArgs(and ideallyparams) rather than only marking it non-partial - falling back to the existing!block.nativeArgsguard at that point instead of leaving stale partial-parse data in place.I found this by cloning the repository and reading the real source (paths/lines above), then verified the specific claim about
partial-json's output by running the actual package version you depend on - not speculation. Happy to answer questions or share the small repro script if useful.