14 KiB
14 KiB
Review: メモリ機構 Phase 1 活動抽出
前提・要件の確認
- Trigger (input tokens 累積閾値、tool call カウント不採用):
Pod::cumulative_input_tokens_since(crates/pod/src/pod.rs:1254) でusage_historyのinput_total_tokensを集計し、memory.extract_threshold未設定時は no-op (pod.rs:1280)。tool call は不参照で要件適合。閾値の単位については後述の Non-blocking 参照 - Trigger (compact より前): Controller の post-run ブロック 4 箇所すべてで
try_post_run_extract→try_post_run_compactの順に呼ばれる (crates/pod/src/controller.rs:335,393,448,545)。要件どおり - 実行主体 (compact と同じ Worker spawn 機構を再利用、Pod は立てない):
run_extract_onceがllm_worker::Workerを直接組んでsystem_prompt/temperature/max_tokens/usage callback/interceptor を貼る構成 (pod.rs:1378-1404)。compact 経路 (pod.rs:1046-1077) と同じ素の Worker spawn パターン。Pod は立てていない - 入出力 (前回 Phase 1 以降の session log 範囲):
processed_through_history_len..current_history_lenをWorker::history()から切り出し (pod.rs:1369)、source.range = [start_entry, end_entry]は session-store の entry index で機械付与 (pod.rs:1417-1420) - 出力 schema (4 種候補配列、空配列許容、自由文不可):
ExtractedPayload(crates/memory/src/extract/payload.rs:13) がdecisions/discussions/attempts/requestsを保持し全 default 空。EXTRACT_SYSTEM_PROMPT (crates/memory/src/extract/prompt.rs:7) は schema 遵守と空配列許容、自由文禁止を明示。write_extractedツール 1 本のみ提供で「ツールで提出して終わる」枠組みに閉じている - pointer 永続化 (session-store 拡張点
LogEntry::Extension):LogEntry::Extension { ts, domain, payload }を新設 (crates/session-store/src/session_log.rs:175)、RestoredState.extensions: Vec<(String, Value)>を追加 (session_log.rs:223)、save_extensionヘルパー (crates/session-store/src/session.rs:333) を export。session-store 側に"memory.extract"定数もpayload構造の知識も持たせていない (replay は順番に積むだけ)。fold 責務はmemory::extract::fold_pointer(crates/memory/src/extract/pointer.rs:26) に閉じている。ドメイン純度の要件を満たす - 並走防止 (
extract_in_flight: AtomicBool、完了時再評価ループ):Pod::extract_in_flight: Arc<AtomicBool>(pod.rs:131) をcompare_exchangeでガード (pod.rs:1287)、完了時はloopで再評価 (pod.rs:1289-1314)。pending状態は持たず、完了時に閾値が落ちていれば自然に Skipped で抜けるため coalesce 相当が成立。要件どおり - 書き込み (
memory/_staging/<id>.json、source 機械付与、UUIDv7 可):write_staging(crates/memory/src/extract/staging.rs:40) が UUIDv7 ファイル名でStagingRecord { source, payload }を pretty JSON 書き出し。sourceは Pod 側でsession_id+rangeを付与し、LLM には推論させない (system prompt にも明示) - モデル設定 (
memory.extract_model+ 副次設定):MemoryConfigにextract_model/extract_threshold/extract_worker_max_input_tokensの 3 つを追加 (crates/manifest/src/lib.rs:75-86)、cascade merge も対称 (crates/manifest/src/config.rs:215-219)、extract_worker_max_input_tokensの default 定数も追加 (crates/manifest/src/defaults.rs:50)。extract_model未設定時は main client のclone_boxed()にフォールバック (pod.rs:1238-1247) - prompt 要件 (
docs/plan/memory-prompts.md§Phase 1):EXTRACT_SYSTEM_PROMPTは §共通原則 (source 推論禁止、空出力許容) と §Phase 1 (派生物作成禁止、4 種限定、自由文禁止、shallow 除外) を網羅。#共通原則の「既存 docs と重複保存しない」も "Do not duplicate content already captured by static project docs" として反映済み
完了条件の確認
- 閾値超過で発火し staging file 作成:
run_extract_onceの最後でwrite_staging→ pointer save。空 payload でも pointer は前進し、staging 書き込みのみ skip という分岐が pointer.rs:19 のコメントと整合 - schema 準拠 +
source機械付与:WriteExtractedToolがfrom_str::<ExtractedPayload>で受け、Pod 側でSourceRefを wrap (pod.rs:1417-1422)。LLM 側に source は渡らない - 抽出対象なしは空配列 / skip どちらでも可: 現実装は空配列でも skip (空時は staging file を作らず、pointer だけ前進)。仕様上どちらでも良いとある通り
- session 側 pointer 更新で続きから走る:
save_extensionでEXTRACT_DOMAIN永続化 + 同期して in-memoryextract_pointer更新 (pod.rs:1442-1445)。restore 時はfold_pointerで最新値を取り出す (pod.rs:254) - 既存 compact の動作に回帰なし: pod 141 / session-store 16 / memory 82 / manifest 75 全 pass、compact のテストは無改変
アーキテクチャ・スコープ
- session-store ドメイン純度:
LogEntry::Extensionは domain 文字列を不透明に扱い、payload はserde_json::Valueのまま積むだけ。"memory.extract"定数もExtractPointerPayloadの構造も memory crate に閉じている。session-store 単体テスト (session_log.rs:644-700) も memory ドメインを知らない汎用テストになっており、設計通りの分離が取れている - memory crate 内のモジュール分割:
extract/{mod,input,payload,pointer,prompt,staging,tool}.rsで関心ごとに 1 ファイルずつ切れている。mod.rsの re-export と doc が綺麗。pod.rsに直書きせず memory 側に責務が寄っており、好ましい - Pod の責務: trigger 判定・worker spawn・source 機械付与・pointer 永続化のみ。
memory::extract::*を呼ぶだけで自分は schema/prompt/tool を知らない。範囲外(Phase 2 / staging cleanup / compact spawn 機構の共通化 / Phase 2 並走防止ファイル)には手を出していない - compact との並列性:
MemoryExtractWorkerInterceptorはCompactWorkerInterceptorのミラー実装。共通化していないが、ticket §範囲外で「compact Worker spawn 機構自体の拡張」を明確に除外しているので OK。ただし将来 3 個目が出るなら共通化候補 - manifest 設定の cascade:
MemoryConfig::merge_with_upperに追加 3 フィールド全てを忘れず追記済み (config.rs:215-219)、defaults.rs に定数化、lib.rs にOptionの意図 doc を追加とパターン遵守 - 依存追加:
memory/Cargo.tomlにuuid = { version = "1.23.1", features = ["v7", "serde"] }追加。バージョンとフォーマットはcargo addの出力体裁
指摘事項
Blocking
- Compact 後に extract_pointer が陳腐化して Phase 1 が止まる —
crates/pod/src/pod.rs:1180-1216のcompact()はsession_idを新しいコンパクト後セッションへ swap しusage_historyをclear()しているが、extract_pointer(in-memory) は古いセッションのprocessed_through_history_len(典型的には 50+)を保持したまま。次回以降cumulative_input_tokens_since(processed_history_len)の filterr.history_len > history_len_pointerは新しいセッションの低いhistory_lenをすべて除外し、永久に 0 を返す。結果としてプロセス継続中に compact が走ると、その後 Phase 1 は二度と発火しない。fix はcompact()の swap 直後(usage_history.clear()の隣)で*self.extract_pointer.lock() = Noneを行うのが最小。新セッション側の log には Extension entry がまだ無いので、cold restore でもfold_pointer = Noneになっており、cold restore と挙動を一致させられる- 対応済み (2026-04-28):
Pod::compact()(crates/pod/src/pod.rs:1217-1226) でusage_history.clear()の直後に*self.extract_pointer.lock() = Noneを実行。意図と cold restore 一致を doc コメントに明記。
- 対応済み (2026-04-28):
- 完了条件「session 側の処理済み pointer が更新され、次回 Phase 1 は続きから走る」が compact を挟むと崩れる — 上記 Blocking の直接の帰結。テストでは現れていないが、compact + extract の組合せシナリオを想定したテストがあれば検出できる
- 対応済み (2026-04-28):
Pod::extract_pointer() -> Option<ExtractPointerPayload>accessor を追加し、回帰テストcompact_resets_extract_pointer_so_phase1_can_fire_again(crates/pod/tests/compact_events_test.rs:319-381) を追加。extract → compact の連続シナリオで pointer が None に戻ることを検証。
- 対応済み (2026-04-28):
Non-blocking / Follow-up
- 閾値の単位(cumulative
input_total_tokensの解釈) —UsageRecord.input_total_tokensは「そのリクエスト送信時のプロンプト全長」(session-store/src/session_log.rs:147-149) で増分ではない。pointer 以降の records を素直に sum すると、長い 1 turn 内の連続 LLM call では各 prompt が前 prompt の super-set なので合計は実トークン消費の数倍に膨らむ。ticket / 設計の「cumulative input tokens since last pointer」をどう取るかは複数解釈あるが、現実装は最もリベラル(=最も発火しやすい)解釈になっており、頻繁発火を許容する仕様前提と整合はする。今後 threshold の運用値を tune するときに「3 turn で hit する」程度の感覚と乖離するので、doc に "billed input cumulative" と明記するか、input_total_tokens - cache_readの差分版や、最後の record だけを見る (= 現プロンプト長) など別解釈に切り替えるか、いずれ運用観察で決める- 対応済み (2026-04-28):
Pod::tokens_added_since(history_len_pointer)をPod::total_tokens_at(now) - Pod::total_tokens_at(pointer)の差分計算に切り替え (crates/pod/src/pod.rs:1284-1294)。compact 側と同じ accounting (measured/interpolated/extrapolated) に乗るので、threshold 値は「pointer 以降に追加されたプロンプト全長」と素直に解釈できる。Pod::total_tokens_at(history_len)をcompact::token_counterに追加 (pubAPI、将来別チケットで llm-worker に下ろす予定)。
- 対応済み (2026-04-28):
build_extract_inputが ToolCall 引数と ToolResult 本文を落とす —crates/memory/src/extract/input.rs:40-45。compact 用build_summary_promptのミラーだが、Phase 1 のattempts抽出は「何を試したか」が tool 引数(書き換えた箇所、実行コマンド)に集中することが多く、tool 名だけだと "ran read_file" レベルの粒度になる。tool 結果 summary は残るので致命ではないが、Phase 1 prompt のattempts.action品質を観察してから decide。共通ヘルパー化したいなら memory crate へ持って行く整理も将来検討- 空 payload で pointer だけ前進する設計の妥当性 —
pod.rs:1414-1424でpayload.is_empty()なら staging を作らず pointer だけ進める。spec §完了条件の「空配列で書き出す または skip、どちらでもよい」に合致。一方LogEntry::Extensionは payload を増やし続けるので、空 turn が連続すると Extension entry が積み上がる。session 寿命なので Phase 1 の頻度なら許容範囲だが、log size 監視のときに見える要素として把握しておく write_extractedが呼ばれずに worker が終了したケースの取り扱い —pod.rs:1406-1412で warn を出して空 payload 扱い、pointer は前進。再実行で同 range を再抽出できないので、ここで pointer を前進させる選択は LLM 不安定時に情報を取りこぼす可能性がある一方、無限ループ防止という意味では合理的。仕様上は "skip でも空配列でもよい" なので問題ないが、運用でwrite_extracted呼び忘れが頻発するなら spec 変更(pointer 据え置きで再 trigger)が選択肢- Controller 側の
if let Err(e)分岐は到達不能 —try_post_run_extractは最後に internalself.alertした上で常にOk(())を返すので、controller のif let Err(e) = pod.try_post_run_extract()は dead branch。try_post_run_compactも同様で先例を踏襲しているだけだが、両方とも内部 alert だけに統一するか、controller-only alert にするかの整理は別 PR で
Nits
pointer.rs:80-90の malformed entry テスト: コメント「壊れた最新を黙って無視すると意図しない再抽出を招く」は妥当な保守姿勢。ただし複数 Extension entries がある中でたまたま最新だけ malformed の場合は古い良 entry に fallback したほうが安全とも言える。現状で良いが、Phase 2 の stagings 消費と接続するときに再度 visittool.rs:42-48のcall_countは debug 用のみで Pod 側からは未使用。残しておくならいずれ logging で警告に繋ぐpod.rs:1339直前の comment「history_len_pointer == 0means everything so far」は「history_len > 0 のすべての record」という意味で正しいが、説明がcumulative_input_tokens_since(0)と読むと若干誤読しやすい。doc に「pointer 0 = 未抽出」と書き換えると親切MEMORY_EXTRACT_WORKER_MAX_INPUT_TOKENS = 30_000の default 値はコメントに根拠を 1 行付記しても良い (compact が 50_000 なので、抽出は context 圧縮後の slice であってより小さくて足りる、という意図か)
判断
Request changes → 対応済み (2026-04-28) — Blocking 指摘事項 (compact 後の extract_pointer リセット漏れ) は crates/pod/src/pod.rs:1217-1226 で fix し、crates/pod/tests/compact_events_test.rs:319-381 に回帰テストを追加して再発防止を担保した。workspace 全テスト pass。Non-blocking / Nits は仕様準拠と保守姿勢の範囲で別チケット / 運用観察に委ねる方針で完了とできる。