diff --git a/docs/plan-interview-improvements.md b/docs/plan-interview-improvements.md new file mode 100644 index 000000000..e083dfe48 --- /dev/null +++ b/docs/plan-interview-improvements.md @@ -0,0 +1,112 @@ +# /plan interview 改善検討 + +## 現象 + +`/plan ` でinterviewモードに入った後、AIが: + +1. MEMORY.mdに何も書かずに会話だけ続ける +2. interviewを無視して実装を始めようとする(exec, ファイル書き込み) +3. 結局フェーズ/ステップ/コマンドが書かれないまま executing に遷移する + +## 原因分析 + +### Layer 1: 技術的障壁(修正済み) + +| 問題 | 原因 | 修正 | +|---|---|---| +| XML tool callがパースされない | MiniMaxの開閉タグ不一致(`` vs ``) | regex + normalizeAlpha + 編集距離で fuzzy matching | +| tool名が不一致で実行失敗 | `readfile` vs `read_file` | `ToolRegistry.Get()` に normalizeAlpha フォールバック | +| interview許可リストも完全一致 | `isToolAllowedDuringInterview` が exact match | normalizeAlpha で比較 | + +### Layer 2: AI行動の問題(未対応) + +技術的障壁を除去しても、モデル(特に小規模モデル)の命令追従に起因する問題が残る: + +- **「会話しながらファイル編集」が複合タスクとして難しい** — interview中にMEMORY.mdを更新する行為は、会話とファイル操作の並行処理。小さいモデルにはハードルが高い +- **AIがワークフローを無視して実装に走る** — interview指示よりも「ユーザーの要求を直接解決しよう」というバイアスが強い +- **tool callイテレーション中に目的を忘れる** — read_fileでファイルを読み始めると、そのまま実装に入ろうとする + +## 検討した案と判断 + +### 案: ユーザー発言の自動追記(却下) + +MEMORY.mdにユーザー発言を自動追記 → ちゃんとしたplanにはならない。生データの蓄積であって構造化された計画ではない。 + +### 案: 専用tool `save_context`(却下) + +tool仕様を変えても、AIがtoolを適切に呼ばない根本問題は解決しない。 + +### 案: 別パスでplan生成LLMコール(却下) + +AIが「情報が揃った」と判断するトリガーの設計が難しい。キーワード検出は不安定。 + +### 案: `/plan draft` コマンド(却下) + +ユーザーがトリガーできても、その前にAIがワークフローを無視して実装を始める問題は残る。また状態とコマンドが増えてユーザーが混乱する。 + +### 案: 毎ターン固定メッセージ注入(却下) + +探索的なマルチターン会話で誤爆する。AIがファイルを読んだりリサーチしている途中のターンで「edit_fileしろ」は邪魔。 + +## 採用方針: tool callイテレーション内リマインド + +### 既知の知見 + +通常の開発モード(executing)で、tool callが反復される中でユーザー指示が忘れられる問題に対し、リマインド注入で自律開発がスムーズに進むようになった実績がある。同じパターンをinterview中にも適用する。 + +### 設計 + +**1. interviewフェーズのtool制限(実装済み)** + +``` +許可: read_file, list_dir, web_search, web_fetch +許可: edit_file / write_file / append_file(MEMORY.mdのみ) +ブロック: exec, その他write系 +``` + +AIが実装に走ろうとしても物理的にできない。 + +**2. tool callイテレーション内でリマインド注入(未実装)** + +`runLLMIteration` 内で、tool結果をLLMに返す直前(= 次のLLMコールの直前)にリマインドを差し込む。 + +```go +// tool結果メッセージの後、次のLLMコール前 +if isPlanPreExecution(agent.ContextBuilder.GetPlanStatus()) { + messages = append(messages, providers.Message{ + Role: "user", + Content: "[System] You are interviewing. Ask questions and save findings " + + "to ## Context in memory/MEMORY.md. " + + "When ready, write ## Phase and ## Commands sections.", + }) +} +``` + +- **注入タイミング**: tool callループ内のみ。ユーザーとの会話ターンには入れない +- **注入条件**: interviewing または review 状態の時 +- **内容**: 固定。短く、具体的に何をすべきか指示 + +**3. 状態遷移は既存のまま** + +``` +/plan → interviewing(AIが質問、read系+MEMORY.md書き込み許可) + → AIがStatus:executingに変更しようとする + → システムがreviewに横取り(phases > 0 の場合) + → ユーザーにplan表示 +/plan start → executing(全toolアンロック) +``` + +新しい状態・新しいコマンドなし。 + +## 実装タスク + +- [ ] `runLLMIteration` 内のtool callループにリマインド注入を追加 +- [ ] リマインド内容をステータスごとに分岐(interviewing / review / executing) +- [ ] 既存のstaleness nudge(2ターン無更新で警告)との統合・整理 +- [ ] テスト追加 + +## 未解決の懸念 + +- **リマインドの効果がモデル依存**: 大きいモデルには効くが、小さいモデルでは無視される可能性 +- **リマインドの頻度**: 毎イテレーション注入でトークン消費が増える(ただし1行程度なので軽微) +- **interview→plan書き込みのタイミング**: AIが「もう十分」と判断する基準はモデル任せ。staleness nudgeが補助するが確実ではない diff --git a/pkg/utils/string_test.go b/pkg/utils/string_test.go new file mode 100644 index 000000000..dff69369e --- /dev/null +++ b/pkg/utils/string_test.go @@ -0,0 +1,125 @@ +package utils + +import ( + "strings" + "testing" +) + +// --- StripThinkBlocks --- + +func TestStripThinkBlocks_ClosedBlock(t *testing.T) { + in := "\nsecret reasoning\n\n\nVisible content" + got := StripThinkBlocks(in) + if got != "Visible content" { + t.Fatalf("StripThinkBlocks() = %q, want %q", got, "Visible content") + } +} + +func TestStripThinkBlocks_UnclosedBlock(t *testing.T) { + in := "reasoning that never ends\nmore reasoning" + got := StripThinkBlocks(in) + if got != "" { + t.Fatalf("StripThinkBlocks() = %q, want empty", got) + } +} + +func TestStripThinkBlocks_MultipleBlocks(t *testing.T) { + in := "firstmiddlesecondend" + got := StripThinkBlocks(in) + if got != "middleend" { + t.Fatalf("StripThinkBlocks() = %q, want %q", got, "middleend") + } +} + +func TestStripThinkBlocks_NoBlocks(t *testing.T) { + in := "plain text without think blocks" + got := StripThinkBlocks(in) + if got != in { + t.Fatalf("StripThinkBlocks() = %q, want %q", got, in) + } +} + +func TestStripThinkBlocks_CaseInsensitive(t *testing.T) { + in := "upper casevisible" + got := StripThinkBlocks(in) + if got != "visible" { + t.Fatalf("StripThinkBlocks() = %q, want %q", got, "visible") + } +} + +func TestStripThinkBlocks_ClosedThenUnclosed(t *testing.T) { + in := "closedmiddleunclosed tail" + got := StripThinkBlocks(in) + if got != "middle" { + t.Fatalf("StripThinkBlocks() = %q, want %q", got, "middle") + } +} + +// --- DetectRepetitionLoop --- + +func TestDetectRepetitionLoop_HighRepetition(t *testing.T) { + // Repeat a short phrase many times → should be detected + phrase := "結構本格的なコード" + repeated := strings.Repeat(phrase, 300) + if !DetectRepetitionLoop(repeated) { + t.Fatal("DetectRepetitionLoop should return true for highly repetitive text") + } +} + +func TestDetectRepetitionLoop_NormalText(t *testing.T) { + // Normal varied text should not trigger + normal := "The quick brown fox jumps over the lazy dog. " + + "Pack my box with five dozen liquor jugs. " + + "How vexingly quick daft zebras jump. " + + "Sphinx of black quartz, judge my vow. " + + "Two driven jocks help fax my big quiz. " + + "The five boxing wizards jump quickly. " + + "Jackdaws love my big sphinx of quartz. " + + "Grumpy wizards make a toxic brew for the jovial queen." + // Extend to be long enough + long := strings.Repeat(normal+" ", 10) + if DetectRepetitionLoop(long) { + t.Fatal("DetectRepetitionLoop should return false for normal text") + } +} + +func TestDetectRepetitionLoop_ShortText(t *testing.T) { + // Text shorter than N-gram size should never trigger + if DetectRepetitionLoop("short") { + t.Fatal("DetectRepetitionLoop should return false for short text") + } +} + +func TestDetectRepetitionLoop_EmptyString(t *testing.T) { + if DetectRepetitionLoop("") { + t.Fatal("DetectRepetitionLoop should return false for empty string") + } +} + +func TestDetectRepetitionLoop_SingleCharRepeat(t *testing.T) { + // "aaaa..." repeated → only 1 unique N-gram → detected + repeated := strings.Repeat("あ", 2500) + if !DetectRepetitionLoop(repeated) { + t.Fatal("DetectRepetitionLoop should return true for single-char repetition") + } +} + +func TestDetectRepetitionLoop_BelowSampleSize(t *testing.T) { + // Repetitive but under sample size still detected + phrase := "abcdefghij" + repeated := strings.Repeat(phrase, 50) // 500 chars + if !DetectRepetitionLoop(repeated) { + t.Fatal("DetectRepetitionLoop should return true for repetitive text below sample size") + } +} + +// --- Truncate --- + +func TestTruncate(t *testing.T) { + if got := Truncate("hello", 10); got != "hello" { + t.Errorf("Truncate short = %q", got) + } + if got := Truncate("hello world!", 8); got != "hello..." { + t.Errorf("Truncate long = %q", got) + } +}