MoonshotAI / MoonshotAI/kimi-code
被 UserPromptSubmit 钩子拦下的 prompt 不过图片格式闸门,坏图原样写进会话历史
Nobody has claimed this yet.
- Dominant language
- TypeScript
- Stars
- 7.5k
- Forks
- 1.2k
- Avg merge
- 11h 53m
- Merged PRs (30d)
- 350
Description
版本 / 环境
- 版本:
main@6b72345f(2026-08-15);已核对 release 0.34.0 / 0.35.0 / 0.36.1(最新)的同一文件,均是同一段代码。agent-core-v2 引擎(当前默认引擎);自 #1626 引入 v2 图片闸门起就存在 - 平台:macOS(
Darwin 25.5.0 arm64) - 模型 / 开放平台:与具体模型、平台无关——问题出在引擎把用户消息写进历史这一步;"之后每一轮的后果"部分与 provider 方言有关,见下文
现象
自 #1626(v2 引擎的图片格式闸门,对应 v1 的 #1536)起,每个 prompt 入口在把图片写进会话历史前都会过一次 gateImageFormatParts:格式损坏或 provider 不接受的图片会被换成 [Image omitted: …] 文字说明,被接受的图片按规范形式转发(image/jpg → image/jpeg、按字节嗅探纠正 MIME)。#1626 写明"every ingestion point enforces it … prompt step requests drop them for a text notice",#1536 也把 prompt/steer 这一层称为 SDK/RPC 路径的"last-funnel"兜底——客户端送来一张坏图也不能把会话搞坏。
这道兜底漏了一条分支:当 UserPromptSubmit 钩子把一条 prompt 拦下时,调度器不走 step request,而是自己把用户消息写进历史(AgentPromptService.appendPrompt),这次写入没有过闸门。于是被拦下的 prompt 会带着原始的 image_url part 留在历史里,而同一条 prompt 不被拦时是会被过滤掉的。
复现步骤
同时满足两点即可:一条会拦 prompt 的 UserPromptSubmit 钩子;一张闸门本来会改写的图片,从"引擎闸门是唯一一道闸"的入口进来。
-
~/.kimi-code/config.toml:[[hooks]] event = "UserPromptSubmit" command = "sh /path/to/block.sh" # 内容:echo "images are not allowed here" >&2; exit 2 -
kimi web启动后,通过 REST 发一条带坏图的 prompt(source.kind: "url"的data:URL 在边缘不做检查,只按 http(s) 扩展名过滤):curl -X POST http://127.0.0.1:<port>/api/v1/sessions/<sid>/prompts \ -H 'content-type: application/json' \ -d '{"content":[{"type":"text","text":"what is in this image?"}, {"type":"image","source":{"kind":"url","url":"data:image/png,not-a-base64-payload"}}]}'返回
status: "blocked"(钩子拦下,这是预期内的)。 -
GET /api/v1/sessions/<sid>/messages,看那条用户消息:{"role":"user","content":[{"type":"text","text":"what is in this image?"}, {"type":"image","source":{"kind":"url","url":"data:image/png,not-a-base64-payload"}}]}原始的坏 URL 原样进了历史,并已落盘到
sessions/…/agents/main/wire.jsonl。 -
对照:去掉
[[hooks]],同一条 prompt 存进历史的是{"type":"text","text":"[Image omitted: \"data:image/png,not-a-base64-payload\" is not a valid data URL (its header or payload could not be parsed). Re-encode the image as PNG or JPEG and try again.]"}
同样能触发的还有:kind: "url" 带 data:image/bmp;base64,…(不支持的格式);kind: "base64" 带 media_type: "image/jpg" 且图片小到不触发压缩(别名 MIME 原样到引擎,正常路径会规范化成 image/jpeg,被拦路径不会);以及 SDK / klient 通道(@moonshot-ai/kimi-code-sdk 的 session.prompt([{type:'image_url', …}]) 直达 promptService.submit,VS Code 扩展走的就是这条——其文件选择器按扩展名标 MIME、不嗅探字节,一个扩展名与内容不符的文件会以 data:image/png;base64,<AVIF 字节> 发出)。
不受影响的入口:TUI 粘贴(魔数嗅探只认 PNG/JPEG/GIF/WebP)、ACP(提交前先过闸)、kimi -p(纯文本)、kimi web 内置 web UI(走文件上传,服务端按字节嗅探)。
期望行为
被钩子拦下的 prompt 写进历史时,应与正常路径经过同一道闸门:坏图 / 不支持的格式变成 [Image omitted: …] 文字说明,被接受的图片以规范化后的形式存储。钩子只应决定"这一轮调不调模型",不应改变消息落进历史的形态。
补充信息
根因:packages/agent-core-v2/src/agent/prompt/promptService.ts 的 appendPrompt 直接 context.append 原始 message.content;正常路径的 UserMessageStepRequest(promptStepRequests.ts)在构造函数里对 content 调了 gateImageFormatParts。两条写入路径逻辑重复,其中一条漏了这一步。
漏进去之后会怎样(实测,Anthropic 方言;用一个只记录请求体的假 provider,无需真实 key):这条消息之后每一轮都会随历史发出。请求 converter 会直接抛 Invalid data URL for image …(别名 MIME 则是 Unsupported media type for base64 image: image/jpg),引擎的图片格式恢复随即把本轮请求里所有图片——包括用户后来正常发的好图——换成 [image omitted for provider compatibility; re-read the file …] 后重发;引擎日志可见 provider rejected an image in the request; resending with rejected media stripped。这个恢复按 turn 记录,之后每一轮都先在引擎内失败一次再剥图重发,直到会话被清空。结果是模型在这个会话里再也"看不见"任何图片,而用户没有任何提示能把它和几轮前被钩子拒掉的那条消息联系起来。OpenAI 兼容方言不做客户端校验、URL 原样发给 provider,能否恢复取决于 provider 的 400 文案是否命中内置的启发式正则,否则该轮直接失败、之后每轮都失败。
无论哪种,都严格差于正常路径——正常路径下坏图变成一句无害的文字,好图始终可见。
修复:在 appendPrompt 写入前对 content 过同一道 gateImageFormatParts(一行对齐),并把防空判断改成基于过滤后的 content;配套回归测试(promptService.test.ts,在 main 上失败、修复后通过)。PR 随本 issue 一并提交。
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with packages/agent-core-v2/src/agent/prompt/promptService.ts and inspect appendPrompt, then compare it with the content handling in promptStepRequests.ts. Run the regression coverage in promptService.test.ts; done means a hook-blocked prompt stores the same filtered and normalized image content as the normal prompt path.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- typescript
- Domain
- backend
- Issue type
- Bug
- Difficulty
- 2/5
- Estimated time
- 1-3 hours
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 68/100