Skip to content

fix(tools): absorb stray assistant text inside parallel tool-call runs - #271

Merged
dwgx merged 2 commits into
dwgx:masterfrom
21hbguo:fix/parallel-toolcall-stray-text
Sep 16, 2026
Merged

dwgx merged 2 commits into
dwgx:masterfrom
21hbguo:fix/parallel-toolcall-stray-text

Conversation

@21hbguo

@21hbguo 21hbguo commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

改了什么 / What changed

interleaveParallelToolMessages now collects all consecutive assistant entries in a run — call-bearing ones into assistantMsgs, text-only ones into strayTexts — and folds the stray prose into the first call-bearing assistant before interleaving call/result pairs. When no tool results match, the whole run is emitted verbatim (calls and strays alike) so nothing is dropped.

为什么 / Why

中文: Responses-API 客户端(Codex)把并行工具调用回放为独立条目:[function_call, function_call, assistant文本, tool_result, tool_result]。原实现收集 assistant run 时遇到无 tool_calls 的纯文本条目即中断 → 交错配对不生效、原样透传 → wire 上产生连续 3 条 ASSISTANT-source ChatMessage(call, call, text)。上游校验器拒绝同源连发 ≥3(invalid_argument,即 an internal error occurred),且对同一 payload 确定性失败——客户端反复重试同一请求,表现为流式回复中途持续失败。

EN: Responses-API clients (Codex) replay parallel tool calls as separate items: [function_call, function_call, assistant text, tool_result, tool_result]. The run collector stopped at the first text-only assistant, so interleaving never engaged and the wire carried 3 consecutive ASSISTANT-source ChatMessages (call, call, text). The upstream validator rejects same-source runs ≥3 with invalid_argument ("an internal error occurred") — deterministically for the same payload, so client retries of the identical request fail in a loop and the stream appears to stall mid-turn.

复现 / Reproduction

History [user, tc1, tc2, assistant(text), tool(r1), tool(r2), user] against swe-2-max:

  • Before: response.failed (upstream internal error) 3/3 attempts; wire dump shows source sequence 1,2,2,2,4,4,1
  • After: response.completed 3/3; wire sequence 1,2,2,4,2,4,1 — max same-source run is 2
  • [call, call, call, result, result, result] (no stray text) still interleaves 2,4,2,4,2,4 — unchanged

测试 / Testing

node --import ./test/setup-env.mjs --test test/tool-emulation.test.js
# tests 75 / suites 11 / pass 75 / fail 0

Two new cases: stray text absorbed into the first call-bearing turn ([tc, tc, text, tool, tool]assistant, tool, assistant, tool), and verbatim preservation of the full run when no results match.

Generated with Devin

复审修订 / Review follow-ups (bac13fe)

中文:

  • M1:折叠目标为数组 content 时不再压平成字符串,改为追加 {type:'text'} part——image_url 等非文本 part 在 wire 上是独立编码类型,压平会让 extractInlineImagesArray.isArray 门槛)丢图。与上游 isMergeableText 同一条规则。已配会红的用例(断言 part 对象身份 + 调用方数组不被改)。
  • M3.1:恢复两处 tcid && 守卫(hasMatches 入口 + 逐项 findIndex 消费)。空 tool_call_id 两侧相等不再构成交错——native 编码对缺失 call id 生成 UUID、空 result id 不进 role=4 分支,两个空串相等不等于调用归属。配了无 ID 历史原样透传的用例。
  • M3.2(取舍声明):stray 只在其自身字段全部可合并(白名单 role/content/tool_calls,且数组 content 全为文本 part)时才折叠;携带 reasoning_content/signature/name/annotations 等字段的 stray 不折叠,整条透传、追加在交错块末尾。取舍:字段一个不丢、wire 保持 2,4,…,2 交替;代价是不可折叠 stray ≥3 连排时仍可能触发同源连发(与修复前该形状行为一致)。Codex 每回合常见形状(一条 reasoning+text)不受影响。

EN:

  • M1: folding into an array content now appends a {type:'text'} part instead of flattening — non-text parts encode as distinct wire types and flattening lost them before extractInlineImages. Same rule as upstream isMergeableText. Red-guarded test asserts part object identity + caller-array immutability.
  • M3.1: tcid && guards restored at the hasMatches entry and the per-call findIndex consume — empty ids on both sides no longer constitute call ownership.
  • M3.2 (declared tradeoff): a stray folds only when every own key is mergeable (whitelist role/content/tool_calls) and its content is text-only. Strays carrying reasoning_content/signature/name/annotations/non-text parts are emitted verbatim after the interleaved block — no field is silently dropped and the wire keeps alternating 2,4,…,2. Accepted cost: ≥3 consecutive unmergeable strays can still form a same-source run (identical to pre-fix behaviour for that shape).

Codex emits parallel function_calls as separate items, then an assistant
text item, then the results — [tc, tc, text, tool, tool]. The interleave
run stopped at the text-only assistant, so no interleave applied and the
wire carried three consecutive ASSISTANT messages (call, call, text).
The upstream validator rejects same-source runs >=3 with invalid_argument
("an internal error occurred"), which surfaced as deterministic
response.failed on the identical retried payload.

Fold the stray prose into the first call-bearing assistant of the run so
the sequence stays interleaved call/result (verified on the wire: max
same-source run is now 2), and emit the whole run verbatim when no
results match so nothing is dropped.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
@dwgx

dwgx commented Sep 16, 2026

Copy link
Copy Markdown
Owner

评审:机制我逐条驱动过,你声明的两个 wire 序列在本机逐位复现(1,2,2,2,4,4,1 run=3 → 1,2,2,4,2,4,1 run=2)。请补一条 —— 折叠会把数组 content 压平成字符串,图从 wire 上消失;另有两处未声明的行为放宽,需要写进描述或加守卫。

我实跑过什么

head a5938f4,worktree 隔离,自建输入驱动(没有重跑你的套件):

结果
自建 protobuf walker,读每帧 #2 角色与 #10 图片 [1,2,2,2,4,4,1] run=3 → 新 [1,2,2,4,2,4,1] run=2 —— 与你描述逐位一致
node --test test/tool-emulation.test.js 75 pass / 0 fail,与声明一致
3000 条随机历史新旧对拍 673 条差异(22.4%),全部落在 run 收集生效的形状上,无意外重排;无输入对象被改;新实现幂等
数组 content + stray 的形状 图丢了,见 M1
空/缺失 tool_call_id 的形状 匹配语义被放宽,见 M3

根因判断我核过,成立:旧实现的 run 收集要求单条消息 tool_calls.length > 1,遇到纯文本条目即断,[call, call, text, tool, tool] 原样透传成同源 run=3;上游拒 run≥3 这一点与仓库自己的记录一致(devin-connect.js:1042-1047 注释 + #267 复核)。

M1(请修)—— 折叠把数组 content 压平成字符串,非文本 part 静默丢失

emulation 路径(nativeToolCall=false)实测:

                    roles          maxRun   images(#10)
旧   [2,2,2,1,1]                      3         1
新   [2,1,2,1]                        1         0     ← 图从 wire 上消失

来源是你新增的 assistantMsgs[0] = { ...first, content: cur ? \${cur}\n\n${strayText}` : strayText }:cur 走 contentTextForPreambleCheck,对数组只取 p.textjoin(''),image part 不再留在 content 里 → 下游 extractInlineImages(要求 Array.isArray)返回 []`。

这不是方向错误,是坐标系问题:同一天 71982d5 刚给上游那层钉过相反规则 —— devin-connect.js:1051-1055isMergeableText 对数组只允许 every(c => c?.type === 'text'),理由写在 :1049(“非文本 part 编码成不同 wire type,必须保持独立”),并且配了会变红的测试;同文件邻居 prependPreambleToContent 面对数组是追加 text part,不压平。

最小改法(保留原 part,只追加):

if (Array.isArray(first.content)) {
  const hasTextPart = first.content.some((part) => part?.type === 'text');
  assistantMsgs[0] = {
    ...first,
    content: [...first.content, { type: 'text', text: `${hasTextPart ? '\n' : ''}${strayText}` }],
  };
} else {
  const cur = first.content == null ? '' : contentTextForPreambleCheck(first.content);
  assistantMsgs[0] = { ...first, content: cur ? `${cur}\n\n${strayText}` : strayText };
}

请配一条会红的用例:首个回合 content 是 [{type:'text',text:'see'}, {type:'image_url',…}],断言输出里那个 part 仍是 image 对象(不是被折成文本);把上面改回压平版,这条必须失败。

M2(合并时我来,不是你的锅)—— anchor 与基线漂移

test/mutations/interleave-parallel-tool-history.json 第三条 anchor 钉的是 matchIdx 块的旧缩进,你把该块移深两层后 anchor 0 次命中 —— 这也会让你分支的 npm testtest/docs-consistency-guard.test.js:212 上变红。三份跑该文件的规格基线各 +3(72→75 / 103→106 / 81→84)。合并时我重锚 + 按实测刷基线,不需要你动。

M3(请补进描述,或顺手加守卫)—— 两处未声明的放宽

  1. 空/缺失 tool_call_id 现在会匹配:旧代码 if (tcid && …) 永不匹配空 id,新的 String(tm?.tool_call_id ?? '') === String(tc?.id ?? '') 让两个空值相等。native 编码对缺失 call ID 生成 UUID、而空 result ID 不进 role=4 分支 —— 两个空值能相等不等于调用归属成立。建议入口与逐项消费两处都恢复 tcid && 守卫。
  2. stray 的其余字段被静默丢弃:折叠只取 contentreasoning_content 在原生路径本来会进 reasoning 帧(fallback 分支),折叠后消失;name / annotations 现在也一并丢。如果这些字段没有通用合并语义,更安全的做法是“带额外字段的 stray 不折叠、整段透传”,并把这个取舍写进描述。

诊断对、方向对、wire 证据成立。补齐 M1 的守卫与用例,并给 M3 一个取舍说法,我重跑全量 + 突变门后进合并序列。若你想要,我可以把这两条(main 层折叠 + 边界用例)整理成一份参考补丁放到评论里 —— 直接说一声即可。

@dwgx

dwgx commented Sep 16, 2026

Copy link
Copy Markdown
Owner

补上前一条说的参考改法。不强制 —— 你按自己的实现改也行,这里只是把两个最小改动摆出来,省得来回。

M1:数组 content 只追加,不压平

替换折叠里从 const cur = … 到赋值的那两行:

if (Array.isArray(first.content)) {
  const hasTextPart = first.content.some((part) => part?.type === 'text');
  assistantMsgs[0] = {
    ...first,
    content: [...first.content, { type: 'text', text: `${hasTextPart ? '\n' : ''}${strayText}` }],
  };
} else {
  const cur = first.content == null ? '' : contentTextForPreambleCheck(first.content);
  assistantMsgs[0] = { ...first, content: cur ? `${cur}\n\n${strayText}` : strayText };
}

配套用例(放进 test/tool-emulation.test.jsinterleaveParallelToolMessages describe):

it('keeps every original content part when absorbing stray text', () => {
  const image = { type: 'image_url', image_url: { url: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==' } };
  const content = [{ type: 'text', text: 'see' }, image];
  const messages = [
    { role: 'assistant', content, tool_calls: [{ id: 'c1', type: 'function', function: { name: 'f', arguments: '{}' } }] },
    { role: 'assistant', tool_calls: [{ id: 'c2', type: 'function', function: { name: 'f', arguments: '{}' } }] },
    { role: 'assistant', content: 'stray note' },
    { role: 'tool', tool_call_id: 'c1', content: 'r1' },
    { role: 'tool', tool_call_id: 'c2', content: 'r2' },
  ];
  const out = interleaveParallelToolMessages(messages);
  assert.equal(out[0].content[1], image, 'image part must survive as an object');
  assert.deepEqual(content, [{ type: 'text', text: 'see' }, image], 'caller array untouched');
});

把实现改回压平版,这条必须红(它断言的是 part 的对象身份,不是文本内容)。

M3:两处非空 ID 守卫(入口 + 逐项消费)

// hasMatches
const hasMatches = toolCalls.some((tc) => {
  const tcid = String(tc?.id ?? '');
  return tcid && toolMsgs.some((tm) => String(tm?.tool_call_id ?? '') === tcid);
});
// 消费处(你新加的 findIndex 里)
const matchIdx = toolMsgs.findIndex(
  (tm, idx) => tcid && !usedIndices.has(idx) && String(tm?.tool_call_id ?? '') === tcid,
);

最小反例:全无 ID 的历史 —— [{role:'assistant',tool_calls:[{function:{name:'f',arguments:'{}'}},{function:{name:'f',arguments:'{}'}}]},{role:'tool',content:'r1'},{role:'tool',content:'r2'}]。旧代码原样透传,当前实现会交错;两个空值能相等,不等于调用归属成立。

时间线

你按自己的节奏改。如果这两天不方便推,我按 #270 的先例(合了方向、同批补维护方修补)在合并批次里带上上面两处 —— 仍按你这个 PR 记账,commit 保持你的署名。

…leave

Review follow-ups on the parallel tool-call interleave:

- Array content on the first call-bearing assistant now gets a text part
  appended instead of being flattened to a string — non-text parts
  (image_url ...) encode as distinct wire types and were silently
  dropped before reaching extractInlineImages. Same rule isMergeableText
  already enforces on the upstream encoder.
- An empty/missing tool_call_id can no longer pair with an empty result
  id: the `tcid &&` gate is restored in both the hasMatches entry check
  and the per-call findIndex consume. Two empty strings comparing equal
  is not evidence of call ownership — native encoding assigns a fresh
  UUID to a missing call id while an empty result id never reaches the
  role=4 branch.
- Stray assistant entries carrying unmergeable own fields
  (reasoning_content/signature feeding native dwgx#11/dwgx#12 + sealed blob,
  name/annotations/refusal/audio) or non-text content parts are no
  longer folded — they are emitted verbatim after the interleaved pairs.
  Tradeoff, declared: a run of >=3 such strays can still chain
  same-source (pre-fix behaviour for that shape); the common Codex shape
  of one reasoning+text item per turn keeps the alternating 2,4,...,2
  wire sequence with every field intact.

Generated with [Devin](https://devin.ai)
@21hbguo

21hbguo commented Sep 16, 2026

Copy link
Copy Markdown
Contributor Author

已按评审推 bac13fe,M1 / M3.1 按参考改法落地,M3.2 有一个实现取舍先说明:

  • M1:数组 content 追加 {type:'text'} part(hasTextPart ? '\n' : ''),不压平;配套你给的用例原样进 interleaveParallelToolMessages describe,压平版必红(断言 part 对象身份)。
  • M3.1tcid && 恢复在 hasMatches 入口与逐项 findIndex 消费两处;配了无 ID 历史原样透传的用例。
  • M3.2:白名单 role/content/tool_calls + 数组 content 全文本 part 才可折叠;否则 stray 整条透传、追加在交错块末尾isStrayMergeable 判定)。

M3.2 没选「整段 run 回退透传」的原因:Codex 的 reasoning item 会被归一成带 reasoning_content/signature 的 assistant(responses.js:391-394),整段回退会让 [call, call, reasoning-stray] 回到同源 3 连的老问题——恰好是本 PR 修的客户端形状。尾部追加透传后 wire 是 2,4,2,4,2,字段一个不丢;代价是不可折叠 stray ≥3 连排仍可能连发(与该形状修复前一致),已写进描述。

node --test test/tool-emulation.test.js → 79/79。M2 的 anchor/基线按你说的留给你合并时处理。

@dwgx
dwgx merged commit dfc6731 into dwgx:master Sep 16, 2026
dwgx added a commit that referenced this pull request Sep 16, 2026
…es after #271

The consume block moved one level deeper and gained the `tcid &&` gate, so the
KNOWN SURVIVOR anchor stopped matching (spec-static-check: hits=0). This commit
re-points it at the verbatim new text and drops only the usedIndices guard in the
replacement, which is the mutation the name describes.

The same change re-measures the three specs that pin test/tool-emulation.test.js:
#271 added 7 tests to that file and all three baselines move by exactly +7.

  interleave-parallel-tool-history.json   72 ->  79
  bash-prefix-repair-boundary.json       103 -> 110
  schema-ref-fanout-budget.json           81 ->  88

Verified by injection, not by derivation: the re-anchored KNOWN SURVIVOR still
reports SURVIVED (79 pass / 0 fail), and wire-h3-reasoning-policy.json H3-M11,
re-indented by the same commit, still reports CAUGHT (3 pass / 1 fail).
dwgx added a commit that referenced this pull request Sep 16, 2026
@dwgx

dwgx commented Sep 16, 2026

Copy link
Copy Markdown
Owner

合并了(rebase-merge,你的两个提交原样落在 master:a1a468c + dfc6731)。两轮评审加一次合并,复盘一下我独立复核的合并时替你处理的,便于后来人对照。

我复核了什么(不只看你的用例)

结果
test/tool-emulation.test.js 79/79
独立探针(我另写的,不信你的用例) 10/10:数组 content 里的图存活(不被压平)、空 tool_call_id 不配对、带 reasoning_content/signature 的游离条目原样透传、单 call + 游离文本不动、非 assistant 直通、孤儿 result 留给 stripOrphanedToolResults
默认路径字节 270/270 逐字节一致(5 条预处理路径 × native/非 native × 三组 env,对拍 v3.9.34 pristine)
门禁 npm test 4330/4335(5 条是 Windows real-Git 机器门禁,非产品回归);spec-static-check exit 0

M3.2 的取舍我接受:整段回退会让 [call, call, reasoning-stray] 回到同源 3 连,确实比"尾部整条透传 + 声明代价"差。字段不丢 > 位置微调,这个判断是对的。

合并时我做的(你评论里留给我处理的两项)

  1. anchor 重指:你把 consume 块嵌深一层并加 tcid &&,打断了 interleave-parallel-tool-history.json 里那条 KNOWN SURVIVOR 锚点(hits=0)。已按逐字新文本重指,replacement 只去掉 usedIndices 守卫 —— 即突变名字描述的语义。

  2. 三条基线重测(实测,不是推导):你给 test/tool-emulation.test.js 加了 7 条,钉同一文件的三份规格全部 +7:

    spec
    interleave-parallel-tool-history 72 79
    bash-prefix-repair-boundary 103 110
    schema-ref-fanout-budget 81 88
  3. 顺带:我昨天那条 wire-h3-reasoning-policy.jsonH3-M11 也被你的缩进改动打断,一并重指。

  4. 注入验证(不是读代码猜):重指后的 KNOWN SURVIVOR 仍然 SURVIVED(79 pass / 0 fail),H3-M11 仍然 CAUGHT(3 pass / 1 fail)。

CI 说明

merge 之后第一次 master CI 是红的 —— 就是上面第 1、2 条(M2),随维护方补丁修掉;第二次 push(673457f,含规格重指 + 基线 + 记账)CI 全绿(run 35114536272)。

记账与去向

contributors.json 已记入(A 级),摘要写清了三处发现(丢图 / 空 id 配对 / 字段丢失)与你的取舍,仪表盘和落地页会渲染。这次改动默认路径零变化,随下一个 release 发布。

谢谢 —— 这是这个形状第二次有人从真实客户端那边把它顶上来,而且这次是带着 wire 证据来的。

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