Skip to content

fix: 修复 Agent 流式 tool_call arguments 拼接错误及并行工具调用串扰问题#1355

Merged
CodFrm merged 7 commits intorelease/v1.4-agentfrom
v1.4-agent-fix-arguments
Apr 23, 2026
Merged

fix: 修复 Agent 流式 tool_call arguments 拼接错误及并行工具调用串扰问题#1355
CodFrm merged 7 commits intorelease/v1.4-agentfrom
v1.4-agent-fix-arguments

Conversation

@cyfung1031
Copy link
Copy Markdown
Collaborator

问题描述

在 Agent Chat 界面中,工具调用的参数(arguments)显示异常,开头出现多余的 {} 前缀,例如:

{}{"description":"Research Bahamut Anime...","prompt":"...","type":"researcher"}

根本原因有两处:

问题一:parseOpenAIStreamif/else if 互斥逻辑(providers/openai.ts

// 原始代码:name 和 arguments 互斥,首个 chunk 若同时携带两者,arguments 被塞进 start 而非 delta
if (tc.function?.name) {
  onEvent({ type: "tool_call_start", toolCall: { arguments: tc.function.arguments || "" } });
} else if (tc.function?.arguments) {
  onEvent({ type: "tool_call_delta", ... });
}

部分 API 网关(OpenRouter、Azure 某些部署、本地 vllm)在首个 chunk 中会同时携带 namearguments: "{}"(占位符)。tool_call_start 携带 "{}" 后,后续真实 JSON 通过 tool_call_delta 追加,最终 "{}" + '{"description":...}' = '{}{...}',与截图完全吻合。

问题二:tool_call_deltalength-1 匹配(多处 consumer)

background_session_manager.tsChatArea.tsxsub_agent_service.ts 中处理 tool_call_delta 时,均使用 toolCalls[toolCalls.length - 1] 来找目标工具,而非按 id 配对。OpenAI 并行返回多个 tool_call 时(用 index 区分),交错到达的 delta 会被写入错误的工具。

另外,Anthropic parser 的 tool_call_delta 固定 emit id: "",导致按 id 配对完全失效。


变更内容

src/app/service/agent/core/types.ts

  • LLMStreamEventtool_call_delta 新增可选字段 index?: number,用于并行工具调用的精确匹配

src/app/service/agent/core/providers/openai.ts

  • if/else if 改为两个独立的 iftool_call_startarguments 永远为空字符串
  • 首个 chunk 携带的 arguments 同样作为 tool_call_delta 发出
  • tool_call_delta 透传 tc.index,供 consumer 精确匹配

src/app/service/agent/core/providers/anthropic.ts

  • 新增 toolUseByIndex: Map<number, { id: string }> 追踪每个 content_block 的 index 与 id 对应关系
  • input_json_delta 发出的 tool_call_delta 现在携带正确的 idindex(原来 id 固定为 ""
  • content_block_stop 时清理对应 index 条目

src/app/service/agent/service_worker/background_session_manager.ts

  • tool_call_delta 匹配逻辑改为:按 id 精确匹配 → 按 index 匹配 → fallback 最近 status=running 的工具

src/pages/options/routes/AgentChat/ChatArea.tsx

  • 子代理 tool_call_delta 分支采用相同的三级匹配逻辑,替换原 length-1 写法

src/app/service/agent/service_worker/sub_agent_service.ts

  • runSubAgentCoretool_call_delta 分支同上,采用三级匹配逻辑

测试更新

  • openai.test.ts:新增并行 tool_call 按 index 分派测试;更新两个因 parser 行为变更而需调整断言的旧测试(event 数量 +1,tool_call_start.arguments 改为断言空字符串)
  • anthropic.test.ts:新增 input_json_delta 携带正确 id 和 index 的测试
  • background.test.ts:新增并行 tool_call 交错 delta 按 index 正确分派的测试

不受影响的文件

  • src/types/scriptcat.d.ts / scriptcat.zh-CN.d.ts:公开给 userscript 的 StreamChunk 接口不含 tool_call_delta,无需修改
  • src/app/service/content/gm_api/cat_agent.tsprocessStream 不处理 tool_call_delta 事件,无需修改
  • 持久化数据结构(ToolCall 类型)不变

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

该 PR 聚焦于 Agent 流式输出中 tool_call 的两个核心问题:首个 chunk 同时携带 name/arguments 时导致参数字符串被错误拼接(出现多余 {} 前缀),以及并发多个 tool call 时 tool_call_delta 被错误写入“最后一个工具”造成串扰。通过在流式事件中引入 index 并让各 consumer 按 id/index 精确路由,提升多 provider(OpenAI/Anthropic)下的稳定性。

Changes:

  • 扩展流式事件类型:tool_call_delta 增加 index?: number,用于并发 tool call 的精确匹配。
  • 修复 OpenAI/Anthropic stream parser:避免首 chunk 的 arguments 污染 start 事件,并透传 index/id 以支持并发路由。
  • 更新多个 consumer 的 delta 归属逻辑与相关单测,覆盖并发/交错 delta 的场景。

Reviewed changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
src/pages/options/routes/AgentChat/ChatArea.tsx 子代理分支的 tool_call_delta 改为按 id/index/running 回退匹配
src/app/service/agent/service_worker/sub_agent_service.ts 子代理 service 内同样改为三级匹配,避免 length-1 串扰
src/app/service/agent/service_worker/background_session_manager.ts 后台会话流状态更新逻辑支持按 id/index 路由 delta
src/app/service/agent/service_worker/background.test.ts 新增并发 tool_call 交错 delta 按 index 正确分派测试
src/app/service/agent/core/types.ts LLMStreamEvent.tool_call_delta 增加可选 index 字段
src/app/service/agent/core/providers/openai.ts 修复首 chunk name/arguments 互斥逻辑并透传 index
src/app/service/agent/core/providers/openai.test.ts 调整既有断言 + 新增并发/index 相关测试
src/app/service/agent/core/providers/anthropic.ts 通过 index→id 映射为 input_json_delta 补齐 id/index
src/app/service/agent/core/providers/anthropic.test.ts 新增 input_json_delta 应携带正确 id/index 的测试

// 并发 tool call 时(OpenAI 用 index 区分、Anthropic 的多个 tool_use block)length-1 会把 delta 写错工具。
if (rc.streamingState.toolCalls.length === 0) break;

let target;
Copy link

Copilot AI Apr 23, 2026

Choose a reason for hiding this comment

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

这里的 let target;strict: true(tsconfig.json)下会触发隐式 any 的编译错误(Variable implicitly has an 'any' type)。建议显式标注类型(例如 ToolCall | undefined)或用 let target: ToolCall | undefined = undefined;

Suggested change
let target;
let target: ToolCall | undefined = undefined;

Copilot uses AI. Check for mistakes.
Comment on lines +182 to 195
case "tool_call_delta": {
if (!sa.currentToolCalls.length) break;
let t = event.id ? sa.currentToolCalls.find((x) => x.id === event.id) : undefined;
if (!t && event.index !== undefined) t = sa.currentToolCalls[event.index];
if (!t) {
for (let i = sa.currentToolCalls.length - 1; i >= 0; i--) {
if (sa.currentToolCalls[i].status === "running") {
t = sa.currentToolCalls[i];
break;
}
}
}
if (t) t.arguments += event.delta;
break;
Copy link

Copilot AI Apr 23, 2026

Choose a reason for hiding this comment

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

这个分支已对「子代理」的 tool_call_delta 做了按 id/index 匹配,但同一函数后面「主消息」的 case "tool_call_delta" 仍然是 toolCalls[toolCalls.length - 1] 追加(见同文件后续 switch),并发 tool_call 仍会串扰。建议把主消息分支也改成同样的三级匹配逻辑(id → index → 最新 running)。

Copilot uses AI. Check for mistakes.
Comment on lines +576 to +580
// 拼接后应等同 LLM 真正要发的(就算首 chunk 有 "{}",也应被后续覆盖式语义接受)
// 注意:如果模型真的先发 "{}" 再发别的 JSON,整体不是合法 JSON —— 这是模型问题,
// 但至少我们不在 start 事件里把 "{}" 当成 args 的 prefix。
expect(joined.startsWith("{}")).toBe(true); // 原样透传
});
Copy link

Copilot AI Apr 23, 2026

Choose a reason for hiding this comment

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

这段测试的注释/用例名写的是“arguments='{}' 不应污染后续 args”,但断言却是 expect(joined.startsWith("{}")) 为 true(原样透传)。建议要么改用例名/注释以匹配当前预期,要么补充断言验证真正想保证的行为(例如 start 事件不携带占位 {}、或最终可解析 JSON 等)。

Copilot uses AI. Check for mistakes.
Comment on lines +190 to +191
const toolUseByIndex = new Map<number, { id: string }>();

Copy link

Copilot AI Apr 23, 2026

Choose a reason for hiding this comment

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

这里新增了 toolUseByIndex 用于 index→id 映射,但当前实现只在 content_block_startset,在 content_block_stop / message_stop 等结束事件里没有清理对应 index。这会导致 map 在长会话里持续增长,并且如果 index 在同一连接生命周期内被复用可能会拿到过期 id。建议在收到 content_block_stop(可用 json.index)时执行 toolUseByIndex.delete(json.index),并在 message_stop 时清空 map。

Copilot uses AI. Check for mistakes.
@CodFrm
Copy link
Copy Markdown
Member

CodFrm commented Apr 23, 2026

Code review

Found 2 issues:

  1. ChatArea.tsx 主消息分支的 tool_call_delta 仍然使用 toolCalls[length - 1] 匹配,未随本 PR 一并修复。PR 的 commit 在 background_session_manager.ts:61-62 自己写了不变式注释 "并发 tool call 时(OpenAI 用 index 区分、Anthropic 的多个 tool_use block)length-1 会把 delta 写错工具",但主消息流这条路径仍然使用旧逻辑,非 sub-agent 的普通对话在并发 tool call 时仍会串扰。

break;
case "tool_call_delta":
if (msg.toolCalls?.length) {
const lastTc = msg.toolCalls[msg.toolCalls.length - 1];
lastTc.arguments += event.delta;
}
break;

  1. llm_client.tstool_call_delta 使用单指针 currentToolCall 模型,本 PR 未更新。该路径是 callLLMWithToolLoop 实际装配 toolCalls[] 并传给工具执行器的"执行路径"。当 OpenAI parser 按本 PR 新行为对并发 tool call 交错发出 deltas(index=0 start → index=1 start → index=1 delta → index=0 delta)时,currentToolCall 始终指向最后一个 start(index=1),index=0 的 delta 会被错误追加到 index=1 的 arguments 上,导致实际执行的工具参数损坏。UI 展示层修好了,但执行层没修——与 PR 标题"并行工具调用串扰问题"的声明不一致。

case "tool_call_start":
// 如果已有一个正在收集的 tool call,先保存它(多个 tool_use 并行返回时)
if (currentToolCall) {
toolCalls.push(currentToolCall);
}
currentToolCall = { ...event.toolCall, arguments: event.toolCall.arguments || "" };
break;
case "tool_call_delta":
if (currentToolCall) {
currentToolCall.arguments += event.delta;
}
break;

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

- background_session_manager.ts: target 添加 ToolCall | undefined 类型,避免 strict 模式下隐式 any
- ChatArea.tsx: 主消息分支 tool_call_delta 同步改为 id/index/running 三级匹配
- openai.test.ts: 重写 "{}" 用例的断言与名称,明确 start 事件 args 为空、首 chunk args 作为 delta
- anthropic.ts: content_block_stop 清理对应 index,message_stop 清空 toolUseByIndex,顺带去掉 diff-annotation 注释
- llm_client.ts: 移除单指针 currentToolCall 模型,改为 push-then-match 三级匹配,修复执行路径在并发 tool_call 下的参数串扰
@CodFrm CodFrm merged commit 6d2b36b into release/v1.4-agent Apr 23, 2026
4 checks passed
@CodFrm CodFrm deleted the v1.4-agent-fix-arguments branch April 23, 2026 03:47
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.

3 participants