Thanks to visit codestin.com
Credit goes to github.com

Skip to content

fix(devin-connect): emit one ChatMessage per assistant turn with parallel tool_calls - #267

Open
lovedxc wants to merge 1 commit into
dwgx:masterfrom
lovedxc:fix/parallel-tool-calls-wire
Open

fix(devin-connect): emit one ChatMessage per assistant turn with parallel tool_calls#267
lovedxc wants to merge 1 commit into
dwgx:masterfrom
lovedxc:fix/parallel-tool-calls-wire

Conversation

@lovedxc

@lovedxc lovedxc commented Sep 13, 2026

Copy link
Copy Markdown

Summary / 摘要

EN: Fix a deterministic upstream invalid_argument ("an internal error occurred") rejection when replaying history where one assistant turn carries multiple parallel tool_calls.

中文: 修复回放"单个 assistant 回合携带多个并行 tool_calls"的历史时,上游确定性返回 invalid_argument("an internal error occurred")的问题。

Root cause / 根因

EN: buildGetChatMessageRequest pushed a separate ChatMessage per tool_call, producing consecutive ASSISTANT-source wire messages. The upstream request validator rejects back-to-back same-source messages — the request decodes and begins processing, then the trailer fails.

中文: buildGetChatMessageRequest 此前为每个 tool_call 单独 push 一条 ChatMessage(#6),wire 序列出现连续多条 ASSISTANT-source 消息。上游校验器拒绝连续同源消息——请求先被解码并开始处理,随后 trailer 报错。

EN: The same violation arises when a client history splits one turn into several consecutive same-role text entries.

中文: 当客户端历史把一个回合拆成多条连续同 role 的纯文本消息时,会构成同类违规。

Changes / 修改

EN:

  • Merge runs of consecutive same-role text-only messages before wire encoding. Entries carrying tool_calls / tool_call_id / reasoning / images are never merged; system turns are skipped for adjacency (they hoist to field 大佬,这个非常厉害,calude code 不支持好像 #2); merge produces fresh objects, never mutating the caller array (safe under retry/failover).
  • Encode an assistant turn's tool_calls as repeated <(0o0)> #6 entries on a single ChatMessage, with leading turn text in Firebase 登入失敗: 信箱或密碼錯誤 #3 — symmetric with response frames that carry content + multiple delta_tool_calls together.

中文:

Verification / 验证

EN: Bisected replay of a captured failing request: the unmodified body reproduced invalid_argument deterministically; after the fix the identical body is accepted and generation proceeds. Single-call turns and text-only histories regress clean. devin-connect.test.js: 181/181 pass.

中文: 对捕获的失败请求做变体重放:原请求 100% 复现 invalid_argument;修复后原样请求被上游接受并正常生成。单调用回合、纯文本历史回归正常。devin-connect.test.js 181/181 通过。

…llel tool_calls

When a history assistant turn carries multiple parallel tool_calls, the
encoder pushed a separate ChatMessage (dwgx#6) per call, producing consecutive
ASSISTANT-source wire messages. The upstream request validator rejects
back-to-back same-source messages with invalid_argument ("an internal
error occurred") — the request decodes and begins processing, then the
trailer fails.

Two changes in buildGetChatMessageRequest:

- Merge runs of consecutive same-role text-only messages before wire
  encoding (a client history may split one turn into several entries).
  Entries carrying tool_calls / tool_call_id / reasoning / images are
  never merged; system turns are skipped for adjacency since they hoist
  to field dwgx#2.
- Encode an assistant turn's tool_calls as repeated dwgx#6 entries on a
  single ChatMessage, with leading turn text in dwgx#3 of the same message —
  symmetric with response frames that carry content + multiple
  delta_tool_calls together.

Verified by bisected replay of a captured failing request: the original
body reproduced invalid_argument deterministically; after the fix the
unmodified body is accepted and generation proceeds. Histories with
single-call turns and text-only runs regress clean.
@lovedxc

lovedxc commented Sep 13, 2026

Copy link
Copy Markdown
Author

补充说明:本 PR 与 #261 修复的是同一个根因——历史中的并行 tool_calls 在 wire 上产生连续 ASSISTANT-source 消息,触发上游 invalid_argument。两个 PR 修改的文件不重叠,思路差异如下供参考:

位置不同#261 在消息层(tool-emulation/chat handler)把并行调用重排成交替的单调用序列;本 PR 在 wire 编码层(buildGetChatMessageRequest)把一个回合的所有调用编码进同一条 ChatMessage(repeated #6),回合文本放同条 #3

覆盖面不同#261 只作用于 nativeStructured 和直接连接链路;本 PR 在编码最后一环生效,所有进入该函数的路径都受益。另外本 PR 同时处理了"连续同 role 纯文本消息"(客户端把一个回合拆成多条文本记录时产生)——这类连续 ASSISTANT 文本同样会触发连续同源校验,交错重排不覆盖这种情况。

语义不同:本 PR 保留回合的并行结构(repeated #6,与 response 帧同时携带 content + 多个 delta_tool_calls 的形态对称);#261 会把历史改写为串行回合,模型看到的语义略有差异。

已验证:对真实失败请求做变体重放,修复前 100% 复现 invalid_argument,修复后原样请求被上游接受并正常生成(含并行调用的回合被整体接受——若 #6 为单数字段,被丢弃的调用会使对应 tool_result 成为孤儿而再次被拒,实测通过反证 repeated #6 合法)。devin-connect.test.js 181/181 通过。

@dwgx

dwgx commented Sep 13, 2026

Copy link
Copy Markdown
Owner

评审:mergedMessages 那半是这一轮里我读到最干净的一块,我收——补一条断言就行。repeated-#6 那半我回绝当前形态,不是不信你,是我没有你的捕获,而本仓库的规矩是请求侧 wire 不许猜。

先说时序:#261 今天已经进 masterf52507e + 3d831b9,并带上了我补的承重测试)。你正文里说两份改的文件不重叠、可共存 —— 这点我核过,成立,合体后的 wire 与单独 #261 逐字节相同(下面第四行)。所以这份不是被 #261 挤掉的,是它自己那一半缺证据。

head 8d155cf,worktree 隔离:

结果
gh pr diff 267 --stat src/devin-connect.js | 58 ++++----1 个文件、0 条测试
你自陈 devin-connect.test.js 181/181 核对为真
该测试是否覆盖本次形态 不覆盖 —— 全仓测试里 nativeStructured: true 0 命中;42 个突变规格对该接缝 0 anchor
本条接缝的 wire 实测 见下表(tmp/wire-shape-real-path.mjs,判据是编码后 #3 ChatMessage 的 #2 source varint 与每帧 #6 计数)

四路 wire 实测(这是全部裁决依据)

wire sources 每帧 #6 最长连续同源 一条帧带多个 #6 字节
真 master(未修) 1,2,2,2,4,4 0,0,1,1,0,0 3 no 1323
#267 1,2,4,4 0,2,0,0 2 YES 1240
#261 1,2,2,4,2,4 0,0,1,0,1,0 2 no 1323
#261 + #267 合体 1,2,2,4,2,4 #261 2 no 1323

从这张表里我要说两件事,第二件对你是好消息。

第一:两份修复都把"连续 3 条同源"消掉了,2-call 场景下都只降到 2,而两份都被各自作者报为 live 通过 ⇒ 与"上游允许连续 2、拒绝 3"一致。缺陷是 run ≥ 3,不是任何连续同源。 你正文里"上游拒绝连续同源"这个表述比实际机制更强,机制上是 run ≥ 3;这不影响你的修复有效,但写进代码注释会误导下一个人。

第二:合体后 wire 与单独 #261 逐字节相同——因为归一化之后,编码器已经看不到"一条消息带多个 call"的输入了。所以你那半一旦 #261 在树上,你在 2-call 场景下拿不到任何行为差异


M1(回绝的理由)— repeated-#6 是请求侧未捕获的形态选择

你把一轮的 N 个 call 编成同一条 ChatMessage 上的 repeated #6,理由是"与 response 帧同时携带 content + 多个 delta_tool_calls 对称"。

这是解码侧的已知事实,推不出请求侧的形状。 仓库自己的注释(devin-connect.js:1376-1387,上一任用二进制静态反汇编 + merge_field 跳表标定的)写着:

  • #6 作为 repeated响应帧顶层 delta_tool_calls 的标定;
  • 请求侧那条子消息 "is physically absent and un-calibratable here"
  • 同一段还立了规矩:"The tags are NOT guessed in code"

所以问题不在对错,在门槛:请求侧 wire 改动必须有真实捕获。你做了 58KB 真实请求体的 bisect 重放——那是行为证据,不是字节证据:它证明了"改完能过",没有证明"真实 CLI 发的就是这个形状"。

这不是苛刻,是因为这份改动影响每一个 native 请求:如果真实 CLI 是"一消息一 call",把它改成"一消息多 call"就是全量静默劣化——本仓库最怕的正是这一类。

给我一段真实 CLI 在"一轮多 call"时的请求帧,哪怕只是 hex,这半我也收。


M2(我收的那半)— mergedMessages 可以直接进

这段我攻不动,写得很专业,机制我逐条核过:

  • 只合并连续同 role 的纯文本,带 tool_calls / tool_call_id / reasoning / 图片的条目明确排除
  • system 不参与相邻判断(它们本就会提升到 #2);
  • 生成新对象、不改调用方数组 —— 这一点说明你知道这个数组会被重试/failover 复用,很多提交者不知道。

它自己的理由也站得住:客户端把一个回合拆成多条连续同 role 纯文本时,构成的是同类违规把它单独提一个 PR,配一条断言我就收

// 同一历史,合并前后 wire 的 #2 source 序列(这是缺失的那条证据)
// 输入:两条连续同 role 纯文本 user 消息
// 断言:编码后 #2 source 序列从 [1,1,...] 变为 [1,...],且带 tool_calls 的条目一条都没被合并

一条正向(纯文本被合并)+ 一条反向(带 tool_calls 的不合并)就够,不需要大测试。这两条断言正好把"合并范围"钉死——那是这段代码唯一可能出错的地方


一句提醒

你自陈的 181/181 我核对为真,不是错报——只是它只能说明"没把别的弄坏",不能说明"这个形态被守住了"。这两件事在本仓库是分开算的。

改法建议:拆成两个 PR——mergedMessages 先来(配上面两条断言),repeated-#6 等你拿到捕获帧再来。改完 ping 我。

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.

2 participants