# 图片轮次读图工具隔离守卫 ## 已知故障 2026-08-22 服务号用户唐连发 4 张图后要求「把那几张图片做成主题页面」,连续三次没有拿到页面。 链路是这样断的: 1. `attachRecentMediaForFollowup` 正确把 4 张图挂到了这一轮,Qwen VL 也成功产出了图片描述, 并注入进 Agent 提示。 2. Agent 拿到描述后,仍然逐张调用 `read_image` 去「确认图片内容」。 3. `read_image` 的工具结果不是 `image_url`,而是嵌在 `toolResponse.toolResult.value.content[]` 里的 base64 `image` 块,4 张图累计 1.5MB 以上。Goose 会把它们持久化进会话历史。 4. 下一次请求把整段历史发给纯文本聊天模型(DeepSeek),上游直接返回 `unknown variant image_url, expected text`,Agent 还没走到 `write_file` 就中断。 5. 页面交付是 fail-closed 的:没有新落盘的 HTML,就不会发占位链接,用户只看到 「这次页面没有按服务号页面技能真正生成成功」。 原有的两道防线都没拦住: - `conversationHasImageUrlContent` 只扫 `image_url`,看不见 `read_image` 留下的工具图片块, 所以微信侧的会话轮换不会触发,同一个被污染的会话被反复复用。 - `agent-run-gateway` 的 `SESSION_VISUAL_CONTEXT_UNSUPPORTED` 视觉降级是**事后**补救, 只覆盖 H5 Agent Run,服务号回复路径没有对应保护。 ## 必须保留的行为 1. 本轮消息带图且已配置图片模型(`llmProviderService.hasVisionKey()`)时, `prepareSessionReplyBody` 必须以 `disableImageReading` 下发会话策略, 使这一轮的 `developer` 扩展不含 `read_image`。视觉理解由图片模型独占, 聊天模型不承担读图。 2. 不带图的轮次必须保留 `read_image`。禁用是按轮次生效的,不能把会话永久降级。 3. `buildVisionPayload` 注入的提示必须明确写出「本轮不会再提供读图工具 + 禁止调用 `read_image`」,并说明强行读图会让本轮及后续请求全部失败。 4. 图片模型没能产出描述时,提示必须改口为「不要臆造画面细节」,不能继续声称 「依据上面的描述写文案」。此时仍然禁用读图 —— 纯文本模型看不到图, 放开 `read_image` 只会让整轮崩掉,而图片嵌入路径依然可用。 5. `conversationHasToolImageContent` 必须能识别 `toolResponse` / `tool_response` 里 `toolResult.value.content[].type === 'image'` 的工具图片块, 并覆盖 `toolResult` / `tool_result` / 直接数组等结构变体。 6. `rotateWechatSessionIfImagePolluted` 必须同时检查 `image_url` 污染和工具图片污染, 任一命中都要在回复前换掉会话。历史遗留的被污染会话靠这条自愈。 7. `agent-run-gateway` 的事后视觉降级(`SESSION_VISUAL_CONTEXT_UNSUPPORTED` → 新会话 + `disableImageReading: true`)保留为兜底,不得因为新增事前预防而删除。 ## 改动前必跑 ```bash node --test tkmind-proxy.test.mjs chat-image-turn-scope.test.mjs wechat-mp.test.mjs agent-run-gateway.test.mjs npm run verify:h5-session-patches ``` 端到端(需本地 Portal 8081 + goosed): ```bash node scripts/run-scenario-test.mjs --scenario image-theme-page ``` 该场景上传 4 张几何图、要求做成主题页面,断言页面生成、公网 200、4 张图全部嵌入, 并禁止回复里出现 `unknown variant`。2026-08-22 首次通过时会话内 `read_image` 调用为 0、 持久化工具图片块为 0,工具序列是 `load_skill → write_file → read_file`。 ## 相关代码与用例 | 位置 | 作用 | |------|------| | `tkmind-proxy.mjs` · `prepareSessionReplyBody` | 事前禁用本轮 `read_image` | | `tkmind-proxy.mjs` · `buildVisionPayload` | 注入禁止读图的硬性提示 | | `chat-image-turn-scope.mjs` · `conversationHasToolImageContent` | 识别 `read_image` 工具图片污染 | | `wechat-mp.mjs` · `rotateWechatSessionIfImagePolluted` | 回复前轮换被污染会话 | | `capabilities.mjs` · `withoutSessionImageRead` | 从会话策略里摘掉 `read_image` | | 用例 | 覆盖 | |------|------| | `tkmind-proxy.test.mjs` · `image turns drop read_image so vision results cannot poison the text provider` | 带图轮次下发的 `developer` 工具不含 `read_image` | | `tkmind-proxy.test.mjs` · `text-only turns keep read_image available` | 纯文本轮次不降级 | | `tkmind-proxy.test.mjs` · `submitSessionReplyForUser applies the shared Qwen vision preprocessing path` | 提示中包含禁止读图约束 | | `chat-image-turn-scope.test.mjs` · `conversationHasToolImageContent detects read_image base64 poison` | 工具图片块识别,且 `image_url` 扫描确实看不见它 | | `wechat-mp.test.mjs` · `wechat mp rotates a session poisoned by read_image tool results` | 服务号在工具图片污染时换会话 |