# Spec 70: MCP のツール結果で画像を受け取る - 状態: **Done**(2026-10-25。P4 = 実機 7 項目を `fuseforks.log` で観測。記録は「P4 実機記録」)(2026-10-24 起票。利用者が推しの 6 点のまま裁定し、同日 P0 = 7 ワイヤの実測と `data_contract` の `mcp_image_result_contract` 凍結。記録は「P0 実測記録」。D1 の表が埋まった。→ 同日、査読 9 点を 実装と P0 の記録に当てて rev2。判定は採用 6 / 前提を訂正して採用 1 / 範囲を広げて採用 1 / 反証 1。表は Notes 2。 → 同日 P1 = コア。記録は「P1 実装記録」。→ 2026-10-25 P2 = 各ワイヤ。記録は「P2 実装記録」。画像がワイヤへ出る ようになった。→ 同日 P3 = 台帳(DETAIL 3 言語・PRIVACY 日英・README 3 言語の 1 句)。→ 同日 P4 = NIK のライブビューで実機) ## rev1 からの変更(査読 9 点の反映。詳細は Notes 2) - **D2 の二重定義を解いた** — 再エンコードしないもの(JPEG / WebP で上限の内側 = バイト列をそのまま)と、再エンコード するもの(PNG・上限超え = JPEG 85・透明は白)を分けて書いた。白で塗るのは再エンコードするときだけ - **「代替」を 1 つの意味にした** — 設計の語としての「代替」は OpenAI 互換が使う「直後の利用者の発話」だけ。P0 で Anthropic と Gemini に試した「同じ発話の別ブロック」は測定の変種で、どのワイヤも採らない(D1) - **OpenAI 互換を一律に代替にした代償を書いた**(xAI の互換の口はネイティブで読めるのに発話が 1 つ増える) - **1 要求に載せる画像の総数に上限を足した**(D2b 新設)— 査読の「利用者の添付と上限を共有したらどちらを落とすか」は 前提が違った(共有しているのは 1 枚の上限の値で、予算ではない)。ただし同じ方向に実在する穴があった — ターンの中で ツールを何周も呼ぶと画像が積み上がり、Anthropic の 1 要求 100 枚の上限を超える - **RepeatGuard の要約値は、接続先へ送るバイト列で取る**(D4) - **Goal 1 の射程を「画像を読めるモデル」に絞った** — 200 で画像を黙って無視するモデルはコアから見分けられない(D5) - **コアが足す文はすべて村の言語で、言語を知っている層が書く**(D5 / D6 / D1 の代替の本文)— 査読は D5 の一文の 日本語固定を指したが、引くと rev1 の D5 は送り直しを `LlmBackend::chat`(言語を知らない層)に置いていた。 `failures.md` #149 の形そのものなので、送り直しの置き場をターンの層へ移した - **`ToolOutput` の形を決めた**(D7)— 落とした画像は `images` に入れず、`text` に村の言語で書き、落とした理由は計器用に別に持つ - 反証 1 件: 「代替の発話を足すと入力キャッシュの前方一致が効かなくなる」(D3 に理由を書いた) - 起点: 利用者(2026-10-24)—「NIK(Nikon のカメラと MCP で繋ぐアプリ)でカメラの写しているイメージを取得したい。 Fuseforks のツール実行ではテキスト以外は受け取れないようになっているようだ。テキスト以外も受け取れるようにできるか」 - 調査だけのターンを 1 回取った(コード: `mcp.rs` の `render_tool_result` / `tool.rs` の `AgentTool::call` / `llm/canonical.rs` の `ChatMessage.attachments` / 各ワイヤの `Role::Tool` の組み立て / `attachment.rs` の上限 / `orchestrator/turn.rs` の `RepeatGuard`)。下の「現況」はその実測 ## 覆す判断 - **`render_tool_result` の「テキスト以外は種別だけを明示して落とす」**(`mcp.rs` の doc —「そのまま渡せないので」)。 落としていた理由は経路が無かったことで、黙って捨てないための注記は正しかった。本 Spec は**画像だけ**経路を作り、 音声・埋め込みリソースは今の注記のまま残す(D6) ## Goal 1. MCP サーバーがツールの結果として返した画像(`type: "image"`)を、そのツールを呼んだ個体のモデルが見られる。 **対象は画像を読めるモデル**(rev2)— 画像を読めないモデルのうち 200 で黙って無視するものは、コアから見分けられない(D5) 2. 画像を返さない MCP・同梱ツールの挙動は 1 バイトも変わらない(ツールの提示・結果の本文・履歴・ログ) 3. 画像が入っても、同じ失敗の繰り返しを止める歯止め(RepeatGuard)が誤って止めない・誤って見逃さない 4. 画像を読めないモデルの個体で、ターンが落ちない 5. 外へ送るものが 1 つ増えることを、PRIVACY に書く ## 現況(実測 2026-10-24) - **落としている場所は 1 箇所** — `mcp.rs` の `render_tool_result` が `CallToolResult.content` の各ブロックを `as_text()` で見て、テキスト以外を「(テキスト以外の内容が返されました。この経路では扱えません)」の 1 行へ置き換える。 この 1 行は**日本語固定**で、英語の村にも日本語のまま入る(`failures.md` #149 の家族)。単体 `non_text_blocks_are_reported_rather_than_dropped` がこの挙動を留めている - **ツールの戻り値の型がテキストしか運べない** — `AgentTool::call` は `CoreResult`(`tool.rs`) - **運ぶ器は半分ある** — `ChatMessage.attachments: Vec`(Spec 23 / 36)。ただし各ワイヤが 添付を組み立てるのは利用者の発話だけで、`Role::Tool` は本文だけを送る(例: `openai_compat.rs` の `Role::Tool` は `OaiContent::Text` で即 return) - **ツール結果はもともと 1 ターン限り** — 履歴(`Record::Exchange`)にはツール結果を積まない。ターンの中の 2 周目 以降には送り直される(入力キャッシュの前方一致に乗る) - **コアが扱える画像の形式は WebP と JPEG だけ**(`PromptMediaType`)。利用者の添付は画面側が WebP にしてから送る (Spec 23)。上限は `ATTACHMENT_IMAGE_MAX_BYTES` = 2 MB・`ATTACHMENT_IMAGE_MAX_EDGE_PX` = 1568。依存の `image` は `features = ["webp", "jpeg"]` で、**PNG のデコーダが無い**。`image` の WebP エンコーダは可逆だけ - **RepeatGuard は (ツール名, 引数, 結果の本文) で数える**(`turn.rs` の `RepeatGuard::observe`)。本文が同じで画像 だけが違う結果(ライブビューを撮り直す)は「同じ結果の 3 回目」として止まる - **ツール結果の圧縮(Jev・Spec 59 / 60)は本文の文字列を見る**。ツールの出力の表示(Spec 57)も文字列のリング - **各社がツール結果の中の画像を受けるか** — Anthropic は `tool_result` の `content` に画像ブロックを置ける(文書)。 OpenAI 互換の `role: "tool"` はテキストだけ(文書)。Gemini ネイティブ・OpenAI / xAI / Meta / Perplexity の Responses は**未測定**(P0)。利用者の発話に載せる画像は 7 ワイヤ全部が運べる(Spec 36 の carries 表) ## Design ### D1. 画像を置く場所 — ワイヤごと(P0 で決める) - **ツール結果の中へ置けて、モデルが中身を読めるワイヤは、ツール結果の中へ置く**(ネイティブ) - **置けないワイヤは、ツール結果の直後に利用者の発話を 1 つ足して、そこへ載せる**(代替)。本文は 「({ツール名} の結果の画像 N 枚)」— モデルが「利用者が貼った画像」と取り違えないよう、どのツールの結果かを名乗る。 送り手封筒(Spec 26)は付けない(利用者の発話ではなく、コアが組んだ運び台) - **「代替」はこの 1 つの意味だけ**(rev2)。P0 で Anthropic と Gemini に試した「同じ発話の別ブロック」は測定の変種で、 どちらのワイヤもネイティブを採るので実装には現れない - **代替の本文はターンの層が村の言語で組み、canonical で adapter へ渡す**(rev2。D6)— adapter は言語を知らないので 文を書かない(`failures.md` #149)。ツール結果の `ChatMessage` に画像と一緒に載せる欄を 1 つ足す(名前は P1) - どちらにするかは P0 の実測で表にして `data_contract` に凍結する(Spec 36 の carries 表と同じ形 — 述語 1 実装と adapter の一致をテストで留める)。**P0 の結果**: | ワイヤ | 置き場 | 根拠(P0) | |---|---|---| | Anthropic | ネイティブ(`tool_result` の `content` の配列に画像ブロック) | ネイティブ・代替とも読めた | | OpenAI 互換 | **代替**(`role: "tool"` は本文だけ、直後に利用者の発話で `image_url`) | ネイティブは接続先しだい(Gemini の互換の口 = 400、xAI の互換の口 = 通る)。代替は 2 つとも読めた | | Gemini ネイティブ | ネイティブ(`functionResponse.parts` に `inlineData`) | ネイティブ・代替とも読めた(3.5-flash-lite / 3.8-flash) | | OpenAI Responses | ネイティブ(`function_call_output.output` を `input_text` + `input_image` の配列に) | ネイティブ・代替とも読めた | | xAI Responses | ネイティブ(同上) | 同上 | | Meta Responses | ネイティブ(同上) | 同上 | | Perplexity Responses | ネイティブ(同上) | 画像を受けるモデル(`google/gemini-3.7-flash`)でネイティブ・代替とも読めた | - **OpenAI 互換を一律に代替にした代償**(rev2)— `openai_compat.rs` は接続先を区別しない 1 実装で、ネイティブが通るかは 接続先しだい(Gemini の互換の口 = 400、xAI の互換の口 = 通る)。400 になる接続先を避けるため一律に代替にする。 代償は、ネイティブで読める接続先(xAI の互換の口など)でも利用者の発話が 1 つ増えること(本文は 10 トークン前後)。 xAI を Responses のワイヤ(`xai_responses`)で使う個体はネイティブなので、この代償は互換の口を選んだ個体だけ - canonical では、画像を**ツール結果の `ChatMessage.attachments`** に載せる。ネイティブか代替かは adapter が決める (プロンプトを組む側は場合分けを抱えない) ### D2. 形式と大きさ — コアで JPEG へ寄せる - **2 つに分ける**(rev2。rev1 は「すべてデコードして JPEG へ」と「上限の内側ならそのまま」を同じ段落に書いていた): - **再エンコードしない**: `image/jpeg` か `image/webp` で、長辺 1568px 以下・2 MB 以下のもの。**MCP が返したバイト列を そのまま送る**(寸法を読むためにデコードはするが、エンコードし直さない。WebP の透明はそのまま残る — 7 ワイヤとも WebP を受ける) - **再エンコードする**: `image/png`、または上限(長辺・大きさ)を超えた JPEG / WebP。デコードし、長辺 1568px へ縮め、 **JPEG(品質 85)** へ。**透明は白で塗る**(JPEG は透明を持たない。白で塗るのはこの経路だけ)。再エンコードしても 2 MB を超えるものは落とす - WebP へ再エンコードしない理由: `image` の WebP エンコーダは可逆だけで、写真が 2 MB の上限を超えやすい。非可逆の WebP は C 依存(libwebp)になる。JPEG は 7 ワイヤ全部が運ぶ(Spec 23 の JPEG フォールバック・本 Spec の P0 で実測済み) - **上限**: 1 つのツール結果につき画像 4 枚・1 枚 2 MB・長辺 1568px。**値は利用者の添付と同じ定数 (`ATTACHMENT_IMAGE_MAX_BYTES` / `ATTACHMENT_IMAGE_MAX_EDGE_PX`)を使うが、予算は共有しない**(rev2。利用者の添付は 1 発話 1 枚で、ツール結果の画像とは別の発話に乗る。どちらかを落として他方を通す関係は無い) - デコードできない・形式が違う(GIF など)・上限を超えた画像は落とし、**落としたことを本文に書く**(`failures.md` #44。D7) - `image` に `png` の機能を足す(純 Rust。依存の木に 7 crate 増えるが、7 つともロックファイルに既にある。P0) ### D2b. 1 要求に載せる画像の総数(rev2 で新設) - ツール結果の画像は、ターンの中で周を重ねるたびに `messages` に積み上がり、毎周送り直される(D3)。カメラを何度も 撮るターンでは、Anthropic の **1 要求 100 枚**の上限(20 枚を超えると 1 枚の上限も 2000px へ下がる)に届く - **1 要求に載せるツール結果の画像は、新しいものから 20 枚まで。** 超えたら古いものから外し、外したツール結果の本文に 「(前の周の画像は省きました)」を村の言語で足す(ターンの層) - 古い画像を外すとその位置から後ろの前方一致が切れ、その周は入力キャッシュの書き直しになる(D3)。新しい画像を 落とす形は採らない — カメラを見張る仕事では、要るのは最新の画像 ### D3. 寿命 — 1 ターン限り - ツール結果と同じ。履歴にも `sessions.redb` にも入らない(Spec 23 D1 と同じ判断) - ターンの中では 2 周目以降に送り直される。入力キャッシュの前方一致に乗るので、払いは主に最初の 1 回 - **代替(D1)でも前方一致は保たれる**(rev2。査読の反証)— 代替の発話はツール結果の直後に**追記**され、次の周も 同じバイト列で組み直されるので、`messages` の並びは周をまたいで前方が変わらない。前方一致が切れるのは D2b で 古い画像を外したときだけ。代替の本文が足すのは 10 トークン前後で、画像のトークン(NIK の 640×424 で約 360)は変わらない ### D4. RepeatGuard — 本文と画像の要約値で数える - 「同じ結果」の判定に、画像のバイト列の SHA-256 を加える。本文が同じで画像が違えば別の結果 - **要約値を取るのは、接続先へ送るバイト列**(rev2)— 再エンコードしないものは MCP が返したバイト列、するものは 再エンコード後の JPEG。D2 の経路の分かれ目を判定に持ち込まない(同じ入力は同じ経路を通るので、同じ画像は同じ要約値)。 落とした画像は要約値の列に入れない - 画像を返さないツールの判定は今と同じ(要約値の列が空) ### D5. 画像を読めないモデル — 画像を外して 1 回だけ送り直す - ツール結果の画像を含む要求が 400 で返ったら、ツール結果の画像を外し、そのツール結果の本文に「画像はこのモデルへ 送れませんでした」を足して 1 回だけ送り直す - **置き場はターンの層**(rev2 で rev1 から移した)— rev1 は Spec 23 D3 の JPEG フォールバックと同じ `LlmBackend::chat` の最外周に置いていたが、そこは村の言語を知らない層で、足す一文が日本語固定になる (`failures.md` #149 が直したばかりの形)。ターンの層は村の言語を知っており、`messages` を組み直せる。 その周の `messages` から画像を外すので、以後の周も画像なしで続く(同じ 400 を毎周踏まない) - 外して通ったことはログに 1 行(`tool image fallback:`) - **この網に掛からないモデルがある**(P0)— 画像を読めないモデルの一部は 400 を返さず、200 で画像を黙って無視する。 sakura の gpt-oss-120b は 3 回とも 200 で、1 回は**存在しない色(青と赤)を答えた**。コアからは見分けられないので 機構は足さず、DETAIL に「画像を読めないモデルの個体では、画像が無いと答えるか、中身を捏造することがある」と書く。 **このモデルは Goal 1 の対象外**(rev2)— Goal 4 の「ターンが落ちない」は満たすが、「見られる」は満たせない ### D6. 範囲 — 画像だけ - 音声(`type: "audio"`)・埋め込みリソース(`type: "resource"`)は今の注記のまま。ただし**注記の文を村の言語で** 書く(日本語固定の 1 行を直す) - **コアがツール結果に足す文は、すべて村の言語で、言語を知っている層が書く**(rev2。査読 8 を広げた): | 文 | 書く層 | |---|---| | テキスト以外を扱えない注記(音声・リソース) | `McpTool`(`ToolContext.language` を持つ) | | 画像を落とした注記(D2・D7) | `McpTool` | | 代替の発話の本文(D1) | ターンの層(canonical の欄で adapter へ渡す) | | 前の周の画像を省いた注記(D2b) | ターンの層 | | 画像をモデルへ送れなかった注記(D5) | ターンの層 | adapter(LLM の層)は文を 1 つも書かない - 同梱ツールは画像を返さない。戻り値の型を広げるのは MCP のツールだけ(D7) ### D7. 型 — 既定実装つきのメソッドを足す - `AgentTool` に `call_with_media`(仮)を足し、既定は `call` の文字列をそのまま包む(`ToolOutput { text, images }`)。 上書きするのは `McpTool` だけ。同梱ツールは 1 行も変えない - **一部の画像を落としたときの形**(rev2)— `ToolOutput` は 3 つを持つ: - `text`: MCP のテキストのブロックに、落とした画像の注記(村の言語)を足したもの。**モデルが読むのはこれ** - `images`: 送る画像だけ(D2 を通ったもの。最大 4 枚)。**落とした画像は入れない** - `dropped`: 落とした画像の理由の列(形式違い / デコード不能 / 大きさ超え / 枚数超え)。**計器だけが読む** (`tool image dropped:` 行)。モデルへは `text` の注記で伝わる - 部分的な成功はエラーにしない — テキストと残った画像は届ける。すべて落ちたら `images` が空で、`text` に注記だけが残る (「空の結果」にしない) - 呼ぶ側(`CallRunner`)は `call_with_media` だけを呼び、画像をツール結果の `ChatMessage.attachments` へ載せる ### D8. 他の機構との関係 - **ツール結果の圧縮(Jev)**: 本文だけを見る。画像は送らない・落とさない - **ツールの出力の表示(Spec 57)**: 本文の後ろに「(画像 N 枚・{形式}・{大きさ})」を足す。画像そのものはリングに 持たない - **計器**: `tool:` 行に `images=N`(画像を返したときだけ)。落とした画像は `tool image dropped: … reason=…` を 1 行。 **画像の中身は書かない** - **トークン予算**: 画像のトークンは接続先が返す `usage` にそのまま入る(新しい計上は要らない) ### D9. PRIVACY - 日英の「LLM の接続先へ送るもの」に「MCP サーバーがツールの結果として返した画像(Spec 70)」を足す。 NIK のように実世界を写す画像が、その個体のモデルの接続先へ出る ## 採らなかった形 - **画像を常に代替(利用者の発話)で運ぶ** — 1 経路で済むが、ネイティブで運べるワイヤでも「利用者が貼った」と読める 位置に置くことになる。P0 で運べると分かったワイヤはネイティブを使う - **画像を WebP へ寄せる** — D2 の理由(可逆だけ / C 依存) - **画像を履歴に残す** — D3(Spec 23 D1 と同じ) ## Tasks ### P0 — 測ってから凍結する(**完了 2026-10-24**。下の「P0 実測記録」) - [x] 7 ワイヤへ「ツールを呼んだ発話 + 画像を含むツール結果」を送り、受理されるか・モデルが画像の中身を読めるかを ネイティブと代替の両方で測る(合成の 2 色の画像。中身の読みは色で判定する) - [x] JPEG と WebP の両方を、ネイティブで受けるワイヤで確かめる - [x] `image` に `png` を足したときに増える依存を数える - [x] 実物の MCP の画像の形(NIK の `live_view_image`)を 1 回読む - [x] `data_contract.yaml` へ凍結(`mcp_image_result_contract`。D1 の表・D2 の上限・D4 の判定・D5 の送り直し) ### P0 実測記録(2026-10-24) **測り方**: 使い捨ての Python(scratchpad。リポジトリには入れていない)で、各社の API を直に 2 段で撃った — 1 段目で `camera_snapshot`(引数なしの関数)を呼ばせ、2 段目で 1 段目の出力を逐語で積み(Anthropic の思考ブロック・ Gemini の `thoughtSignature`・Responses の `reasoning` を含む)、画像入りのツール結果を返す。画像は 128×96 の 左半分が緑・右半分がマゼンタの合成で、答えに両方の色が出れば「読めた」。置き方は **ネイティブ**(ツール結果の中)と **代替**(Anthropic と Gemini は同じ発話の別ブロック、それ以外は直後の利用者の発話)。鍵は資格情報ストアから読み、 表示もファイルへの書き出しもしていない。 | 対象(モデル) | ネイティブ JPEG | ネイティブ WebP | 代替 JPEG | |---|---|---|---| | Anthropic(claude-sonnet-5-5) | 読めた | 読めた | 読めた | | Gemini(gemini-3.5-flash-lite / gemini-3.8-flash) | 読めた | 読めた | 読めた | | OpenAI Responses(gpt-6.1-sol) | 読めた | 読めた | 読めた | | xAI Responses(grok-4.7) | 読めた | 読めた | 読めた | | Meta Responses(muse-spark-1.3-contributor) | 読めた | 読めた | 読めた | | Perplexity Responses(google/gemini-3.7-flash) | 読めた | 読めた | 読めた | | OpenAI 互換・Gemini の互換の口(gemini-3.5-flash-lite) | **400** `Invalid content part type: image_url` | **400** | 読めた | | OpenAI 互換・xAI の互換の口(grok-4.7) | 読めた | 読めた | 読めた | **画像を読めないモデル**(D5 の網との関係): | 対象 | 結果 | |---|---| | Perplexity(perplexity/deepseek-v4-pro-0813) | 画像入りは 3 通りとも本文なしの `400 invalid request`。画像なしの対照は 200、ツールなしで画像だけを送っても 400 → **往復の形ではなくモデルが画像を受けない**。D5 の送り直しで救える形 | | sakura(gpt-oss-120b、OpenAI 互換) | 3 通りとも **200**。2 回は「画像が見えない」と答え、**1 回は存在しない色(青と赤)を答えた** → D5 の網に掛からない | **測れなかったもの**: - **OpenAI の互換の口**(`/v1/chat/completions`)— gpt-6.1-sol はこの口でツールを使えない(推論を付けたままでは `Function tools with reasoning_effort are not supported`、`reasoning_effort: "none"` は `does not support 'none'`)。 OpenAI の文書はツールの結果をテキストだけと書いており、D1 で OpenAI 互換を代替に固定するので結論は変わらない - sakura の Qwen3-VL — サーバー側でツールが使えない設定(`"auto" tool choice requires --enable-auto-tool-choice`) **実物の MCP の画像**(NIK の `live_view_image`、Claude Code から 1 回): `content` は **JPEG の画像 1 枚 (640×424・21,017 バイト)と、テキストのブロック 1 つ**(`{"height":424,"jpeg_bytes":21017,"seq":33985,"width":640}`)。 D2 の上限の内側の JPEG なので、**変換せずにそのまま通る**。 **依存**: `image` に `png` を足すと、`fuseforks-core` の依存の木に 7 crate(`png` / `fdeflate` / `flate2` / `miniz_oxide` / `adler2` / `crc32fast` / `simd-adler32`)増える。**7 つともロックファイルに既にある**(GUI 側の依存) ので、リポジトリ全体では新しい crate は増えない。`Cargo.toml` とロックファイルは測った後に元のバイト列へ戻した (SHA-256 で確認)。 **トークン**: 合成の画像(128×96)は入力の数十トークン。NIK の 640×424 なら Anthropic の目安(幅 × 高さ ÷ 750)で 約 360 トークン。ターンの中の 2 周目以降に送り直されるが、入力キャッシュの前方一致に乗る。 ### P1 — コア(**完了 2026-10-24**。下の「P1 実装記録」) - [x] 画像の正規化の純機構(`tool_image.rs`)と `image` の `png` - [x] `ToolOutput` と `AgentTool::call_with_media`、`McpTool` の画像つきの結果(`render_tool_output`) - [x] ターンの層 — ツール結果へ画像を載せる・1 要求 20 枚・400 の送り直し・RepeatGuard・計器・表示の 1 行 - [x] 単体と結合、変異で赤を確かめる ### P1 実装記録(2026-10-24) **adapter はまだ 1 行も変えていない**(P2)。どのワイヤも `Role::Tool` の `attachments` を読まないので、P1 の時点では 画像は canonical の要求に載るがワイヤへは出ない。**P1 と P2 の間で版を出さない** — 出すと、それまでの 「テキスト以外は扱えません」の注記が消えたうえに画像も届かず、モデルは画像があったことを知らない。 **入れたもの**: - `crates/fuseforks-core/src/tool_image.rs`(新設・純機構)— `normalize`(D2 の 2 経路)/ `cap_tool_images`(D2b)/ `strip_tool_images`(D5)/ `attach`(代替の本文を枚数と一緒に組む)/ コアが足す文 6 つ(`dropped_note` / `images_only_text` / `caption` / `omitted_note` / `fallback_note` / `display_suffix`。どれも村の言語を引数に取る) - `tool.rs` — `ToolOutput { text, images, dropped }` と `AgentTool::call_with_media`(既定は `call` を包む) - `mcp.rs` — `render_tool_result(result) -> String` を `render_tool_output(result, language) -> ToolOutput` へ。 `McpTool::call` は `call_with_media` の本文だけを返す形に - `llm/canonical.rs` — `ChatMessage.attachment_caption`(代替の本文。`skip_serializing_if` で、画像の無い発話は 欄の追加前と同じシリアライズ) - `orchestrator/turn.rs` — `execute_tool` は `call_with_media` を呼ぶ / `CallOutcome::Executed { body, images }` / 周の終わりに `cap_tool_images` / `chat_with_tool_image_fallback`(周の呼び出しとまとめ呼び出しの 2 箇所)/ `RepeatGuard::observe_result`(本文と要約値の列の組)/ 計器 3 行 / 表示の 1 行 - `attachment.rs` — `is_animated_webp` を `pub(crate)` で出した(利用者の添付の検証と同じ 1 実装) **実装で決めたこと**(Design に書いていなかったもの): - **形式は `mimeType` ではなくバイト列で決める**(`image::guess_format`)— `mimeType` はサーバーの自己申告で、 送る `media_type` がバイト列と食い違うと接続先が 400 を返す。判別できないバイト列は「読み取れない」、 判別できて運べない形式(GIF・BMP)は「対応していない形式」と分けた - **アニメーション WebP は再エンコードの経路**(先頭フレームの JPEG)— そのまま送ると、モデルに何が渡るかが 接続先しだいになる - **展開の上限 1 辺 16,384px**(`TOOL_IMAGE_DECODE_MAX_EDGE_PX`)— 伸張爆弾の歯止め。D2 の「上限を超えたら縮めて送る」は 縮める前に展開するので、小さなファイルで巨大な画像を名乗られるとメモリを食い尽くす。超えたら展開せずに 「大きすぎる」で落とす - **画像だけが返った結果の本文は「(画像 N 枚)」**(`McpTool`)— Spec 70 の前の「(空の結果)」のままだと嘘になる。 D6 の表に無かった 6 つ目の文 - **同じ関数にあった日本語固定の 2 文も村の言語へ**(「(空の結果)」「ツールはエラーを返しました:」)。日本語の村の 本文は、画像を返さない結果では Spec 70 の前と 1 バイトも変わらない。**ターンの層に以前からある日本語固定の文** (ツールの実行に失敗しました / そのツールはありません / 同じ結果の繰り返しを止めた文)は範囲外のまま - **1 要求 20 枚の確かめは周の終わりの 1 回**で、`messages` を直に書き換える。一部だけ外す結果は先頭から残し、代替の 本文の枚数を組み直す。同じ結果を 2 度絞っても一文を重ねない。外した周にだけ `tool image capped:` の 1 行 (D8 に無かった計器) - **送り直しの拒否は 400 と `ImagesRejected`** — OpenAI 互換の口では、ツール結果の WebP も Spec 23 の JPEG フォールバックを 通り、2 回とも 400 なら `ImagesRejected` で返る。送り直した後のターンでは、**新しいツール結果の画像も載せずに** 本文へ一文(同じ 400 を毎周踏まない)。打ち切りが立っていたら送り直さない。**まとめ呼び出し**(ツール上限・ 繰り返しで打ち切った後の 1 回)も同じ送り直しを通す — 最後の周に返った画像を初めて運ぶ呼び出しになりうる - **表示の 1 行の形は「(画像 1 枚: JPEG 21 KB)」**(複数なら「、」でつなぐ)。D8 の「(画像 N 枚・{形式}・{大きさ})」を、 枚数ごとに形式と大きさが違いうる形へ寄せた **テスト**: 単体 = `tool_image` 14 本(2 経路・透明の白・縮める寸法・展開の上限・GIF / 判別不能 / 壊れた base64・ 注記の言語・20 枚の絞り方・送り直しの外し方・表示の 1 行)+ `mcp` の結果の組み立て 9 本(うち 4 本を新設、 既存の `non_text_blocks_are_reported_rather_than_dropped` は画像ではなく**音声**で留め直した — 画像は扱える側へ移ったので、 「テキスト以外」の注記を留めるには音声が要る)。結合 = `tests/tool_image_results.rs` 6 本(ツール結果に載る / 本文だけの ツールの欄が増えない / 400 で外して送り直し・次の周は最初から載せない / 画像が違えば繰り返しではない / その対照 / 20 枚)+ `tests/tool_image_log.rs` 1 本(計器 3 つと、本文だけのツールの行に `images=` が無い負の対照)。 **変異 9 回とも予測どおり**(赤になる本を先に書いてから回した。戻しは控えから写して SHA-256 で確認): | | 変異 | 赤 | |---|---|---| | M1 | 1 要求 20 枚の絞りを外す | 20 枚の 1 本 | | M2 | RepeatGuard が画像の要約値を比べない | 画像が違えば繰り返しではない、の 1 本 | | M3 | 400 の送り直しをしない | 送り直しの結合 1 本 + 計器の 1 本 | | M4 | 送り直した後も新しい画像を載せる | 送り直しの結合 1 本 | | M5 | `images=` を常に書く | 計器の 1 本(負の対照) | | M6 | MCP の画像を Spec 70 の前の注記へ戻す | `mcp` の 4 本 | | M7 | PNG をそのまま送る | 透明を白で塗る 1 本 | | M8 | 展開の上限を外す | 展開の上限の 1 本 | | M9 | 透明を白で塗らない | 透明を白で塗る 1 本 | M7 と M9 が同じ 1 本で赤になるのは、その本が「JPEG へ再エンコードした」と「左上の画素が白」の両方を見ているから。 ワークスペース全体で 1,428 本通過・失敗 0(77 バイナリ)・clippy 0。 ### P2 — 各ワイヤ(**完了 2026-10-25**。下の「P2 実装記録」) - [x] Anthropic — `tool_result` の `content` を画像とテキストのブロック列に - [x] Gemini — `functionResponse.parts` に `inlineData` - [x] Responses 4 社 — `function_call_output.output` を `input_text` + `input_image` の列に(共有の `responses_input.rs` の 1 箇所) - [x] OpenAI 互換 — `role: "tool"` は本文だけ、画像は直後の利用者の発話(本文は `attachment_caption`) - [x] 7 ワイヤの置き場の表をテストで凍結し、変異で赤を確かめる ### P2 実装記録(2026-10-25) **これで画像がワイヤへ出る。** P1 の「P2 まで版を出さない」はここで解けた。 **入れたもの**: - `llm/wire.rs` — `AnthropicToolResultContent`(素の文字列かブロック列。untagged)と `AnthropicToolResultBlock` (テキスト / 画像。`cache_control` を持たせない — 境界は `tool_result` ブロック自身に打つ)/ `GeminiFunctionResponse.parts`(空なら欄ごと出さない)/ Responses の `function_call_output.output` を `ResponsesContent` へ - `llm/anthropic.rs` — `tool_result_content`(画像 → テキストの並び。キャッシュの概算は中のテキストだけを数える) - `llm/gemini.rs` — ツール結果の `functionResponse` に `parts` - `llm/responses_input.rs` — `encode_tool_output`(テキスト → 画像の並び。part は利用者の発話と同じ `part_for`)。 4 社が共有するので 1 箇所で済んだ - `llm/openai_compat.rs` — `encode_messages`(運び台を続けて並ぶツール結果の後ろへまとめる)と `tool_image_message` **実装で決めたこと**: - **`Provider` の述語は置かなかった**(D1 は「述語 1 実装と adapter の一致をテストで留める」と書いていた)。 `Provider::carries` は送信の入口が実行時に読むが、ツール結果の置き場は adapter が決めて終わりで、実行時に表を 読む者が居ない。述語を置くとテストだけが読む 2 つ目の写しになる。表は `tests/tool_image_placement.rs` が逐語で 凍結し、7 ワイヤの adapter の組み立てから置き場を読み取って突き合わせる(見るのは「画像を載せたとき発話の列が 1 つ伸びるか」の 1 点。型名は社ごとに違うので、形はそれぞれの単体が持つ) - **並びは P0 で通った形のまま** — Anthropic は画像 → テキスト、Responses はテキスト → 画像。社ごとに逆だが、 どちらも P0 の撃ち方そのもので、逆の並びは測っていない - **OpenAI 互換の運び台は、続けて並ぶツール結果の後ろへまとめて置く** — 1 つの assistant が複数のツールを 呼んだ周で、`role: "tool"` の並びの途中に利用者の発話を挟まない(OpenAI の規則は「`tool_calls` の後には 各呼び出しへの `tool` の発話が続く」。挟んだ形は測っていない — 並びを保つ側へ倒した)。周をまたいでは 追記にしかならないので、前方一致は切れない(D3) - **代替の本文が無ければ画像だけを送る** — adapter は文を書かない(#149)。ターンの層は画像を載せるとき必ず 本文を組むので、実運用で本文の無い運び台は出ない - **運ぶのは画像の種別だけ** — ツール結果の `attachments` に他の種別は載らない(凍結 1)が、adapter でも 画像に絞った(最後の砦) - **互換の口の WebP は Spec 23 の JPEG フォールバックを通る**(`with_jpeg_attachments` は全発話の添付を見る)。 2 回とも 400 なら `ImagesRejected` で、ターンの層が画像を外して送り直す(P1) - **Gemini 2.5 系は `functionResponse.parts` を測っていない**(P0 は 3.5-flash-lite と 3.8-flash)。受けないモデルが 400 を返せば D5 の送り直しが受ける - 誤りを 1 つ回収 — `responses_input.rs` の doc が `function_call_output.output` を「string 限定」と書いていた。 列を送ったことが無かっただけで、P0 で 4 社とも列を受けた **テスト**: 単体 10 本(Anthropic 2 / Gemini 2 / OpenAI 互換 4 / Responses 2。どれも「画像を載せた形」と「画像の 無い結果が Spec 70 の前と同じ形」の対)+ 結合 `tests/tool_image_placement.rs` 2 本(7 ワイヤの置き場の表 / 画像の 無い結果は 7 ワイヤとも文字列)。既存の llm の単体 171 本は無改変で通った(画像の無いツール結果のバイト列は不変)。 **変異 9 回とも予測どおり**(赤になる本を先に書いてから回した。戻しは SHA-256 で確認): | | 変異 | 赤 | |---|---|---| | N1 | Anthropic が画像を落とす | Anthropic の単体 1 本 + 表の 1 本 | | N2 | Gemini が画像を落とす | Gemini の単体 1 本 + 表の 1 本 | | N3 | Responses が画像を落とす | Responses の単体 1 本 + 表の 1 本 | | N4 | 互換が運び台をツール結果の並びの途中へ置く | 互換の単体 1 本(2 つのツールを呼んだ周) | | N5 | 互換が運び台を出さない | 互換の単体 3 本 + 表の 1 本 | | N6 | Anthropic がテキストを画像より前に置く | Anthropic の単体 1 本 | | N7 | 互換が代替の本文を落とす | 互換の単体 1 本 + 表の 1 本 | | N8 | Responses が画像の無い結果も列にする | Responses の単体 1 本 + 既存の xAI・OpenAI Responses の単体 2 本 + 「画像の無い結果は文字列」の 1 本 | | N9 | Gemini が画像の無い結果にも `parts` を出す | Gemini の単体 1 本 + 「画像の無い結果は文字列」の 1 本 | N4 を留めるのは 2 つのツールを呼んだ周の 1 本だけ — 表のテストはツールを 1 つしか呼ばないので、並びの途中か後ろかを区別しない。 ワークスペース全体で 1,440 本通過・失敗 0(78 バイナリ)・clippy 0。 ### P3 — 台帳(**完了 2026-10-25**) - [x] DETAIL 3 言語 — 節「MCP のツール結果の画像」(リモート MCP の OAuth の節の次)とディレクトリ木の `tool_image.rs` - [x] PRIVACY 日英 — 4-1 の送るもの・4-3 の MCP サーバー・4-4 の Jev が送らないもの・6 の記録しないもの・最終更新日 - [x] README 3 言語 — 行は足さず、既存の MCP の行に 1 句(170 / 170 / 166 行のまま) - [x] 追従漏れの回収 ### P3 台帳記録(2026-10-25) - **DETAIL** — 日英は同じ構成(対象・形式・落とした画像・1 要求 20 枚・寿命・置き場の表・画像を読めないモデル・ 繰り返しの検出・画面・ログ・Jev・外へ出るもの)。中国語は抄訳の 1 節。D5 が求めた「画像を読めないモデルの個体では、 画像が無いと答えるか、中身を捏造することがある」は、**画像を返すツールは画像を読めるモデルの個体に持たせる**という 次の手と一緒に書いた - **PRIVACY** — D9 の 1 行に加えて 3 箇所。4-3 に「画像はそのツールを呼んだエージェントのモデルの提供事業者へ送られ、 本アプリは保存しない」、4-4 に「Jev へは画像を送らない」、6 に「画像は記録しない(枚数と渡せなかった理由だけ)」。 **送信先は増えていない**(既に送っている提供事業者へ、送るものが 1 種類増えた)ので、要旨の「外部へ通信するのは 2 つだけ」は変えていない - **README** — 入口で読めるべき側と判断した(Spec 59 / 63 / 67 と同じ — 外へ送るものが変わる)。ただし行は足さず、 MCP の行に「ツールが返した画像はサーヴァントのモデルが見る・提供事業者へ送られる」の 1 句 - **追従漏れを 1 件回収** — `data_contract.yaml` の Meta Responses の節と Spec 37 が 「`function_call_output.output` は **string のみ**・型を緩めると 3 社で割れる」と書いていた。Spec 37 の P0 で拒否 されたのは **object** で、配列は撃っていなかった。Spec 70 の P0 で配列は Meta を含む 4 社とも通っている。 どちらにも、その場に訂正を書き足した(`responses_input.rs` の doc は P2 で直した) - ランディングページ(リポジトリの外)は MCP の機能を列挙していないので触っていない ### P4 — 実機(NIK のライブビュー) **前提**: NIK を `agent_9`(ミュゼ)の個別 MCP に `type: "http"` で繋いである(`127.0.0.1:47321/mcp`)。 **比べる元の行**は 2026-10-24 21:11 の `tool: agent=agent_9 round=1 name=nik__live_view_image ok=true … body_chars=87` (Spec 70 の前。画像は「扱えません」の注記に置き換わり、`images=` は無い)。判定はすべて `fuseforks.log` の行で読む。 **依頼文は全項目で同じ**:「NIK のライブビューを 1 枚撮って、写っているものを 1 文で説明して」。テンプレートの切り替えは ミュゼの設定ダイアログで行い、終わったら `muse-spark-latest` へ戻す。 - [x] 準備 — 配布版が動いていないこと / 開発ビルドの起動行が `version: app=0.1.0 profile=debug`(#150)/ NIK でカメラと ライブビューが付いていて、`live_view_image` が NIK の設定で入っている(呼ぶたびに NIK が許可を求める) - [x] 検収 1 Meta(`muse-spark-latest`)— `tool: … name=nik__live_view_image ok=true … images=1` と `turn: … backend=meta-responses stop=-`。 `tool image dropped:` / `tool image fallback:` は出ない。答えがカメラの映っているものと合う(利用者が見比べる) - [x] 検収 2 Anthropic(`claude_sonnet`)— 同じ行で `backend=anthropic` - [x] 検収 3 Gemini(`gemini-3.8-flash`)— 同じ行で `backend=gemini` - [x] 検収 4 OpenAI Responses(`gpt-6-sol`)— 同じ行で `backend=openai-responses` - [x] 検収 5 OpenAI 互換=代替(任意。テンプレートを 1 つ足す — プロトコル「OpenAI 互換」・baseUrl `https://generativelanguage.googleapis.com/v1beta/openai`・モデル `gemini-3.5-flash-lite`・鍵は Gemini の鍵)— **この接続先はツール結果の中の画像を 400 で断る**(P0)ので、代替の経路を判別できる。`images=1` / `backend=openai-compat` / **`tool image fallback:` も `attachment fallback:` も出ない** / 答えが映っているものと合う - [x] 検収 6 画像を読めないモデル(`perplexity` = `perplexity/deepseek-v4-pro-0813`)— `tool image fallback: agent=agent_9 model=perplexity/deepseek-v4-pro-0813 removed=1 outcome=ok` が 1 本、`turn: … stop=-`(ターンが落ちない = Goal 4)。 答えは画像を見られなかった旨になる - [x] 検収 7 画面 — 検収 1 のツール行を開くと、出力の後ろに「(画像 1 枚: JPEG … KB)」の 1 行。画像そのものは出ない **負の対照は同じ走行に入る** — ミュゼが先に `nik__camera_status` などを呼べば、その `tool:` 行には `images=` が無い(Goal 2)。 **実機では判定にならないもの**(結合テストが留める): RepeatGuard(NIK の本文の JSON は `seq` を持ちフレームごとに 変わるので、本文だけで既に別の結果になる — 画像の要約値を見ているかを実機では区別できない)/ 1 要求 20 枚(21 回の 許可が要る)/ 落とした画像(NIK は上限の内側の JPEG を返す)。 ### P4 実機記録(2026-10-25。利用者・NIK のライブビュー・ミュゼ = `agent_9`) **7 項目すべてを `fuseforks.log` の行で観測した。** ビルドは開発ビルドではなく**このリポジトリから作ったリリースビルド** (起動行 `version: app=0.1.0 profile=release`。インストール版なら `app=0.6.2` なので、#150 の取り違えではない。 `images=` の欄は P1 以降、画像をワイヤへ出すのは P2 以降にしか無い)。 | 項目 | 時刻 | テンプレート | `tool:` 行 | `turn:` 行 | 2 周目の入力の増え方 | |---|---|---|---|---|---| | 1 Meta | 03:03:29 | `muse-spark-latest` | `images=1`・`body_chars=56` | `backend=meta-responses stop=-` | +450 | | 2 Anthropic | 03:06:59 | `claude_sonnet` | `images=1` | `backend=anthropic stop=-` | +456(`cache_write=458`) | | 3 Gemini | 03:08:08 | `gemini-3.8-flash` | `images=1` | `backend=gemini stop=-` | +1,407 | | 4 OpenAI Responses | 03:09:27 | `gpt-6-sol` | `images=1` | `backend=openai-responses stop=-` | +390 | | 5 OpenAI 互換 | 03:16:51 | `gemini-3.8-flash` をプロトコル「OpenAI 互換」へ | `images=1` | `backend=openai-compat stop=-` | +1,228 | | 6 画像を読めない | 03:20:46 | `perplexity`(deepseek-v4-pro) | `images=1` | `backend=perplexity-responses stop=-` | **+76**(画像を外した) | - **画像がモデルに届いた証拠は 2 周目の入力の増え方。** ツール結果の本文は 56〜57 字で、文字だけなら数十トークンしか 増えない。1〜5 は 390〜1,407 増え、画像を外した 6 だけが +76。接続先ごとの差は画像の数え方の差(Gemini が最も多い) - **項目 1〜5 で `tool image dropped:` / `tool image fallback:` / `attachment fallback:` / `llm retry` / `turn failed` は 0 本。** 項目 5 は `handoff:` も無い(下の 1 回目と区別する) - **項目 6** — `llm retry stop: … status=400 class=client_error code=invalid_request` の直後に `tool image fallback: agent=agent_9 model=perplexity/deepseek-v4-pro-0813 removed=1 outcome=ok` が 1 本。400 は HTTP の層では 再試行せず(Spec 52 の分類どおり)、ターンの層が画像を外して 1 回送り直した。ターンは `stop=-` で終わり、答えは 「画像の取得は成功したが、このモデルには渡せず中身を確認できない」と正直に書いた(Goal 4) - **項目 7** — 項目 5 のツール行を開くと、入力 `{}`(2 字)と、出力の本文の後ろに「(画像 1 枚: JPEG 19 KB)」。表示の字数 「出力(78 字)」は本文 57 + 改行 1 + その 1 行 20 と一致。画像そのものは出ない - **比べる元の行との差** — 10-24 21:11 は `body_chars=87`・`images=` 無し。今回は `body_chars=56`・`images=1`。縮んだ 31 字はおおむね「扱えません」の注記(30 字 + 改行)の分(`seq` の桁は回ごとに違うので 1 字単位では比べない) - **答えの照合は利用者**(画像の中身は Neo には見えない)。項目 1〜4 はミュゼの履歴が 8〜16 往復ぶん残った状態で、 前の回の説明が履歴にあった。画像が届いた根拠は上の 2 つ(`images=1` と入力の増え方)で、履歴に依らない。項目 5 は **履歴に無い新しい場面**を説明したので、画像を見て答えたと読める **検収 5 の 1 回目は成立していなかった**(03:13)。使ったのは `Qwen3-VL-30B-A3B-Instruct`(sakura)のテンプレートで、 **「ツールを使う」がオフ**(P0 で、sakura のサーバー側がこのモデルでツールを受けない設定と分かっていた)。ミュゼは NIK を 呼ばず、ツール非対応の経路どおり最初の接続先のルナへ渡した(`handoff: agent=agent_9 to=agent_8`。入力は 6,862 で ツールの定義を送っていない)。画面では答えが返っていたので取れたように見えたが、履歴に前の回の説明が 16 往復ぶん 残っており、**画像を見ずに説明を書けた**。**一般化: 実機の確認で答えの中身を照合するときは、同じ答えを履歴から 書ける状態かを先に数える。**`images=1` の行と入力の増え方のように、履歴に依らない証拠をログで取る **ずれと残り**: - 項目 5 は計画の `…/v1beta/openai` + `gemini-3.5-flash-lite` ではなく、既存の `gemini-3.8-flash` のプロトコルを 「OpenAI 互換」へ切り替え、baseUrl は `…/v1beta` のまま(`/openai` 無しでも応答は返った)。P0 で「ツール結果の中の 画像を 400 で断る」と測ったのは前者の組み合わせなので、**この組み合わせで中に置いたら断られるかは測っていない**。 確かめたのは「OpenAI 互換では直後の発話の画像をモデルが読める」こと - **負の対照(画像を返さないツールの行に `images=` が無い)はこの走行に無かった**(呼ばれたツールは `nik__live_view_image` だけ)。結合テスト `tool_image_log.rs` が留めている - 走行の後に、ミュゼのテンプレートを `muse-spark-latest` へ、`gemini-3.8-flash` のプロトコルを Gemini(ネイティブ)へ戻す (利用者) ## 未決 なし(D1 の表は P0 の実測で埋める) ## Notes ### 1. P0 で見つけた別件(本 Spec の範囲外) - **gpt-6 系は OpenAI 互換の口でツールを使えない。** Fuseforks の `openai_compat.rs` は、ツールを送る周に gpt-5 以降へ `reasoning_effort: "none"` を送る(`failures.md` #77)が、gpt-6.1-sol は `none` を受けない。この村の gpt-6-sol は Responses のワイヤなので実害は出ていないが、互換の口で gpt-6 系にツールを持たせると 400 になる - **テンプレート `pplx-nemotron` のモデルは、Perplexity がもう受けない**(`model "perplexity/nemotron-3-ultra-550b-a55b" is not supported`) - **`render_tool_result` の注記の 1 行が日本語固定**(英語の村にも日本語のまま入る。D6 で直す) ### 2. rev1 の査読の反映(2026-10-24。9 点 → 採用 6 / 前提を訂正して採用 1 / 範囲を広げて採用 1 / 反証 1) | # | 指摘 | 判定 | 反映 | |---|---|---|---| | 1 | D2 が「すべて JPEG へ」と「上限の内側ならそのまま」を二重に定義。白で塗るのはどちらか | 採用 | D2 を「再エンコードしない / する」に割り、白で塗るのは再エンコードの経路だけ | | 2 | 「代替」の定義が D1 と P0 記録で 2 種類 | 採用 | D1 — 設計の語は「直後の利用者の発話」だけ。P0 の「同じ発話の別ブロック」は測定の変種 | | 3 | OpenAI 互換を一律に代替にした代償が書かれていない | 採用 | D1 の表の下に、xAI の互換の口で発話が 1 つ増えることと、Responses のワイヤならネイティブなこと | | 4 | 利用者の添付と 2 MB の上限を共有すると、超えたときどちらを落とすか | **前提を訂正して採用** | 共有しているのは 1 枚の上限の値で、予算ではない(D2)。ただし同じ方向に実在する穴 — ターンの中で画像が積み上がり Anthropic の 1 要求 100 枚に届く — があったので D2b を新設 | | 5 | RepeatGuard の要約値の対象が MCP の元のバイト列か再エンコード後か不明 | 採用 | D4 — 接続先へ送るバイト列 | | 6 | Goal 4(落ちない)と Goal 1(見られる)が、200 で無視するモデルで乖離 | 採用 | Goal 1 の射程を「画像を読めるモデル」に絞り、D5 に対象外と書いた | | 7 | 代替の発話を足すと前方一致が効かなくなる | **反証** | 代替の発話は追記で、次の周も同じバイト列で組み直されるので前方は変わらない(D3)。切れるのは D2b で古い画像を外したときだけ | | 8 | D5 の「画像はこのモデルへ送れませんでした」も日本語固定になる | **範囲を広げて採用** | 引くと rev1 の D5 は送り直しを言語を知らない層(`LlmBackend::chat`)に置いていた(#149 の形)。置き場をターンの層へ移し、コアが足す文 5 つの書き手を D6 の表に固定 | | 9 | 一部を落としたとき `ToolOutput` の `text` と `images` が食い違う | 採用 | D7 — `images` は送るものだけ、`text` に注記、理由は `dropped`(計器用) |