9.2 KiB
Review: reasoning ブロックを history に永続化する
前提・要件の確認
- streaming 層で受信した reasoning が
Item::Reasoningとしてworker.historyに append される- 満たされている。
crates/llm-worker/src/llm_client/scheme/anthropic/events.rs:373-389でcontent_block_stop時にpending_thinkingからEvent::ReasoningItemを発火、worker.rs:1006-1011でreasoning_item_collector.take_collected()→build_assistant_items経由で history に append。tests/reasoning_round_trip_test.rs:25-62が end-to-end で検証。
- 満たされている。
- Anthropic
signatureの round-trip 保持- 満たされている。受信側は
events.rs:350-358でsignature_deltaを蓄積、scheme_impl.rs:36-52でPendingThinkingに格納、Item::Reasoning::signature(types.rs:100-105) として保持。送信側はrequest.rs:340-367でsignatureを見てAnthropicContentPart::Thinkingを投影。signature無し +encrypted_contentあり →RedactedThinking、両方無し → drop の分岐も妥当。
- 満たされている。受信側は
- ツール使用ループ内の reasoning 引き継ぎ
- 達成されている。Item として history に乗っているので、次の
build_requestでそのまま items に含まれる。ただし下記「アーキテクチャ・スコープ」参照: 同一論理ターン内の thinking が wire 上で同一 assistant message に纏まらない既存のメッセージ束ね方の問題が残る。
- 達成されている。Item として history に乗っているので、次の
- 通常のマルチターン (新しいユーザー入力をまたぐ) での引き継ぎ
- 同上。Item として残るので保持される。世代別 keep/strip はスコープ外として明記済みで意図通り。
Worker::on_thinking_blockcallback 互換性- 維持されている。
Event::ReasoningItemはBlockStart/Delta/Stop(Thinking)と並行発火する設計 (event.rs:48-58、anthropic/events.rs:336-348、openai_responses/events.rs:458-471)。pod/src/controller.rs:216の既存 thinking callback が引き続き機能することを確認。
- 維持されている。
- OpenAI Responses の
encrypted_content/summaryを活用openai_responses/events.rs:30-74でpending_reasoningに蓄積、output_item.done(Reasoning) でEvent::ReasoningItem発火 (events.rs:366-395)。送信側は既存のrequest.rs:286-309がItem::ReasoningをInputItem::Reasoningに投影しており、encrypted_contentも伝搬する。
- resume 時の reasoning 再現
session-store/src/logged_item.rs:36-46, 95-108, 147-159でLoggedItem::Reasoningにsignatureを追加し、永続化スキーマ側でも保持される。session.rs:206-211の assistant 群グルーピングにもis_reasoning()が含まれており LogEntry::AssistantItems に正しく束ねられる。tests/reasoning_round_trip_test.rs:142-211が injected reasoning が outgoing request に乗ることを確認。
アーキテクチャ・スコープ
- CLAUDE.md「LLM コンテキスト加工原則」遵守
- reasoning を context に差し込むのではなく、必ず
worker.historyに commit してから次リクエストの items に出す設計になっており、原則に沿う。
- reasoning を context に差し込むのではなく、必ず
- layer 分離
- llm-worker 内で完結しており低レベル基盤に閉じている。Pod / 上位層への漏れなし。
- 新規モジュールの妥当性
timeline/reasoning_item_collector.rs新設はtext_block_collector/tool_call_collectorと対称な構造で、責務分割が明確。Event::ReasoningItemを独立イベントにし、streaming 表示用BlockType::Thinkingと persist 用ReasoningItemを別経路にした責務分離も合理的。
- scheme 側の
parse_with_state導入- Anthropic は
content_block_stopが block_type を持たない都合で既に state が必要だったので、pending_thinkingの蓄積を同じ場所に置く判断は自然。signature_deltaをストリーム表示には流さず state にだけ蓄積する選択も合理的。
- Anthropic は
build_assistant_itemsの順序 (Reasoning → text → tool_call)- Anthropic 仕様 (thinking はアシスタントメッセージの先頭) を意識した並びは妥当。ただし下記 Blocking 参照。
- 不必要な実装は見当たらない
- 各変更点はチケット要件と直結しており、scope creep なし。スコープ外 3 件 (世代別 keep/strip、
clear_thinking_20251015、prune-aware) は明示されており触られていない。
- 各変更点はチケット要件と直結しており、scope creep なし。スコープ外 3 件 (世代別 keep/strip、
指摘事項
Blocking
なし。Anthropic 側の wire 表現に懸念 (Non-blocking 参照) があるが、本チケットで「ある reasoning item が history に commit され、次リクエストの Request::items に signature 付きで載る」という最小要件は達成されており、先行マージしてフォローアップで対応する判断は妥当。
Non-blocking / Follow-up
-
Anthropic で同一論理ターンの content blocks が複数の assistant messages に分割される
crates/llm-worker/src/llm_client/scheme/anthropic/request.rs:242-301のconvert_items_to_messagesは、Item::Reasoning(assistant pending) とItem::Message{Assistant}(text) が連続するとき、前者を一度 flush して別 message にしてから text を別 message として追加する。結果としてmessages = [..., assistant[Thinking], assistant("text"), assistant[ToolUse], ...]のように同一論理ターンが3つの assistant message に割れる。Anthropic 仕様上、thinking blocks は最終 text/tool_use と同一 assistant message の content 配列の先頭に並ぶ必要があるので、新世代モデル (Opus 4.5+/Sonnet 4.6+) でツール使用ループに入ったとき signature 検証や thinking continuity が壊れる可能性がある。- これは pre-existing な分割パターン (text + tool_call の時点で既に複数 assistant message を生む) を Reasoning が継承した形であり、現行 production が動いている以上 Anthropic 側で許容されている可能性もある。ただし thinking signature の round-trip という本チケットの中核要件にとっては実 wire 上のリスクが残る。
- 対応案:
convert_items_to_messagesで「連続する assistant 系 Item (Reasoning + assistant_message + ToolCall) は一つの assistant message に統合する」事後マージを入れる、もしくはItem::Message{Assistant}受信時にpending_assistantを flush せず content_part を pending に追加してから flush する形に変える。
-
wire 構造を検証するテストが薄い Anthropic 側の
reasoning_with_signature_projects_thinking_part(request.rs:817-838) は thinking part の存在のみ検証し、message 配列内の位置・隣接 message との関係をチェックしていない。上記 follow-up と合わせて「Reasoning + assistant_message + ToolCall を含む history が単一 assistant message にまとまる」アサーションを足すと回帰検出に有効。 -
session-store:
signatureの round-trip テストが無いlogged_item.rs:267-287のround_trip_reasoning_preserves_encrypted_contentは signature を検証していない。フィールドを追加した以上、JSON シリアライズ → デシリアライズでsignatureが保持されることを 1 ケース足しておくと安全 (現状実装は通るはずだが、将来LoggedItem::Reasoningをいじったときの保険)。 -
flush_usage直後のEvent::ReasoningItem順序 Anthropic ではcontent_block_stopで BlockStop と同時に ReasoningItem が発火する。Worker 側は streaming 完了後にまとめて collector をtake_collected()するので順序問題はないが、scheme 側で 1 回のparse_with_stateから 2 イベントが返るパスは新しいので、念のため将来的にVec<Event>のサイズ前提で組まれた呼び出し側 (テスト含め) が無いか軽く確認しておくと安心。
Nits
crates/llm-worker/src/llm_client/event.rs:54のコメント「上位層(Worker / ReasoningItemCollector)はこれをItem::Reasoningとしてworker.historyに append する。」 — これは「Worker が ReasoningItemCollector 経由で取り出して」の方が実装と整合する (collector 自体は append しない)。worker.rs:144-145でreasoning_item_collector自体はWorkerに保持されているが、外部公開メソッドが無い (collect は内部のみ)。text_block_collector/tool_call_collectorも同パターンなので踏襲としては妥当。
判断
Approve with follow-up — 本チケットの要件 (signature を含む reasoning material を Item::Reasoning として history に commit し、scheme の送受信両方向で round-trip させる) は満たされており、tests・既存テストともパス。Anthropic wire 上で同一論理ターンが複数 assistant message に分割される件は要 follow-up だが、これは pre-existing なメッセージ束ね方の問題を Reasoning が継承した形で、本チケット範囲を超える。別チケット (世代別 keep/strip と合わせるのが自然) で対応するのが適切。