Repository navigation
Description
What happens
A request that reaches an upstream with a non-tool item between a batch's tool calls and one of its results is refused by every strict upstream. Codex writes such an item itself: when a tool returned an image it resized, its <image_resize_notice> goes between the two results of one assistant step.
Reported to Codex and opencode first, one assistant step with view_image and exec_command:
function_call view_image call_00_hC0DonBjeKHawzABcyLL1642
function_call exec_command call_01_mNySew0rARWuQYQXPeIY2319
function_call_output call_00_... (the image, 1024x9000)
message (developer) <image_resize_notice>…</image_resize_notice>
function_call_output call_01_... (the command's output)
Every call in that body has its result. Relayed as is:
- Volcengine Agent Plan (
deepseek-v4.1-flash) answers 400An assistant message with 'tool_calls' must be followed by tool messages responding to each 'tool_call_id'. (insufficient tool messages following tool_calls message) - OpenCode Zen/Go (
deepseek-v4.1-flash) answers 400No tool output found for tool call <id>— and only for an interleave their gateway doesn't recognize: with this notice item they answer 200, with any other text there 400. The original report (request_id 01271f1e-0095-4424-9a58-7ccb17b37182) is one of these two shapes.
The chat path has mended this since #1275 (pairToolMessages closes a run, interrupted ones included, before another message) and #1341 mended the missing-result half of it for Responses (pairToolItems). The Responses relay itself still passes an interrupted run through.
The change
joinToolRuns, beside orphanedToolOutputs and pairToolItems in the normalization attempt runs for a relayed Responses request (internal/gateway/responses.go, internal/gateway/gateway.go): an item that sits inside a run of tool calls and results is moved to the end of that run. A run is open while one of its calls has been seen and the result answering it is still ahead, so an item between two whole exchanges stays where it is; a request with no tool items, one whose exchanges are each whole, and a body that isn't a Responses request come back byte for byte. It runs before the request is relayed or translated, so every destination whose own form is strict gets it.
The item's own bytes, its role and its order among the other non-tool items are as they were; a native ChatGPT (Codex account) destination is left as it came.
One expectation changed on purpose: TestGrokGetsAgentMessageAsUser's turn holds a subagent's reply between the send_message call and its output, so Grok is now given that reply after the run. Its conversion is unchanged (same user message, same From <author> to <recipient> header, same text and images) and only its place moved; the community plugin (packages/grok) converts in place the same way. Narrow the rule to messages other than agent_message if Grok's backend needs the reply in place.
The patch
The repository's PR policy is collaborators_only, so this couldn't be sent as a PR (GitHub answers Edward4226 does not have the correct permissions to execute CreatePullRequest). The change is on a branch of my fork instead:
- branch
codex/keep-tool-runs-together—03626b2e, rebased ontod0c587ae - patch: https://github.com/Edward4226/magpie/commit/03626b2e6034f020feeb260157cb808e6d5fbe21.patch
- diff against main: main...Edward4226:codex/keep-tool-runs-together
git fetch https://github.com/Edward4226/magpie.git codex/keep-tool-runs-together && git cherry-pick 03626b2e applies it — cherry-picked onto origin/main fd035923 with no conflict; the later internal/gateway changes do not touch the files this patch edits.
What was run
-
go test -tags nogui ./internal/gateway/— 2 failures, both red onorigin/main6c1c0a7 as well and neither from this change:TestClaudeAsRootEnv(a Linux-only expectation with no Windows guard) andTestSweepBridgeProjectsTakesOnlyTheBridgesFolders(needs a symlink privilege this Windows box doesn't hold). Everything else passes,TestQueuedStreamKeptAliveOnceSentToItsVendorincluded (it failed once on main under load — the queued-stream timing the standards name). -
the same run under a temporary HOME with
GOPATH/GOMODCACHE/GOCACHEpinned fromgo env: the same two failures. -
gofmt -lon the four changed files: nothing.go vet ./...,GOOS=linux go vet -tags nogui ./...,GOOS=windows go vet ./...: clean.go build,GOOS=windows go build,GOOS=linux go build -tags nogui: ok. -
without the change (the one call-site line taken out)
TestResponsesRelayKeepsAToolRunTogetherprintsstatus 400: {"error":{"code":null,"message":"Fake: An assistant message with 'tool_calls' must be followed by tool messages responding to each 'tool_call_id'.",…}}; with the line back it passes. -
real upstreams, the shape above, the same build with the line and without it, streamed the way Codex sends it:
upstream / model run whole notice between the results any other note there Volcengine Agent Plan / deepseek-v4.1-flash200 → 200 400 → 200 400 → 200 OpenCode Zen/Go / deepseek-v4.1-flash200 → 200 200 → 200 400 → 200 -
the reporter's own rebuilt turn (1399 items, from their rollout) to Volcengine: refused
insufficient tool messages following tool_calls messagewithout the change, past that check with it (what is left is the input's length, 1.7M tokens, over the model's limit).
Not tried
- A real Grok subscription: the reply's new place is unverified against xAI's backend.
- The OpenCode Zen/Go 400 of the original report is not reproduced by the reporter's own bytes — with both results and the notice between them that gateway answers 200 before and after the change, so that refusal may be Codex's follow-up body assembled before the batch's last output was in it (Next sampling request is sent before the last tool output of a batch is appended, so the provider rejects it with "No tool output found for tool call X" and the thread stays wedged openai/codex#44604). What this change removes is the 400 class two other real upstreams reproduce on the same shape.
- A real ChatGPT (Codex account) destination, and the Windows GUI.
Refs: #1275, #1341. Reported as anomalyco/opencode#54698, openai/codex#44604.
Activity
Edward4226 commentedon Oct 12, 2026
更新一下基线和验证,方便直接
git am/ cherry-pick。这个补丁做什么
在 Responses 转发之前,把"一批工具调用 + 它们的返回"里被夹进去的非工具项(Codex 自己在图片被缩放时会插
<image_resize_notice>)移到这串的末尾,严格上游就不再把它后面的 output 判成缺失。这与 #1341(b3330ea8)修的是同一个问题的互补形状:#1341 管"整个 input 里都没有结果",这里管"结果在、只是被夹开"。这个形状的独立报告在 #1822(call_00输出 → notice →call_01输出 →No tool output found),上游 deepseek-ai/DeepSeek-V3#1588 同型。为什么是这个形状(含考虑过的替代做法)
它和 #1341 里的
pairToolItems、以及 chat 路径的pairToolMessages(#1275,internal/gateway/chat.go:522)是同一条规则:一次工具交换当作一个不可分割的 run,先闭合,再让别的 item 排在后面 —— chat 路径早就是把一次交换的答案攥住、等它闭合才一起输出,夹在交换中间的空 assistant 消息甚至直接丢弃;你 b3330ea 里把合成结果放到整串末尾,用的也是这条规则。joinToolRuns只在"调用确实有结果、却被非工具项隔开"时才移动那条项;没隔开就返回原字节,也不动调用与结果的相对顺序。它放在attempt已有的那个归一化点(gateway.go:2910的if)、在 relay/translate 分叉之前,所以两个方向都吃到同一个形状。考虑过、没选的两个:① 只把
<image_resize_notice>折进它所描述的那条 output —— 顺序完全不动、notice 仍紧贴图片,但只对"描述前一条输出"这类条目成立,夹进 reasoning 或真消息就不适用;② 被 400 拒绝后才归一化重试(refusesInput/bareReasoningRefused那套)—— happy path 一个字节不动,但要认两种不同的 400 文案,而且那套重试写在passthrough里、只覆盖 relay 路径,translate 方向够不着。如果更希望 item 完全不移动,我可以改成 ①;如果更希望少一个独立 pass,也愿意把它折进
pairToolItems/orphanedToolOutputs做一次遍历(行为不变)。可直接采纳
在
fd035923上零冲突(正文里"main 的两个新提交只动 gui/provider"那句已过时:其后 5 个提交动过internal/gateway的 cors / decide / codex_backend,未碰本补丁改的三个文件)。也可以直接用下面折叠块里的 format-patch。验证
把
gateway.go:2911的body = joinToolRuns(body)删掉即复红:真实上游 A/B(同一构建,只差那一行;列为"整串不动 / notice 夹在两条结果之间 / 任意文本夹在那里"):
deepseek-v4.1-flashdeepseek-v4.1-flash边界
attempt里已有的那个if(gateway.go:2910,from == provider.Responses && (p.Account == nil || p.Account.Agent != "codex")),即"发给非原生 ChatGPT 账号的中继 /responses"。grok_agent_message_test.go的TestGrokGetsAgentMessageAsUser—— 子代理那条消息从send_message调用与结果之间移到整串之后;转换出的内容不变(同一条 user 消息、同样的From … to …头、同样文字与图片)。若 Grok 后端需要它在原位,把规则收窄到非agent_message即可,说一声我来改。本补丁不声明修的东西
原始报告里那次 OpenCode Zen/Go 的 400,用报告者自己的字节复现不出来(两条结果都在、notice 夹中间,改前改后都 200),可能属 Codex 自己 follow-up 请求体早于该批最后一个 output 组装(openai/codex#44604)。可复现的依据是 #1822 的序列与上面的 A/B。
两个可选项
joinToolRuns要不要折进orphanedToolOutputs/pairToolItems,合成一个"工具串归一化"的 pass?名字也可换。docs/subsystems/gateway-routing.md要不要我补一条(挨着pairFunctionTurns/ gateway: 被打断的工具调用在 Gemini/Vertex/Code Assist 上游得到合成结果,而不是 400 #1560 那条),还是你合并时自己写?补丁全文
03626b2e(git am可直接采纳):03626b2 — an item between a batch's tool calls and their results.patch
gateway: joinToolRuns leaves the model's own step and every call-carr…
yetone commentedon Oct 12, 2026
谢谢 @Edward4226,补丁已合入 main,作者保留为你:
03626b2e原样 cherry-pick(joinToolRuns,含 Grok 那条断言的调整)。call_id的项一律不动,不管 type。原版只认function_call/custom_tool_call/tool_search_call,夹在 run 里的local_shell_call之类会被挪到结果后面(连同它自己的 output 一起错位)。[call_a, reasoning, call_b, out_a, out_b]里的 reasoning 挪到结果之后,变成下一步的开头——对 DeepSeek(每步必须以 reasoning 开头,f4cc7459 的reasonFirst)会造出一个不对的步。其他项(子代理的agent_message、notice 等)照旧挪到 run 之后,所以 Grok 那条断言维持你的改法。核对过的地方
core/src/image_preparation.rs的prepare_response_items在每个缩放过图片的function_call_output/custom_tool_call_output之后紧接着 push developer<image_resize_notice>,所以一批两个图片结果之间必然夹着它,和 codex报错OpenCode Go: The reasoning_text in the thinking mode must be passed back to the API #1822 rollout 第 347–349 行一致。joinToolRuns在attempt里、在所有目的地分叉之前,Responses 直转、插件(走passthrough)、翻译到 Chat / Anthropic / Gemini 都会经过。翻译路径本来对 developer / user 项是安全的(developer 被提到 system,user 轮合并且结果排前),但 assistant 消息夹在两个结果之间时,Chat 翻译的pairToolMessages会给第二个调用补一个 "unavailable" 的假结果、真结果变成 user 文本——这个现在也一起修了(TestTranslatedToolRunKeepsItsRealResults,去掉调用点即红)。TestRelayedCodexImageBatchKeepsItsResultsTogether用的是 codex报错OpenCode Go: The reasoning_text in the thinking mode must be passed back to the API #1822 里的真实 call_id 和形状(两个并行view_image、图片结果、notice 夹中间),fake 按 DeepSeek 的规则回 "No tool output found for tool call …";去掉调用点即 400。实测(DeepSeek 官方
api.deepseek.com/v1/responses,deepseek-v4-flash)No tool output found for tool call call_01_x2<image_resize_notice>原文,直连(文本结果)和你在 OpenCode Zen/Go 上看到的一样:DeepSeek 认得 notice 这一条,所以纯文本结果下复现不出 #1822 那次 400;报告者的是图片结果加很长的历史,我这边没能复现那一条。OpenCode Go 我这个账号的工作区没开 Global 区域,所有请求在到模型之前就被拒,没测成。Volcengine、真实 Grok 也没测。
还没发版(按规矩等 CI 绿了再一起打 tag)。issue 先开着,等 #1822 的报告者升级后确认。
docs/subsystems/gateway-routing.md也补了一条,不用你再写了。yetone commentedon Oct 12, 2026
@Edward4226 v0.1.1217 is out (check for updates in magpie) and includes your patch 5b7112f (authored by you) and da085e3 (co-authored): an item interrupting a batch's tool results goes after them, so strict Responses upstreams stop refusing the relayed request. Please try it and let us know here: we'll close this once it works for you, or keep going if it doesn't.
Edward4226 commentedon Oct 13, 2026
已在 v0.1.1222(Windows,2026-10-13)上对真实 OpenCode Go 上游复测通过。
复测形状就是这里的原形:一批两个
function_call、两条结果之间夹一条非工具项,POST /v1/responses,model: opencode-go-2/deepseek-v4.1-flash,stream: false:a plain note)No tool output found for tool call call_01_b40d25529-dc0f-4f2e-9d38-466f54027399)<image_resize_notice>e383fbaf-5691-4330-ad60-615eb876cd70)(复现时
max_output_tokens: 16,响应是被截到 reasoning-only 的incomplete;这里核对的是 400 那一类,请求体与 10-12 的 A/B 逐字节相同。)#1822 里报告者补的第二个报错(resize notice 夹在两条
view_image结果之间)是同形状,那边的最小复现体在 v0.1.1222 下也全部 200,已在 #1822 一并说明。谢谢 cherry-pick(5b7112fe2)和 review 后的收紧(da085e3f7,带
call_id的项不动、首个结果前模型自写的文字不动)。这条确认完毕,关闭。gateway: the model's text between a batch's tool calls goes ahead of …