fix(tools): absorb stray assistant text inside parallel tool-call runs - #271
Conversation
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>
|
评审:机制我逐条驱动过,你声明的两个 wire 序列在本机逐位复现( 我实跑过什么head
根因判断我核过,成立:旧实现的 run 收集要求单条消息 M1(请修)—— 折叠把数组 content 压平成字符串,非文本 part 静默丢失emulation 路径( 来源是你新增的 这不是方向错误,是坐标系问题:同一天 最小改法(保留原 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 是 M2(合并时我来,不是你的锅)—— anchor 与基线漂移
M3(请补进描述,或顺手加守卫)—— 两处未声明的放宽
诊断对、方向对、wire 证据成立。补齐 M1 的守卫与用例,并给 M3 一个取舍说法,我重跑全量 + 突变门后进合并序列。若你想要,我可以把这两条(main 层折叠 + 边界用例)整理成一份参考补丁放到评论里 —— 直接说一声即可。 |
|
补上前一条说的参考改法。不强制 —— 你按自己的实现改也行,这里只是把两个最小改动摆出来,省得来回。 M1:数组 content 只追加,不压平替换折叠里从 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 };
}配套用例(放进 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 的历史 —— 时间线你按自己的节奏改。如果这两天不方便推,我按 #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)
|
已按评审推
M3.2 没选「整段 run 回退透传」的原因:Codex 的 reasoning item 会被归一成带
|
…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).
|
合并了(rebase-merge,你的两个提交原样落在 master: 我复核了什么(不只看你的用例)
M3.2 的取舍我接受:整段回退会让 合并时我做的(你评论里留给我处理的两项)
CI 说明merge 之后第一次 master CI 是红的 —— 就是上面第 1、2 条(M2),随维护方补丁修掉;第二次 push( 记账与去向
谢谢 —— 这是这个形状第二次有人从真实客户端那边把它顶上来,而且这次是带着 wire 证据来的。 |
改了什么 / What changed
interleaveParallelToolMessagesnow collects all consecutive assistant entries in a run — call-bearing ones intoassistantMsgs, text-only ones intostrayTexts— 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 withinvalid_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]againstswe-2-max:response.failed(upstream internal error) 3/3 attempts; wire dump shows source sequence1,2,2,2,4,4,1response.completed3/3; wire sequence1,2,2,4,2,4,1— max same-source run is 2[call, call, call, result, result, result](no stray text) still interleaves2,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 0Two 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)
中文:
content时不再压平成字符串,改为追加{type:'text'}part——image_url等非文本 part 在 wire 上是独立编码类型,压平会让extractInlineImages(Array.isArray门槛)丢图。与上游isMergeableText同一条规则。已配会红的用例(断言 part 对象身份 + 调用方数组不被改)。tcid &&守卫(hasMatches入口 + 逐项findIndex消费)。空tool_call_id两侧相等不再构成交错——native 编码对缺失 call id 生成 UUID、空 result id 不进 role=4 分支,两个空串相等不等于调用归属。配了无 ID 历史原样透传的用例。role/content/tool_calls,且数组 content 全为文本 part)时才折叠;携带reasoning_content/signature/name/annotations等字段的 stray 不折叠,整条透传、追加在交错块末尾。取舍:字段一个不丢、wire 保持2,4,…,2交替;代价是不可折叠 stray ≥3 连排时仍可能触发同源连发(与修复前该形状行为一致)。Codex 每回合常见形状(一条 reasoning+text)不受影响。EN:
contentnow appends a{type:'text'}part instead of flattening — non-text parts encode as distinct wire types and flattening lost them beforeextractInlineImages. Same rule as upstreamisMergeableText. Red-guarded test asserts part object identity + caller-array immutability.tcid &&guards restored at thehasMatchesentry and the per-callfindIndexconsume — empty ids on both sides no longer constitute call ownership.role/content/tool_calls) and its content is text-only. Strays carryingreasoning_content/signature/name/annotations/non-text parts are emitted verbatim after the interleaved block — no field is silently dropped and the wire keeps alternating2,4,…,2. Accepted cost: ≥3 consecutive unmergeable strays can still form a same-source run (identical to pre-fix behaviour for that shape).