Skip to content

fix(responses): normalize multimodal content in tool call output items - #265

Merged
dwgx merged 4 commits into
dwgx:masterfrom
justhil:fix/responses-multimodal-tool-output
Sep 14, 2026
Merged

dwgx merged 4 commits into
dwgx:masterfrom
justhil:fix/responses-multimodal-tool-output

Conversation

@justhil

@justhil justhil commented Sep 12, 2026

Copy link
Copy Markdown
Contributor

改了什么 / What changed

  • src/handlers/responses.js 中,将 function_call_outputcustom_tool_call_outputoutput 字段通过 normalizeMessageContent() 转译为结构化消息内容,而非直接使用 stringifyMaybe() 强行转为扁平文本(本修复针对 DEVIN_CONNECT 路径生效)。
  • normalizeMessageContent() 中增加非 content-block 数组的防护逻辑:当数组元素不包含标准 content block 特征(type 字段)时(如 ['file1.txt', 'file2.txt']),安全回退到原有 stringifyMaybe() 行为,保证通用数据结构不被丢弃。
  • nginx.conf 中为内置反向代理配置 client_max_body_size 32m;(解除 Nginx 默认 1m 瓶颈,放行至应用层)以及 900s 代理超时(作为不变式严格高于应用层 DEVIN_TIMEOUT_MS 默认 600s 阈值)。

作用范围与边界说明 / Scope & Boundary

  • 本次修复生效路径:本 PR 的多模态工具结果结构化分流主要针对 DEVIN_CONNECT 路径。转译后的结构化消息进入 src/devin-connect.js 后,多模态图像正常被 extractInlineImages 提取并分流至图像通道(#10 images)。
  • Cascade 路径现状保留:Cascade 侧目前仍维持原有行为(tool-emulation.js:1097-1099 仍会将数组形态的 tool output JSON.stringify 回文本,且 client.jsextractImages 目前仅作用于最后一条消息)。本次改动不涉及 Cascade 侧深层重构,特此明确边界,避免后续误解为全局所有分支均已重构。

为什么 / Why

在 Responses API 规范中,工具调用的结果(function_call_output.output)支持多模态内容数组(例如 [{type: 'input_text', text: '...'}, {type: 'input_image', image_url: 'data:image/...;base64,...'}])。
此前 WindsurfAPI 在 responsesToChat 阶段对 item.output 统一调用 stringifyMaybe(),导致包含 Base64 图像的工具输出被整体序列化为巨型文本字符串:

  1. 丢失了多模态图像结构,导致后续协议编码阶段无法将其提取为视觉特征(ImageData);
  2. 整个数十万字符的 Base64 字符串被当作普通提示词文本送入切词,造成 Token 数量急剧膨胀,极易触发上游上下文上限超长错误。
  3. 同时在 Docker Compose 默认拓扑中,配套的 Nginx 负载均衡器未显式配置 client_max_body_size(默认仅 1m)。任何超过 1 MB 的多模态负载在到达应用层之前即被 Nginx 拦截抛出 413 Request Entity Too Large

Nginx 参数依据与实测边界 / Nginx Parameters Rationale & Boundaries

  • proxy_read_timeout 900s; / proxy_send_timeout 900s;:注释与配置已明确为系统不变式(必须严格大于应用层 DEVIN_TIMEOUT_MS,默认 600s)。消除两层同时超时的竞态条件,确保应用层永远优先返回明确的 upstream_timeout,避免客户端偶发收到 Nginx 的 504。
  • client_max_body_size 32m; 实测边界
    • 本 PR 在 Nginx 侧的核心价值是解除 Nginx 默认 1m 的瓶颈,使合法的多模态工具结果能够穿透前置网关递交至 Node 应用。
    • 实测生效边界为 10 MB:应用层存在显式硬限制 server.js: MAX_BODY_SIZE = 10 * 1024 * 1024。因此,Nginx 侧只需配置 ≥ 10m 即可完全放行应用层支持的最大负载;收紧至 32m 既完全覆盖了应用层 10m 上限,又避免了 100m 带来的不必要攻击面与内存暴露。

测试 / Testing

  1. 单元测试
    test/responses.test.js 补充了针对 function_call_output / custom_tool_call_output 多模态结构转译及普通数组回退保护的断言:

    node --import ./test/setup-env.mjs --test test/responses.test.js

    测试结果:40 tests, 4 suites, 40 pass, 0 fail.

  2. 端到端多模态实机调用测试
    通过 Responses API 发起工具调用读取多张实际图片,模型均能精准解析图像内容,无 Token 膨胀及报错。
    对 Nginx 端口发送大体积 Base64 图像负载(5MB+),成功穿透反代进入应用层并返回 200,不再被 Nginx 413 提前截断。

Checklist

  • 代码风格和现有文件一致 / Code style matches existing files
  • 没有引入 npm 运行时依赖 / No new npm runtime dependencies (project is zero-dep)
  • 测试跑过了,贴了结果 / Tests run, output pasted above
  • 新行为有测试,且断言行为而不是 grep 源码文本 / New behaviour has tests that assert behaviour
  • commit message 没有任何 AI 署名尾注Co-Authored-ByGenerated with 等)

@dwgx

dwgx commented Sep 13, 2026

Copy link
Copy Markdown
Owner

评审:nginx.conf 那条是真缺陷,我要它responses.js 那条也成立。但两处必须先改 —— 一个是超时值会互相抵消,一个是 Cascade 侧只算半修。

head db94044,worktree 隔离。下表是相对当时origin/master = e725c7d 量的;master 之后前进了(5ec9194,含我自己的修复),合并前我会按那时的 master 再跑一遍全量

一条你该知道的:本仓库 CI 上逐文件的测试数量会在两次全绿之间漂移(同一份代码、两次 run 都绿,而 test/openai-error-vocab.test.js 一次 pass 47、一次 pass 39)。所以我下面只把"哪几条失败"当证据,不把总数当证据。

结果
全量 test/*.test.js 4171 / 4166 / 5 fail —— 与 master 基线逐字相同,零新增失败
test/responses.test.js 40/40(含你新增的两条)
secret-scan / git diff --check exit 0 / clean
突变 anchor 无漂移;test/responses.test.js 被 5 个规格覆盖,基线要跟着刷(那是我们合并时的动作,不用你管

我核过下游,你这条修的是真东西

  • devin-connect.js:1088-1116role:'tool' 也调 extractInlineImagesgetImageFieldTag() 默认 10 ⇒ 工具输出里的 data-URL 图真的会进 #10 images
  • nginx.confdocker-compose.yml:91:ro 挂载,且 nginx 是这套拓扑里唯一的前置跳数 ⇒ 未设 client_max_body_size 时 nginx 默认 1m,5 MB base64 图必然 413。这条我认。

M1(请修)— proxy_read_timeout 600s 恰好等于应用侧超时,两层会在同一刻放弃

你设的 600s 与被代理的应用默认值逐字相等DEVIN_TIMEOUT_MS 默认 600000src/devin-connect.jsintEnv('DEVIN_TIMEOUT_MS', 600000, …))。

后果是谁先谁后不确定:两个 600s 同时到点,有时应用先返回一个干净的 upstream_timeout,有时 nginx 先掐连接、客户端拿到 504 —— 同一种故障在日志里变成两种样子,运维要花时间才能看出是同一件事。

这条不需要辩论,请把 nginx 侧抬到严格大于应用侧(比如 proxy_read_timeout 900sproxy_send_timeout 900s),让应用永远是那个"决定怎么失败"的一层。这是跨层行为变更的常规要求,不是风格问题。


M2(请核)— client_max_body_size 100m 这个数字

放开方向我同意(1m 是真缺陷)。但 100m 是默认值的 100 倍,而 limit_req … burst=20 nodelay 还在 —— 也就是说限速还在,但单次体积放开了 100 倍,一次 20 个并发大请求可以在内存里同时展开。

请说一下 100m 是怎么来的:是最坏情况实测(多图 + 大 payload),还是取的整档?如果只是想"别再 413",16m32m 大概率覆盖真实场景,而给运营留的暴露面小得多。这个数我倾向由你给依据、我拍板,但你要是没有实测,我就自己定一个并在 release notes 里写明依据。


M3(请改说明)— Cascade 侧只修了一半,PR 描述里要写清

responses.js 这条只覆盖 DEVIN_CONNECT。Cascade 侧结构性还是老样子

  • tool-emulation.js:1097-1099 / :1111-1113 会把数组形态的 content 直接 JSON.stringify 回文本,base64 仍然进提示词;
  • client.js:767/795/842extractImages 只作用于最后一条消息

这不是要你在本 PR 里修 —— 是要你在描述里把影响面说成**"Connect 侧的修复"**,别让后来的人以为图像分流已经在所有路径上生效。本仓库这类"只修三维之一"的缺陷已经发生过 6 次,写清边界能省下一轮排查。


下一步

M1 改数值、M2 给依据(或接受我来定)、M3 改一句描述,改完 ping 我,我重跑全量 + 对抗验证再合。

@justhil

justhil commented Sep 13, 2026

Copy link
Copy Markdown
Contributor Author

已按评审意见全部修改并推送到 PR 分支(commit 4e11c4a):

  1. M1 (超时层级优化):已将 nginx.conf 中的 proxy_send_timeoutproxy_read_timeout 提升至 900s,严格大于 DEVIN_TIMEOUT_MS 默认的 600s,确保两层不会同时超时产生竞态,由应用层先行处理失败并返回干净的 upstream_timeout
  2. M2 (Payload 依据与收紧):100m 原为本地未压缩 4K 极限测试时设置的粗糙上界。经评估在 burst=20 nodelay 并发场景下的内存与攻击面,已直接收紧至 32m。32m 足以覆盖真实多图工具输出场景(常见 1080p/2K 截图经 Base64 编码后约 24MB,单次携带 58 张图约 15~25MB),同时大幅降低并发高负荷下的暴露面。
  3. M3 (明确影响边界):已在 PR 说明正文中新增「作用范围与边界说明」章节,明确标注本次多模态分流主要针对 DEVIN_CONNECT 路径生效;Cascade 侧(tool-emulation.jsclient.js)维持现有结构性行为未作改动,避免后续维护者产生全局分支均已重构的误解。

单元测试 40/40 持续全绿通过。请重跑测试与对抗验证

@dwgx

dwgx commented Sep 13, 2026

Copy link
Copy Markdown
Owner

复核:三条我都核过,都成立responses.js 那条我攻不动。但 32m 这个数字对、它的理由不对 —— 我把实测边界给你,因为按现在的理由写,下一个维护者会以为这条路能过 15–25 MB。

head 4e11c4a,worktree 隔离:

结果
nginx.conf:37/47-49 client_max_body_size 32mproxy_connect_timeout 60sproxy_send_timeout 900sproxy_read_timeout 900s
test/responses.test.js 40/40 —— 与你自陈一致
全量(你这棵树) 4171 / 4166 / 5 fail,失败的文件正好是那 5 个已知的 Unix-git 机器闸(数量比当前 master 少是我这轮往 master 合了东西,不是你的问题)
轻门 node --check / check-i18n / secret-scan 全 exit 0

M1 成立 ✓(但那个数字是不变式,不是常量)

DEVIN_TIMEOUT_MS 默认是 10 * 60_000 = 600s(devin-acp.js:17special-agent.js:86),900s > 600s,层级关系正确,连接超时也补上了。

一处建议:它是 intEnv('DEVIN_TIMEOUT_MS', 600000, 1000) —— 运维可改。所以 900s 只在默认值下成立;有人把它调到 900000 以上,竞态就回来了。建议把 nginx.conf 那句注释写成一个不变式而不是一个裸数字:

# 必须严格大于应用层 DEVIN_TIMEOUT_MS(默认 600s)。改任意一边都要重新核对。

M2 数字对、理由不成立 —— 这条请改

32m 我认可,但它不是因为"58 张图约 1525 MB"。应用层有自己的上限:

src/server.js:50   export const MAX_BODY_SIZE = 10 * 1024 * 1024;
src/server.js:89   if (size > MAX_BODY_SIZE) → 413 ERR_REQUEST_BODY_TOO_LARGE

所以:

  1. nginx 只需要 ≥ 10m,32m 满足,这条改动是对的。
  2. 但这个 PR 真正修的是:nginx 的 client_max_body_size 默认只有 1m,所以任何 >1 MB 的多模态请求在 nginx 就被挡掉、根本到不了应用。这才它的价值 —— 而在这个意义上 32m100m 几乎没有区别(都 ≥10m),所以你收紧到 32m 是净收益(攻击面小了,功能没少)。
  3. 你写的理由做不到:15–25 MB 的载荷现在会被应用层的 10 MB 挡掉 —— 413 从 nginx 换成了应用,请求仍然失败。这个 PR 不会让那条路通过。

如果目标真是 15–25 MB,那要动的是 MAX_BODY_SIZE,而那是一件比 nginx 大一档的事:server.js:86-96 是把整个 body 收进 chunks 数组的,提高它会按并发连接数线性放大内存 —— 正是你为 nginx 收紧时考虑的那件事。建议不要在 nginx 这个 PR 里做,另开一个并带上内存估算。


responses.js 我攻不动 ✓

我把函数整段读了(不是只看 diff),两道守卫都在 .some 之前,没有崩溃路径

if (typeof content === 'string') return content;
if (!Array.isArray(content)) return stringifyMaybe(content);
const hasContentBlock = content.some(...)   // ← 到这里 content 一定是数组

字符串 part 转成 { type: 'text', text }input_image 走既有的 H-1 归一化(:56-62),function_call_output / custom_tool_call_output 两处都换成了它,测试也覆盖到了。这一半可以直接合。


下一步

只剩 M1 那条注释改成不变式 + M2 的理由改成实测边界(一句话的事,不用改代码)。改完 ping 我,我合。

@justhil

justhil commented Sep 13, 2026

Copy link
Copy Markdown
Contributor Author

感谢指正!应用层 server.js 里的 MAX_BODY_SIZE = 10MB 边界确实是我此前未全面审计到的盲区,这个实测边界澄清非常关键。

已按意见完成最后一轮修正并同步推送:

  1. M1 (不变式注释):已将 nginx.conf 超时配置的注释更新为不变式说明(commit fc8c183):
    # 必须严格大于应用层 DEVIN_TIMEOUT_MS(默认 600s)。改任意一边都要重新核对。
  2. M2 (修正依据与实测边界):已更新 PR 描述中的依据阐述。明确本 PR 的核心收益是将 Nginx 默认的 1m 解除至 ≥ 10m,从而允许合法多模态负载无损进入应用层;当前真实生效上限受限于应用层的 MAX_BODY_SIZE(10MB)。32m 作为前置阈值既完整放行了应用层上限,又约束了极端内存暴露。

目前 PR 描述、代码注释与 40/40 单元测试均已就绪,请查阅合入,辛苦!

@dwgx
dwgx merged commit 1b1d0e5 into dwgx:master Sep 14, 2026
7 checks passed
dwgx added a commit that referenced this pull request Sep 14, 2026
@dwgx

dwgx commented Sep 14, 2026

Copy link
Copy Markdown
Owner

评审:最后两条都到位。注释写成了不变式,正文把真上限改成了 MAX_BODY_SIZE 10 MB。已合(merge commit 1b1d0e5)。

head fc8c183

结果
nginx.conf 32m / connect 60s / send·read 900s,注释是不变式
test/responses.test.js 40/40,无 --test-force-exit
CI 34790142853 6/6 success

合完按实测刷了 4 个覆盖 responses.test.js 的突变基线各 +2(47→49 / 44→46 / 57→59 / 42→44)。Cascade 侧仍是你写的边界,没在这个 PR 里动。

dwgx added a commit that referenced this pull request Sep 14, 2026
User-visible: consecutive same-role Connect text no longer trips run>=3 invalid_argument (#270); Responses tool-output images reach Connect tag 10 and docker nginx is 32m/900s with the real cap still MAX_BODY_SIZE=10MB (#265); local import finds Devin desktop userData and a 160KB windsurfAuthStatus is no longer silently skipped (#264); swe-2 is in the Connect catalog and gpt-5.6 Local is opt-in (#266).

No API breakage. ACU ^22 still default off. FREE_TIER_SELECTOR still swe-1-6-slow. Mutation specs stay 49/588; baselines were remeasured at merge.
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