Files
memind/docs/regression-guards/vision-turn-read-image-isolation.md
T
john b93c92a3e2
Memind CI / Test, build, and release guards (push) Failing after 14m27s
test(scenario): cover multi-image page generation end to end
The read_image poisoning fix had no end-to-end guard because scenarios could
only send text. Add an upload_images step that renders and uploads visually
distinct images, let chat steps attach them the same way the WeChat channel
does, and assert the generated page actually embeds every upload.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-22 10:50:23 +08:00

80 lines
4.8 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 图片轮次读图工具隔离守卫
## 已知故障
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` | 服务号在工具图片污染时换会话 |