Skip to content

fix(core): retry malformed JSON protocol leaks - #8289

Closed
xianjianlf2 wants to merge 2 commits into
QwenLM:mainfrom
xianjianlf2:agent/json-tool-call-recovery
Closed

fix(core): retry malformed JSON protocol leaks#8289
xianjianlf2 wants to merge 2 commits into
QwenLM:mainfrom
xianjianlf2:agent/json-tool-call-recovery

Conversation

@xianjianlf2

@xianjianlf2 xianjianlf2 commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

What this PR does

Retries the malformed production response shape from #8207 when the assistant starts with a JSON array payload and then leaks </parameter></function> closing protocol tags. The stream now buffers only leading JSON-array-looking text until post-stream validation can decide whether it is the malformed protocol leak; ordinary JSON is flushed as assistant text, and real structured function calls still preserve original chunk order. This intentionally does not infer or execute tool calls from JSON item name fields.

Why it's needed

The recorded failure was not valid standalone JSON and was not a trustworthy structured tool call. It was leaked protocol output that reached UI/history as visible text, so converting arbitrary JSON shapes into tool calls was too broad and could execute tools outside the current request surface. Routing only the exact malformed protocol shape through the existing PROTOCOL_TAG_LEAK retry path fixes the observed leak without expanding tool execution semantics.

Reviewer Test Plan

How to verify

  • cd packages/core && npx prettier --check src/core/geminiChat.ts src/core/geminiChat.test.ts
  • cd packages/core && npx eslint src/core/geminiChat.ts src/core/geminiChat.test.ts
  • cd packages/core && npm run typecheck
  • cd packages/core && npx vitest run src/core/geminiChat.test.ts -t "ordinary leading JSON|leading JSON array followed|flushes buffered leading JSON|protocol-tagged response"

Evidence (Before & After)

Before: a leading JSON array followed by leaked </parameter></function> tags could be emitted and persisted as assistant text. After: the regression fixture retries through PROTOCOL_TAG_LEAK, does not emit or record the leaked payload, keeps ordinary leading JSON as text, and flushes buffered JSON before a later real structured tool call to preserve event order.

Tested on

OS Status
🍏 macOS Passed
🪟 Windows Not run
🐧 Linux Not run

Environment (optional)

Local macOS worktree under Node/npm from the repository toolchain.

Risk & Scope

  • Main risk or tradeoff: buffering applies to leading JSON-array text until stream completion, so a normal answer that begins with a JSON array is displayed at the end of that response instead of chunk-by-chunk.
  • Not validated / out of scope: this does not attempt to recover malformed JSON into executable tool calls, and it does not infer the outer tool name for agent calls.
  • Breaking changes / migration notes: none.

Linked Issues

Fixes #8207

中文说明

这个 PR 改为处理线上真实形态:模型输出以 JSON 数组开头,后面泄漏 </parameter></function> 协议闭合标签。客户端只在这种精确形态下走已有的 PROTOCOL_TAG_LEAK 重试路径,不再根据 JSON 里的 name 字段推断并执行工具,因此不会扩大工具执行面。普通 JSON 文本仍会作为普通助手文本输出。

@xianjianlf2
xianjianlf2 marked this pull request as ready for review August 1, 2026 07:09
@qwen-code-ci-bot

qwen-code-ci-bot commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator

Qwen Triage finishedview run. See the stage comments in this thread for the result.

Qwen Triage 已完成 —— 查看运行。结果见本线程中的各阶段评论。

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this — the underlying fix is well-motivated. #8207 is a real, observed production bug (qwen3.7-max leaking JSON tool-call arguments as plain text when the model drops function-calling format), and extending the existing post-stream recovery architecture with a conservative JSON branch is a sensible direction.

Before we can review the code, the PR body needs to follow the repository's pull request template. Right now it uses free-form headings (Summary / Demo / Validation) and is missing the sections the template requires:

  • What this PR does and Why it's needed — in prose, not by file or function name
  • Reviewer Test PlanHow to verify, Evidence (Before & After) (N/A is fine here since there's no user-visible UI change), and the Tested on table
  • Risk & Scope — main risk or tradeoff, what's not validated / out of scope, and any breaking changes
  • Linked IssuesFixes #8207 as a closing keyword inside this section
  • The bilingual 中文说明 <details> block translating the English body paragraph-by-paragraph

Could you restructure the description using the template, @xianjianlf2? It lives here: https://github.com/QwenLM/qwen-code/blob/main/.github/pull_request_template.md

Once the description is in shape, we'll dive into the code review. 🙏

中文说明

感谢贡献——这个修复本身的动机是充分的。#8207 是一个真实观测到的生产环境 bug
(qwen3.7-max 在模型丢失 function-calling 格式时,把 JSON tool-call 参数作为
纯文本泄漏),在现有 post-stream recovery 架构上扩展一个保守的 JSON 分支也是
合理的方向。

但在审查代码之前,PR 描述需要遵循仓库的 pull request 模板。目前使用的是自由
标题(Summary / Demo / Validation),缺少模板要求的章节:

  • What this PR doesWhy it's needed —— 用散文描述,不要按文件或函数名罗列
  • Reviewer Test Plan —— How to verifyEvidence (Before & After)
    (因为没有用户可见的 UI 变化,这里可以填 N/A)、以及 Tested on 表格
  • Risk & Scope —— 主要风险或权衡、未验证/超出范围的内容、以及破坏性变更
  • Linked Issues —— 在此章节内用 Fixes #8207 作为关闭关键字
  • 双语的 中文说明 <details> 块,逐段翻译英文正文

能否用模板重新组织一下描述,@xianjianlf2?模板在这里:
https://github.com/QwenLM/qwen-code/blob/main/.github/pull_request_template.md

描述整理好后,我们会进入代码审查。🙏

Qwen Code · qwen3.8-max-preview

@yiliang114 yiliang114 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reproduced the reported payload against this commit. The current recovery misses issue #8207 and also crosses the text-to-execution boundary without a trustworthy signal. Please address the blocking comments below before this lands.

const recovered = calls.map((call) => {
if (!isRecord(call) || typeof call['name'] !== 'string') return null;
const name = call['name'];
if (!isKnownToolName(name)) return null;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] This interprets each item name as the registered tool name, but in #8207 create_node and read_ref are values of the agent call name argument; the intended outer tool is agent. Running the exact reported shape with only agent registered returns recovered: false, so the original leak remains. Please make the regression fixture faithful to the issue and preserve each item name as an argument. Since the text has no trustworthy outer function name, it should not be inferred from this field.

Comment thread packages/core/src/core/geminiChat.ts Outdated
contentText
) {
const toolRegistry = this.config.getToolRegistry();
const recovery = tryRecoverJsonToolCalls(contentText, (name) =>

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] This validates against every loaded registry tool rather than the tools actually declared for this request. As a result, ordinary JSON documentation for a registered tool can become a real call even when that tool was deferred, excluded by a subagent allowlist, or the request explicitly used tools: []; in YOLO mode that can execute immediately. Recovery needs a trustworthy intent signal and must be constrained to the current request tool surface; JSON shape alone is not sufficient.

Comment thread packages/core/src/core/geminiChat.ts Outdated
const chunkStartsJson =
chunkText?.trimStart().startsWith('[') ||
chunkText?.trimStart().startsWith('{');
if (!hasToolCall && (bufferingJsonText || chunkStartsJson)) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Buffering can start on a JSON-looking chunk in the middle of a normal answer and then hold every remaining chunk until EOF. It also leaves buffered prefix chunks pending when a later structured function call arrives, so the tool-call/finish chunk is yielded before earlier text. Please restrict detection to the leading response and flush pending chunks in original order before yielding a real structured call.

@yiliang114

Copy link
Copy Markdown
Collaborator

One more detail from the production trace: the visible content was not standalone JSON. The array was followed by closing protocol tags, specifically and , so JSON.parse(trimmed) fails before this code reaches the name check. Those tags are also the trustworthy signal that this is malformed protocol output, while the outer function name is still absent. The smaller safe fix is to buffer this exact shape before UI/history emission and send it through the existing PROTOCOL_TAG_LEAK retry path, with a regression using the array plus closing tags. That fixes the recorded case without guessing agent or turning ordinary JSON into executable calls.

@xianjianlf2

Copy link
Copy Markdown
Contributor Author

@qwen-code /triage

@xianjianlf2 xianjianlf2 changed the title fix(core): recover JSON tool-call text leaks fix(core): retry malformed JSON protocol leaks Aug 1, 2026
@xianjianlf2

Copy link
Copy Markdown
Contributor Author

@qwen-code /triage

@yiliang114

Copy link
Copy Markdown
Collaborator

Thanks for iterating on this. I opened #8301 as the maintainer-owned fix based on the full production trace. It treats the JSON array plus closing protocol tags as a failed protocol response and retries it, rather than reconstructing and executing a tool call; it also covers ordinary JSON streaming and structured-call ordering. I’m closing this one as superseded so we can continue the core change in one place.

@yiliang114 yiliang114 closed this Aug 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(core): JSON-style tool call arguments leak as plain text when model drops function-calling format

3 participants