feat(tasks-2): implement subagent orchestration container model

Add Container Model for subagent↔conductor communication:
- ContainerMessage + channel-based escalation (ask_conductor, submit_plan)
- Deliberate vs Exploratory preset workflows with SubagentPlanState
- AnswerSubagentTool + ReviewSubagentPlanTool for conductor side
- SessionRecorder extensions (RecordQuestion, RecordPlanSubmit)
- Environment context injection (MEMORY.md extraction, plan context)
- Orchestration guidance with escalation tools documentation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
dj-oyu 2026-03-05 01:36:25 +09:00
parent 75ea77e206
commit 7b70d70252
18 changed files with 5116 additions and 514 deletions

View file

@ -19,12 +19,24 @@ Lint: `golangci-lint run`
- **Interview tool filtering**: `interviewAllowedTools` in `pkg/agent/loop.go` is the single source of truth for tools available during interview/review phases. Both `filterInterviewTools` (strips definitions before LLM call) and `isToolAllowedDuringInterview` (argument-level gating) reference this map. - **Interview tool filtering**: `interviewAllowedTools` in `pkg/agent/loop.go` is the single source of truth for tools available during interview/review phases. Both `filterInterviewTools` (strips definitions before LLM call) and `isToolAllowedDuringInterview` (argument-level gating) reference this map.
- **History clear**: `/plan start clear` wipes session history and summary on transition to executing. The Mini App review UI offers two sliders: standard approve and approve-with-clear. - **History clear**: `/plan start clear` wipes session history and summary on transition to executing. The Mini App review UI offers two sliders: standard approve and approve-with-clear.
## Subagent Orchestration (実装済み部分) ## Subagent Orchestration (実装済み)
- **Startup flag**: `--orchestration` で on/off。`SubagentsConfig.Enabled` で gate。 - **Startup flag**: `--orchestration` で on/off。`SubagentsConfig.Enabled` で gate。
- **Conductor identity**: orchestration 有効時に conductor identity + spawn/subagent guidance を system prompt へ注入。 - **Conductor identity**: orchestration 有効時に conductor identity + spawn/subagent guidance を system prompt へ注入。
- **Sandbox/Spawn**: `pkg/tools/sandbox.go`, `pkg/tools/spawn.go` 実装済み。 - **Sandbox/Spawn**: `pkg/tools/sandbox.go`, `pkg/tools/spawn.go` 実装済み。
- **AgentReporter**: `orch.AgentReporter` / `orch.Noop` / `orch.Broadcaster` で統一。main/heartbeat/subagent 全セッションが同一 Broadcaster に発火。Mini App は `agentLoop.GetOrchBroadcaster()``handler.SetOrchBroadcaster()` で受信。 - **AgentReporter**: `orch.AgentReporter` / `orch.Noop` / `orch.Broadcaster` で統一。main/heartbeat/subagent 全セッションが同一 Broadcaster に発火。Mini App は `agentLoop.GetOrchBroadcaster()``handler.SetOrchBroadcaster()` で受信。
- **Container Model (Q&A escalation)**:
- `ContainerMessage` + `inCh`/`outCh` channels on `SubagentTask` — deliberate preset (coder/worker/coordinator) のみ
- `ask_conductor` tool — subagent → conductor question (blocking)
- `answer_subagent` tool — conductor → subagent answer
- `submit_plan` tool — subagent → conductor plan review (blocking)
- `review_subagent_plan` tool — conductor → subagent approve/reject
- `PendingQuestions()` で conductor LLM loop に question/plan_review を注入
- **Deliberate Plan Mode**: `SubagentPlanState` (Clarifying → Review → Executing → Completed)
- `runDeliberateTask()`: clarifying phase (ask_conductor + submit_plan のみ) → executing phase (全ツール)
- `runExploratoryTask()`: exploratory preset の single-phase loop
- **Environment injection**: `extractPlanContext()` で MEMORY.md から Context/Commands/Orchestration セクションを抽出 → subagent system prompt に注入
- **SessionRecorder 拡張**: `RecordQuestion()` / `RecordPlanSubmit()` + `TurnQuestion` / `TurnPlanSubmit` TurnKind
## Session DAG (Phase 03 実装済み) ## Session DAG (Phase 03 実装済み)
@ -61,7 +73,7 @@ Lint: `golangci-lint run`
| ファイル | 概要 | | ファイル | 概要 |
|---|---| |---|---|
| [`todo/TASKS-1.md`](todo/TASKS-1.md) | ~~**Memory & Performance Optimization**~~ ✅ 実装済みMemoryStore キャッシュ+パース済み state、FunctionCall.Arguments map統一、ToolDefinition.Parameters RawMessage化、検索結果フォーマット共通化、stats 定期フラッシュ) | | [`todo/TASKS-1.md`](todo/TASKS-1.md) | ~~**Memory & Performance Optimization**~~ ✅ 実装済みMemoryStore キャッシュ+パース済み state、FunctionCall.Arguments map統一、ToolDefinition.Parameters RawMessage化、検索結果フォーマット共通化、stats 定期フラッシュ) |
| [`todo/TASKS-2.md`](todo/TASKS-2.md) | **Subagent Orchestration (Container Model)** — SubagentContainer、Orchestrator、Presets enforcement、Subagent Plan ModeTASKS-1 の型変更前提メモ追記済み | | [`todo/TASKS-2.md`](todo/TASKS-2.md) | ~~**Subagent Orchestration (Container Model)**~~ ✅ 実装済みContainer Q&A escalation、Deliberate Plan Mode、Environment injection、SessionRecorder 拡張 |
| [`todo/TASKS-3.md`](todo/TASKS-3.md) | ~~**Session DAG (SQLite Store)**~~ ✅ 実装済みPhase 03: SQLite SessionStore、LegacyAdapter、Fork/Report、CompactOldTurns、`/session` CLI コマンド、Mini App グラフ UI | | [`todo/TASKS-3.md`](todo/TASKS-3.md) | ~~**Session DAG (SQLite Store)**~~ ✅ 実装済みPhase 03: SQLite SessionStore、LegacyAdapter、Fork/Report、CompactOldTurns、`/session` CLI コマンド、Mini App グラフ UI |
| [`todo/TASKS-4.md`](todo/TASKS-4.md) | **Mini App & Static Serving** — 静的配信の汎用化、バンドラ導入、フロントエンドテスト追加 | | [`todo/TASKS-4.md`](todo/TASKS-4.md) | **Mini App & Static Serving** — 静的配信の汎用化、バンドラ導入、フロントエンドテスト追加 |
| [`todo/TASKS-5.md`](todo/TASKS-5.md) | ~~**Heartbeat Worktree Management**~~ ✅ 実装済み(`/plan worktrees``list/inspect/merge/dispose`、安全化した `PruneOrphaned`、Mini App `/miniapp/api/worktrees` + Git タブ UI | | [`todo/TASKS-5.md`](todo/TASKS-5.md) | ~~**Heartbeat Worktree Management**~~ ✅ 実装済み(`/plan worktrees``list/inspect/merge/dispose`、安全化した `PruneOrphaned`、Mini App `/miniapp/api/worktrees` + Git タブ UI |

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -7,6 +7,7 @@ import (
) )
// sessionRecorderImpl bridges tools.SessionRecorder → session.SessionStore. // sessionRecorderImpl bridges tools.SessionRecorder → session.SessionStore.
type sessionRecorderImpl struct { type sessionRecorderImpl struct {
adapter *session.LegacyAdapter adapter *session.LegacyAdapter
} }
@ -19,40 +20,102 @@ func newSessionRecorder(adapter *session.LegacyAdapter) *sessionRecorderImpl {
func (r *sessionRecorderImpl) RecordFork(conductorKey, subagentKey, taskID, label string) error { func (r *sessionRecorderImpl) RecordFork(conductorKey, subagentKey, taskID, label string) error {
store := r.adapter.Store() store := r.adapter.Store()
return store.Fork(conductorKey, subagentKey, &session.CreateOpts{ return store.Fork(conductorKey, subagentKey, &session.CreateOpts{
ForkTurnID: taskID, ForkTurnID: taskID,
Label: label,
Label: label,
}) })
} }
func (r *sessionRecorderImpl) RecordSubagentTurn(subagentKey string, messages []providers.Message) error { func (r *sessionRecorderImpl) RecordSubagentTurn(subagentKey string, messages []providers.Message) error {
store := r.adapter.Store() store := r.adapter.Store()
turn := &session.Turn{ turn := &session.Turn{
Kind: session.TurnNormal, Kind: session.TurnNormal,
Messages: messages, Messages: messages,
} }
return store.Append(subagentKey, turn) return store.Append(subagentKey, turn)
} }
func (r *sessionRecorderImpl) RecordCompletion(subagentKey, status, result string) error { func (r *sessionRecorderImpl) RecordCompletion(subagentKey, status, result string) error {
store := r.adapter.Store() store := r.adapter.Store()
return store.SetStatus(subagentKey, status) return store.SetStatus(subagentKey, status)
} }
func (r *sessionRecorderImpl) RecordReport(conductorKey, subagentKey, senderID, content string) error { func (r *sessionRecorderImpl) RecordReport(conductorKey, subagentKey, senderID, content string) error {
store := r.adapter.Store() store := r.adapter.Store()
turn := &session.Turn{ turn := &session.Turn{
Kind: session.TurnReport, Kind: session.TurnReport,
OriginKey: subagentKey, OriginKey: subagentKey,
Author: senderID,
Author: senderID,
Messages: []providers.Message{ Messages: []providers.Message{
{Role: "user", Content: content}, {Role: "user", Content: content},
}, },
} }
if err := store.Append(conductorKey, turn); err != nil { if err := store.Append(conductorKey, turn); err != nil {
return err return err
} }
// Advance LegacyAdapter's stored counter so flush loop doesn't double-write. // Advance LegacyAdapter's stored counter so flush loop doesn't double-write.
r.adapter.AdvanceStored(conductorKey, 1) r.adapter.AdvanceStored(conductorKey, 1)
return nil
}
func (r *sessionRecorderImpl) RecordQuestion(conductorKey, subagentKey, taskID, question string) error {
store := r.adapter.Store()
turn := &session.Turn{
Kind: session.TurnQuestion,
OriginKey: subagentKey,
Author: taskID,
Messages: []providers.Message{
{Role: "user", Content: question},
},
}
if err := store.Append(conductorKey, turn); err != nil {
return err
}
r.adapter.AdvanceStored(conductorKey, 1)
return nil
}
func (r *sessionRecorderImpl) RecordPlanSubmit(conductorKey, subagentKey, taskID, planText string) error {
store := r.adapter.Store()
turn := &session.Turn{
Kind: session.TurnPlanSubmit,
OriginKey: subagentKey,
Author: taskID,
Messages: []providers.Message{
{Role: "user", Content: planText},
},
}
if err := store.Append(conductorKey, turn); err != nil {
return err
}
r.adapter.AdvanceStored(conductorKey, 1)
return nil return nil
} }

View file

@ -11,15 +11,22 @@ import (
func newTestRecorder(t *testing.T) (*sessionRecorderImpl, *session.LegacyAdapter, session.SessionStore) { func newTestRecorder(t *testing.T) (*sessionRecorderImpl, *session.LegacyAdapter, session.SessionStore) {
t.Helper() t.Helper()
tmpDir := t.TempDir() tmpDir := t.TempDir()
dbPath := filepath.Join(tmpDir, "test.db") dbPath := filepath.Join(tmpDir, "test.db")
store, err := session.OpenSQLiteStore(dbPath) store, err := session.OpenSQLiteStore(dbPath)
if err != nil { if err != nil {
t.Fatalf("open store: %v", err) t.Fatalf("open store: %v", err)
} }
adapter := session.NewLegacyAdapter(store) adapter := session.NewLegacyAdapter(store)
t.Cleanup(func() { adapter.Close() }) t.Cleanup(func() { adapter.Close() })
recorder := newSessionRecorder(adapter) recorder := newSessionRecorder(adapter)
return recorder, adapter, store return recorder, adapter, store
} }
@ -27,6 +34,7 @@ func TestRecordFork(t *testing.T) {
rec, _, store := newTestRecorder(t) rec, _, store := newTestRecorder(t)
// Create conductor session first. // Create conductor session first.
if err := store.Create("conductor:main", nil); err != nil { if err := store.Create("conductor:main", nil); err != nil {
t.Fatalf("create conductor session: %v", err) t.Fatalf("create conductor session: %v", err)
} }
@ -37,31 +45,39 @@ func TestRecordFork(t *testing.T) {
} }
// Verify child session exists with correct parent. // Verify child session exists with correct parent.
info, err := store.Get("subagent:subagent-1") info, err := store.Get("subagent:subagent-1")
if err != nil { if err != nil {
t.Fatalf("Get child: %v", err) t.Fatalf("Get child: %v", err)
} }
if info == nil { if info == nil {
t.Fatal("child session not found") t.Fatal("child session not found")
} }
if info.ParentKey != "conductor:main" { if info.ParentKey != "conductor:main" {
t.Errorf("ParentKey = %q, want %q", info.ParentKey, "conductor:main") t.Errorf("ParentKey = %q, want %q", info.ParentKey, "conductor:main")
} }
if info.ForkTurnID != "subagent-1" { if info.ForkTurnID != "subagent-1" {
t.Errorf("ForkTurnID = %q, want %q", info.ForkTurnID, "subagent-1") t.Errorf("ForkTurnID = %q, want %q", info.ForkTurnID, "subagent-1")
} }
if info.Label != "scout" { if info.Label != "scout" {
t.Errorf("Label = %q, want %q", info.Label, "scout") t.Errorf("Label = %q, want %q", info.Label, "scout")
} }
// Verify parent lists child. // Verify parent lists child.
children, err := store.Children("conductor:main") children, err := store.Children("conductor:main")
if err != nil { if err != nil {
t.Fatalf("Children: %v", err) t.Fatalf("Children: %v", err)
} }
if len(children) != 1 { if len(children) != 1 {
t.Fatalf("children count = %d, want 1", len(children)) t.Fatalf("children count = %d, want 1", len(children))
} }
if children[0].Key != "subagent:subagent-1" { if children[0].Key != "subagent:subagent-1" {
t.Errorf("child key = %q, want %q", children[0].Key, "subagent:subagent-1") t.Errorf("child key = %q, want %q", children[0].Key, "subagent:subagent-1")
} }
@ -71,15 +87,19 @@ func TestRecordSubagentTurn(t *testing.T) {
rec, _, store := newTestRecorder(t) rec, _, store := newTestRecorder(t)
// Create subagent session. // Create subagent session.
if err := store.Create("subagent:subagent-1", nil); err != nil { if err := store.Create("subagent:subagent-1", nil); err != nil {
t.Fatalf("create: %v", err) t.Fatalf("create: %v", err)
} }
msgs := []providers.Message{ msgs := []providers.Message{
{Role: "system", Content: "You are a scout."}, {Role: "system", Content: "You are a scout."},
{Role: "user", Content: "Investigate X."}, {Role: "user", Content: "Investigate X."},
{Role: "assistant", Content: "Found Y."}, {Role: "assistant", Content: "Found Y."},
} }
if err := rec.RecordSubagentTurn("subagent:subagent-1", msgs); err != nil { if err := rec.RecordSubagentTurn("subagent:subagent-1", msgs); err != nil {
t.Fatalf("RecordSubagentTurn: %v", err) t.Fatalf("RecordSubagentTurn: %v", err)
} }
@ -88,15 +108,19 @@ func TestRecordSubagentTurn(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("Turns: %v", err) t.Fatalf("Turns: %v", err)
} }
if len(turns) != 1 { if len(turns) != 1 {
t.Fatalf("turns count = %d, want 1", len(turns)) t.Fatalf("turns count = %d, want 1", len(turns))
} }
if turns[0].Kind != session.TurnNormal { if turns[0].Kind != session.TurnNormal {
t.Errorf("Kind = %d, want TurnNormal", turns[0].Kind) t.Errorf("Kind = %d, want TurnNormal", turns[0].Kind)
} }
if len(turns[0].Messages) != 3 { if len(turns[0].Messages) != 3 {
t.Errorf("messages count = %d, want 3", len(turns[0].Messages)) t.Errorf("messages count = %d, want 3", len(turns[0].Messages))
} }
if turns[0].Messages[2].Content != "Found Y." { if turns[0].Messages[2].Content != "Found Y." {
t.Errorf("last message = %q, want %q", turns[0].Messages[2].Content, "Found Y.") t.Errorf("last message = %q, want %q", turns[0].Messages[2].Content, "Found Y.")
} }
@ -117,18 +141,23 @@ func TestRecordCompletion(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("Get: %v", err) t.Fatalf("Get: %v", err)
} }
if info.Status != "completed" { if info.Status != "completed" {
t.Errorf("Status = %q, want %q", info.Status, "completed") t.Errorf("Status = %q, want %q", info.Status, "completed")
} }
// Test failed status. // Test failed status.
if err := store.Create("subagent:subagent-2", nil); err != nil { if err := store.Create("subagent:subagent-2", nil); err != nil {
t.Fatalf("create: %v", err) t.Fatalf("create: %v", err)
} }
if err := rec.RecordCompletion("subagent:subagent-2", "failed", "error"); err != nil { if err := rec.RecordCompletion("subagent:subagent-2", "failed", "error"); err != nil {
t.Fatalf("RecordCompletion failed: %v", err) t.Fatalf("RecordCompletion failed: %v", err)
} }
info2, _ := store.Get("subagent:subagent-2") info2, _ := store.Get("subagent:subagent-2")
if info2.Status != "failed" { if info2.Status != "failed" {
t.Errorf("Status = %q, want %q", info2.Status, "failed") t.Errorf("Status = %q, want %q", info2.Status, "failed")
} }
@ -138,6 +167,7 @@ func TestRecordReport(t *testing.T) {
rec, adapter, store := newTestRecorder(t) rec, adapter, store := newTestRecorder(t)
// Create conductor session via adapter so it's in cache. // Create conductor session via adapter so it's in cache.
_ = adapter.GetOrCreate("conductor:main") _ = adapter.GetOrCreate("conductor:main")
if err := store.Create("subagent:subagent-1", nil); err != nil { if err := store.Create("subagent:subagent-1", nil); err != nil {
@ -145,27 +175,34 @@ func TestRecordReport(t *testing.T) {
} }
content := "[System: subagent:subagent-1] Task 'scout' completed.\n\nResult:\nFound Y." content := "[System: subagent:subagent-1] Task 'scout' completed.\n\nResult:\nFound Y."
if err := rec.RecordReport("conductor:main", "subagent:subagent-1", "subagent:subagent-1", content); err != nil { if err := rec.RecordReport("conductor:main", "subagent:subagent-1", "subagent:subagent-1", content); err != nil {
t.Fatalf("RecordReport: %v", err) t.Fatalf("RecordReport: %v", err)
} }
// Verify TurnReport in store. // Verify TurnReport in store.
turns, err := store.Turns("conductor:main", 0) turns, err := store.Turns("conductor:main", 0)
if err != nil { if err != nil {
t.Fatalf("Turns: %v", err) t.Fatalf("Turns: %v", err)
} }
if len(turns) != 1 { if len(turns) != 1 {
t.Fatalf("turns count = %d, want 1", len(turns)) t.Fatalf("turns count = %d, want 1", len(turns))
} }
if turns[0].Kind != session.TurnReport { if turns[0].Kind != session.TurnReport {
t.Errorf("Kind = %d, want TurnReport(%d)", turns[0].Kind, session.TurnReport) t.Errorf("Kind = %d, want TurnReport(%d)", turns[0].Kind, session.TurnReport)
} }
if turns[0].OriginKey != "subagent:subagent-1" { if turns[0].OriginKey != "subagent:subagent-1" {
t.Errorf("OriginKey = %q, want %q", turns[0].OriginKey, "subagent:subagent-1") t.Errorf("OriginKey = %q, want %q", turns[0].OriginKey, "subagent:subagent-1")
} }
if turns[0].Author != "subagent:subagent-1" { if turns[0].Author != "subagent:subagent-1" {
t.Errorf("Author = %q, want %q", turns[0].Author, "subagent:subagent-1") t.Errorf("Author = %q, want %q", turns[0].Author, "subagent:subagent-1")
} }
if len(turns[0].Messages) != 1 || turns[0].Messages[0].Role != "user" { if len(turns[0].Messages) != 1 || turns[0].Messages[0].Role != "user" {
t.Errorf("unexpected messages: %v", turns[0].Messages) t.Errorf("unexpected messages: %v", turns[0].Messages)
} }
@ -175,6 +212,7 @@ func TestAdvanceStoredPreventsDoubleWrite(t *testing.T) {
rec, adapter, store := newTestRecorder(t) rec, adapter, store := newTestRecorder(t)
// Create conductor session via adapter. // Create conductor session via adapter.
_ = adapter.GetOrCreate("conductor:main") _ = adapter.GetOrCreate("conductor:main")
if err := store.Create("subagent:subagent-1", nil); err != nil { if err := store.Create("subagent:subagent-1", nil); err != nil {
@ -182,57 +220,157 @@ func TestAdvanceStoredPreventsDoubleWrite(t *testing.T) {
} }
// Simulate: conductor has 2 messages already flushed. // Simulate: conductor has 2 messages already flushed.
adapter.AddMessage("conductor:main", "user", "hello") adapter.AddMessage("conductor:main", "user", "hello")
adapter.AddMessage("conductor:main", "assistant", "hi") adapter.AddMessage("conductor:main", "assistant", "hi")
if err := adapter.Save("conductor:main"); err != nil { if err := adapter.Save("conductor:main"); err != nil {
t.Fatalf("Save: %v", err) t.Fatalf("Save: %v", err)
} }
// RecordReport writes directly to store and advances stored counter. // RecordReport writes directly to store and advances stored counter.
content := "[System: subagent:subagent-1] result" content := "[System: subagent:subagent-1] result"
if err := rec.RecordReport("conductor:main", "subagent:subagent-1", "subagent:subagent-1", content); err != nil { if err := rec.RecordReport("conductor:main", "subagent:subagent-1", "subagent:subagent-1", content); err != nil {
t.Fatalf("RecordReport: %v", err) t.Fatalf("RecordReport: %v", err)
} }
// The in-memory cache should also be updated (by loop.go calling AddFullMessage). // The in-memory cache should also be updated (by loop.go calling AddFullMessage).
// Simulate what loop.go does after RecordReport succeeds. // Simulate what loop.go does after RecordReport succeeds.
adapter.AddFullMessage("conductor:main", providers.Message{Role: "user", Content: content}) adapter.AddFullMessage("conductor:main", providers.Message{Role: "user", Content: content})
// AdvanceStored was already called by RecordReport, so stored = 3 + 1 = 4 // AdvanceStored was already called by RecordReport, so stored = 3 + 1 = 4
// but we added 1 message to cache making it len=4 as well. No double write. // but we added 1 message to cache making it len=4 as well. No double write.
// Save should NOT re-write the report turn. // Save should NOT re-write the report turn.
if err := adapter.Save("conductor:main"); err != nil { if err := adapter.Save("conductor:main"); err != nil {
t.Fatalf("Save after report: %v", err) t.Fatalf("Save after report: %v", err)
} }
// Count all turns in store for conductor session. // Count all turns in store for conductor session.
turns, err := store.Turns("conductor:main", 0) turns, err := store.Turns("conductor:main", 0)
if err != nil { if err != nil {
t.Fatalf("Turns: %v", err) t.Fatalf("Turns: %v", err)
} }
// Expected: turn 1 (initial 2 msgs), turn 2 (TurnReport from RecordReport) // Expected: turn 1 (initial 2 msgs), turn 2 (TurnReport from RecordReport)
// NOT turn 3 (duplicate from flush). // NOT turn 3 (duplicate from flush).
if len(turns) != 2 { if len(turns) != 2 {
t.Errorf("turns count = %d, want 2 (no double-write)", len(turns)) t.Errorf("turns count = %d, want 2 (no double-write)", len(turns))
for i, turn := range turns { for i, turn := range turns {
t.Logf(" turn[%d]: seq=%d kind=%d msgs=%d", i, turn.Seq, turn.Kind, len(turn.Messages)) t.Logf(" turn[%d]: seq=%d kind=%d msgs=%d", i, turn.Seq, turn.Kind, len(turn.Messages))
} }
} }
} }
func TestRecordQuestion(t *testing.T) {
rec, adapter, store := newTestRecorder(t)
// Create conductor session via adapter so it's in cache.
_ = adapter.GetOrCreate("conductor:main")
if err := store.Create("subagent:subagent-1", nil); err != nil {
t.Fatalf("create subagent: %v", err)
}
question := "What database schema should I use for the users table?"
if err := rec.RecordQuestion("conductor:main", "subagent:subagent-1", "subagent-1", question); err != nil {
t.Fatalf("RecordQuestion: %v", err)
}
turns, err := store.Turns("conductor:main", 0)
if err != nil {
t.Fatalf("Turns: %v", err)
}
if len(turns) != 1 {
t.Fatalf("turns count = %d, want 1", len(turns))
}
if turns[0].Kind != session.TurnQuestion {
t.Errorf("Kind = %d, want TurnQuestion(%d)", turns[0].Kind, session.TurnQuestion)
}
if turns[0].OriginKey != "subagent:subagent-1" {
t.Errorf("OriginKey = %q, want %q", turns[0].OriginKey, "subagent:subagent-1")
}
if turns[0].Author != "subagent-1" {
t.Errorf("Author = %q, want %q", turns[0].Author, "subagent-1")
}
if len(turns[0].Messages) != 1 || turns[0].Messages[0].Content != question {
t.Errorf("unexpected messages: %v", turns[0].Messages)
}
}
func TestRecordPlanSubmit(t *testing.T) {
rec, adapter, store := newTestRecorder(t)
_ = adapter.GetOrCreate("conductor:main")
if err := store.Create("subagent:subagent-1", nil); err != nil {
t.Fatalf("create subagent: %v", err)
}
planText := "Goal: Implement auth\nSteps:\n1. Add middleware\n2. Add JWT validation"
if err := rec.RecordPlanSubmit("conductor:main", "subagent:subagent-1", "subagent-1", planText); err != nil {
t.Fatalf("RecordPlanSubmit: %v", err)
}
turns, err := store.Turns("conductor:main", 0)
if err != nil {
t.Fatalf("Turns: %v", err)
}
if len(turns) != 1 {
t.Fatalf("turns count = %d, want 1", len(turns))
}
if turns[0].Kind != session.TurnPlanSubmit {
t.Errorf("Kind = %d, want TurnPlanSubmit(%d)", turns[0].Kind, session.TurnPlanSubmit)
}
if turns[0].OriginKey != "subagent:subagent-1" {
t.Errorf("OriginKey = %q, want %q", turns[0].OriginKey, "subagent:subagent-1")
}
if len(turns[0].Messages) != 1 || turns[0].Messages[0].Content != planText {
t.Errorf("unexpected messages: %v", turns[0].Messages)
}
}
func TestExtractTaskID(t *testing.T) { func TestExtractTaskID(t *testing.T) {
tests := []struct { tests := []struct {
input string input string
want string
want string
}{ }{
{"subagent:subagent-1", "subagent-1"}, {"subagent:subagent-1", "subagent-1"},
{"subagent:subagent-42", "subagent-42"}, {"subagent:subagent-42", "subagent-42"},
{"plain-id", "plain-id"}, {"plain-id", "plain-id"},
{"a:b:c", "c"}, {"a:b:c", "c"},
} }
for _, tt := range tests { for _, tt := range tests {
got := extractTaskID(tt.input) got := extractTaskID(tt.input)
if got != tt.want { if got != tt.want {
t.Errorf("extractTaskID(%q) = %q, want %q", tt.input, got, tt.want) t.Errorf("extractTaskID(%q) = %q, want %q", tt.input, got, tt.want)
} }
@ -241,5 +379,6 @@ func TestExtractTaskID(t *testing.T) {
func init() { func init() {
// Suppress log output in tests. // Suppress log output in tests.
os.Setenv("PICOCLAW_LOG_LEVEL", "error") os.Setenv("PICOCLAW_LOG_LEVEL", "error")
} }

View file

@ -19,6 +19,15 @@ const (
) )
// Escalation turn kinds — explicit values to keep stable across versions.
const (
TurnQuestion TurnKind = 10 // Subagent → conductor question (escalation)
TurnPlanSubmit TurnKind = 11 // Subagent plan submission for review
)
// Turn represents a single conversation turn persisted in the store. // Turn represents a single conversation turn persisted in the store.
type Turn struct { type Turn struct {

View file

@ -0,0 +1,138 @@
package tools
import (
"context"
"fmt"
)
// AnswerSubagentTool allows the conductor to answer a subagent's question.
type AnswerSubagentTool struct {
manager *SubagentManager
}
func NewAnswerSubagentTool(manager *SubagentManager) *AnswerSubagentTool {
return &AnswerSubagentTool{manager: manager}
}
func (t *AnswerSubagentTool) Name() string { return "answer_subagent" }
func (t *AnswerSubagentTool) Description() string {
return "Answer a subagent's question or escalation. The subagent is blocked waiting for your response."
}
func (t *AnswerSubagentTool) Parameters() map[string]any {
return map[string]any{
"type": "object",
"properties": map[string]any{
"task_id": map[string]any{
"type": "string",
"description": "The task ID of the subagent (e.g. subagent-1)",
},
"answer": map[string]any{
"type": "string",
"description": "Your answer to the subagent's question",
},
},
"required": []string{"task_id", "answer"},
}
}
func (t *AnswerSubagentTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
taskID, _ := args["task_id"].(string)
if taskID == "" {
return ErrorResult("required parameter \"task_id\" (string) is missing")
}
answer, _ := args["answer"].(string)
if answer == "" {
return ErrorResult("required parameter \"answer\" (string) is missing")
}
if t.manager == nil {
return ErrorResult("subagent manager not available")
}
if err := t.manager.AnswerQuestion(taskID, answer); err != nil {
return ErrorResult(fmt.Sprintf("failed to answer subagent: %v", err))
}
return &ToolResult{
ForLLM: fmt.Sprintf("Answer sent to %s.", taskID),
ForUser: fmt.Sprintf("Answered %s", taskID),
}
}
// ReviewSubagentPlanTool allows the conductor to approve/reject a subagent's plan.
type ReviewSubagentPlanTool struct {
manager *SubagentManager
}
func NewReviewSubagentPlanTool(manager *SubagentManager) *ReviewSubagentPlanTool {
return &ReviewSubagentPlanTool{manager: manager}
}
func (t *ReviewSubagentPlanTool) Name() string { return "review_subagent_plan" }
func (t *ReviewSubagentPlanTool) Description() string {
return "Approve or reject a subagent's execution plan. Use decision 'approved' to approve, or provide rejection feedback."
}
func (t *ReviewSubagentPlanTool) Parameters() map[string]any {
return map[string]any{
"type": "object",
"properties": map[string]any{
"task_id": map[string]any{
"type": "string",
"description": "The task ID of the subagent (e.g. subagent-1)",
},
"decision": map[string]any{
"type": "string",
"description": "Decision: 'approved' to approve, or rejection feedback text",
},
},
"required": []string{"task_id", "decision"},
}
}
func (t *ReviewSubagentPlanTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
taskID, _ := args["task_id"].(string)
if taskID == "" {
return ErrorResult("required parameter \"task_id\" (string) is missing")
}
decision, _ := args["decision"].(string)
if decision == "" {
return ErrorResult("required parameter \"decision\" (string) is missing")
}
if t.manager == nil {
return ErrorResult("subagent manager not available")
}
if err := t.manager.AnswerQuestion(taskID, decision); err != nil {
return ErrorResult(fmt.Sprintf("failed to send review decision: %v", err))
}
return &ToolResult{
ForLLM: fmt.Sprintf("Review decision '%s' sent to %s.", decision, taskID),
ForUser: fmt.Sprintf("Reviewed %s: %s", taskID, decision),
}
}

110
pkg/tools/ask_conductor.go Normal file
View file

@ -0,0 +1,110 @@
package tools
import (
"context"
"fmt"
)
// AskConductorTool allows a subagent to ask the conductor a question.
// The subagent blocks until the conductor answers via AnswerSubagentTool.
type AskConductorTool struct {
taskID string
conductorKey string
subagentKey string
outCh chan<- ContainerMessage
inCh <-chan string
recorder SessionRecorder
}
func NewAskConductorTool(
taskID, conductorKey, subagentKey string,
outCh chan<- ContainerMessage,
inCh <-chan string,
recorder SessionRecorder,
) *AskConductorTool {
return &AskConductorTool{
taskID: taskID,
conductorKey: conductorKey,
subagentKey: subagentKey,
outCh: outCh,
inCh: inCh,
recorder: recorder,
}
}
func (t *AskConductorTool) Name() string { return "ask_conductor" }
func (t *AskConductorTool) Description() string {
return "Ask the conductor a clarifying question. Blocks until the conductor responds. Use when you need guidance or a decision before proceeding."
}
func (t *AskConductorTool) Parameters() map[string]any {
return map[string]any{
"type": "object",
"properties": map[string]any{
"question": map[string]any{
"type": "string",
"description": "The question to ask the conductor",
},
},
"required": []string{"question"},
}
}
func (t *AskConductorTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
question, ok := args["question"].(string)
if !ok || question == "" {
return ErrorResult("required parameter \"question\" (string) is missing")
}
// Fire-and-forget: record question in session DAG.
if t.recorder != nil {
_ = t.recorder.RecordQuestion(t.conductorKey, t.subagentKey, t.taskID, question)
}
// Send question to conductor (blocking with ctx).
select {
case t.outCh <- ContainerMessage{Type: "question", Content: question, TaskID: t.taskID}:
case <-ctx.Done():
return ErrorResult(fmt.Sprintf("context canceled while sending question: %v", ctx.Err()))
}
// Wait for conductor's answer.
select {
case answer := <-t.inCh:
return &ToolResult{
ForLLM: fmt.Sprintf("Conductor answered: %s", answer),
ForUser: answer,
}
case <-ctx.Done():
return ErrorResult(fmt.Sprintf("context canceled while waiting for answer: %v", ctx.Err()))
}
}

View file

@ -0,0 +1,77 @@
package tools
import (
"context"
"testing"
"time"
)
func TestAskConductorTool_Execute(t *testing.T) {
outCh := make(chan ContainerMessage, 4)
inCh := make(chan string, 1)
tool := NewAskConductorTool("subagent-1", "conductor:main", "subagent:subagent-1", outCh, inCh, nil)
if tool.Name() != "ask_conductor" {
t.Errorf("Name() = %q, want %q", tool.Name(), "ask_conductor")
}
// Simulate conductor answering in background.
go func() {
msg := <-outCh
if msg.Type != "question" {
t.Errorf("msg.Type = %q, want %q", msg.Type, "question")
}
if msg.Content != "What port?" {
t.Errorf("msg.Content = %q, want %q", msg.Content, "What port?")
}
inCh <- "Use port 8080"
}()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
result := tool.Execute(ctx, map[string]any{"question": "What port?"})
if result.IsError {
t.Fatalf("unexpected error: %s", result.ForLLM)
}
if result.ForUser != "Use port 8080" {
t.Errorf("ForUser = %q, want %q", result.ForUser, "Use port 8080")
}
}
func TestAskConductorTool_MissingQuestion(t *testing.T) {
tool := NewAskConductorTool("subagent-1", "conductor:main", "subagent:subagent-1", nil, nil, nil)
result := tool.Execute(context.Background(), map[string]any{})
if !result.IsError {
t.Error("expected error for missing question")
}
}
func TestAskConductorTool_ContextCanceled(t *testing.T) {
outCh := make(chan ContainerMessage) // unbuffered, will block
inCh := make(chan string)
tool := NewAskConductorTool("subagent-1", "conductor:main", "subagent:subagent-1", outCh, inCh, nil)
ctx, cancel := context.WithCancel(context.Background())
cancel() // cancel immediately
result := tool.Execute(ctx, map[string]any{"question": "test?"})
if !result.IsError {
t.Error("expected error on canceled context")
}
}

View file

@ -16,4 +16,10 @@ type SessionRecorder interface {
// RecordReport injects a TurnReport into the conductor session. // RecordReport injects a TurnReport into the conductor session.
RecordReport(conductorSessionKey, subagentSessionKey, senderID, content string) error RecordReport(conductorSessionKey, subagentSessionKey, senderID, content string) error
// RecordQuestion injects a TurnQuestion into the conductor session (subagent escalation).
RecordQuestion(conductorKey, subagentKey, taskID, question string) error
// RecordPlanSubmit injects a TurnPlanSubmit into the conductor session (plan review request).
RecordPlanSubmit(conductorKey, subagentKey, taskID, planText string) error
} }

View file

@ -7,22 +7,29 @@ import (
) )
type SpawnTool struct { type SpawnTool struct {
manager *SubagentManager manager *SubagentManager
originChannel string
originChatID string originChannel string
originChatID string
allowlistCheck func(targetAgentID string) bool allowlistCheck func(targetAgentID string) bool
callback AsyncCallback // For async completion notification
callback AsyncCallback // For async completion notification
} }
func NewSpawnTool(manager *SubagentManager) *SpawnTool { func NewSpawnTool(manager *SubagentManager) *SpawnTool {
return &SpawnTool{ return &SpawnTool{
manager: manager, manager: manager,
originChannel: "cli", originChannel: "cli",
originChatID: "direct",
originChatID: "direct",
} }
} }
// SetCallback implements AsyncTool interface for async completion notification // SetCallback implements AsyncTool interface for async completion notification
func (t *SpawnTool) SetCallback(cb AsyncCallback) { func (t *SpawnTool) SetCallback(cb AsyncCallback) {
t.callback = cb t.callback = cb
} }
@ -38,31 +45,42 @@ func (t *SpawnTool) Description() string {
func (t *SpawnTool) Parameters() map[string]any { func (t *SpawnTool) Parameters() map[string]any {
return map[string]any{ return map[string]any{
"type": "object", "type": "object",
"properties": map[string]any{ "properties": map[string]any{
"task": map[string]any{ "task": map[string]any{
"type": "string", "type": "string",
"description": "The task for subagent to complete", "description": "The task for subagent to complete",
}, },
"label": map[string]any{ "label": map[string]any{
"type": "string", "type": "string",
"description": "Optional short label for the task (for display)", "description": "Optional short label for the task (for display)",
}, },
"agent_id": map[string]any{ "agent_id": map[string]any{
"type": "string", "type": "string",
"description": "Optional target agent ID to delegate the task to", "description": "Optional target agent ID to delegate the task to",
}, },
"preset": map[string]any{ "preset": map[string]any{
"type": "string", "type": "string",
"enum": []string{"scout", "analyst", "coder", "worker", "coordinator"},
"enum": []string{"scout", "analyst", "coder", "worker", "coordinator"},
"description": "Optional capability tier: scout (explore), analyst (analyze), coder (code), worker (build), coordinator (orchestrate)", "description": "Optional capability tier: scout (explore), analyst (analyze), coder (code), worker (build), coordinator (orchestrate)",
}, },
}, },
"required": []string{"task"}, "required": []string{"task"},
} }
} }
func (t *SpawnTool) SetContext(channel, chatID string) { func (t *SpawnTool) SetContext(channel, chatID string) {
t.originChannel = channel t.originChannel = channel
t.originChatID = chatID t.originChatID = chatID
} }
@ -72,20 +90,28 @@ func (t *SpawnTool) SetAllowlistChecker(check func(targetAgentID string) bool) {
func (t *SpawnTool) Execute(ctx context.Context, args map[string]any) *ToolResult { func (t *SpawnTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
task, ok := args["task"].(string) task, ok := args["task"].(string)
if !ok || strings.TrimSpace(task) == "" { if !ok || strings.TrimSpace(task) == "" {
return ErrorResult( return ErrorResult(
`Required parameter "task" (string) is missing. ` + `Required parameter "task" (string) is missing. ` +
`Example: {"task": "describe what you need done", "preset": "scout"}`, `Example: {"task": "describe what you need done", "preset": "scout"}`,
) )
} }
label, _ := args["label"].(string) label, _ := args["label"].(string)
agentID, _ := args["agent_id"].(string) agentID, _ := args["agent_id"].(string)
preset, _ := args["preset"].(string) preset, _ := args["preset"].(string)
// Check allowlist if targeting a specific agent ID. // Check allowlist if targeting a specific agent ID.
// Presets (scout, analyst, etc.) are NOT agent IDs — they are validated // Presets (scout, analyst, etc.) are NOT agent IDs — they are validated
// separately by IsValidPreset() in the subagent manager. // separately by IsValidPreset() in the subagent manager.
if agentID != "" && t.allowlistCheck != nil { if agentID != "" && t.allowlistCheck != nil {
if !t.allowlistCheck(agentID) { if !t.allowlistCheck(agentID) {
return ErrorResult(fmt.Sprintf("agent %q is not in the allowed agents list", agentID)) return ErrorResult(fmt.Sprintf("agent %q is not in the allowed agents list", agentID))
@ -93,9 +119,12 @@ func (t *SpawnTool) Execute(ctx context.Context, args map[string]any) *ToolResul
} }
// Validate preset name if provided // Validate preset name if provided
if preset != "" && !IsValidPreset(Preset(preset)) { if preset != "" && !IsValidPreset(Preset(preset)) {
return ErrorResult(fmt.Sprintf( return ErrorResult(fmt.Sprintf(
"preset %q is not valid. Available presets: scout, analyst, coder, worker, coordinator", "preset %q is not valid. Available presets: scout, analyst, coder, worker, coordinator",
preset, preset,
)) ))
} }
@ -105,11 +134,13 @@ func (t *SpawnTool) Execute(ctx context.Context, args map[string]any) *ToolResul
} }
// Pass callback to manager for async completion notification // Pass callback to manager for async completion notification
result, err := t.manager.Spawn(ctx, task, label, agentID, t.originChannel, t.originChatID, preset, t.callback) result, err := t.manager.Spawn(ctx, task, label, agentID, t.originChannel, t.originChatID, preset, t.callback)
if err != nil { if err != nil {
return ErrorResult(fmt.Sprintf("failed to spawn subagent: %v", err)) return ErrorResult(fmt.Sprintf("failed to spawn subagent: %v", err))
} }
// Return AsyncResult since the task runs in background // Return AsyncResult since the task runs in background
return AsyncResult(result) return AsyncResult(result)
} }

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,125 @@
package tools
import (
"testing"
)
func TestIsDeliberatePreset(t *testing.T) {
tests := []struct {
preset Preset
want bool
}{
{PresetScout, false},
{PresetAnalyst, false},
{PresetCoder, true},
{PresetWorker, true},
{PresetCoordinator, true},
{"unknown", false},
}
for _, tt := range tests {
got := isDeliberatePreset(tt.preset)
if got != tt.want {
t.Errorf("isDeliberatePreset(%q) = %v, want %v", tt.preset, got, tt.want)
}
}
}
func TestContainerMessageChannels(t *testing.T) {
// Simulate channel creation for a deliberate preset task.
task := &SubagentTask{
ID: "subagent-1",
inCh: make(chan string, 1),
outCh: make(chan ContainerMessage, 4),
}
// Subagent sends a question.
task.outCh <- ContainerMessage{
Type: "question",
Content: "Which DB schema?",
TaskID: task.ID,
}
// Drain pending messages.
var msgs []ContainerMessage
for {
select {
case msg := <-task.outCh:
msgs = append(msgs, msg)
default:
goto done
}
}
done:
if len(msgs) != 1 {
t.Fatalf("msgs count = %d, want 1", len(msgs))
}
if msgs[0].Type != "question" {
t.Errorf("Type = %q, want %q", msgs[0].Type, "question")
}
if msgs[0].Content != "Which DB schema?" {
t.Errorf("Content = %q, want %q", msgs[0].Content, "Which DB schema?")
}
// Conductor answers.
task.inCh <- "Use PostgreSQL"
answer := <-task.inCh
if answer != "Use PostgreSQL" {
t.Errorf("answer = %q, want %q", answer, "Use PostgreSQL")
}
}
func TestPendingQuestionsAndAnswerQuestion(t *testing.T) {
mgr := &SubagentManager{
tasks: map[string]*SubagentTask{
"subagent-1": {
ID: "subagent-1",
outCh: make(chan ContainerMessage, 4),
inCh: make(chan string, 1),
},
"subagent-2": {
ID: "subagent-2",
// No channels — exploratory preset.
},
},
}
// Send question from subagent-1.
mgr.tasks["subagent-1"].outCh <- ContainerMessage{
Type: "question",
Content: "What port?",
TaskID: "subagent-1",
}
msgs := mgr.PendingQuestions()
if len(msgs) != 1 {
t.Fatalf("pending count = %d, want 1", len(msgs))
}
if msgs[0].TaskID != "subagent-1" {
t.Errorf("TaskID = %q, want %q", msgs[0].TaskID, "subagent-1")
}
// Second call should return empty (already drained).
msgs2 := mgr.PendingQuestions()
if len(msgs2) != 0 {
t.Errorf("second pending count = %d, want 0", len(msgs2))
}
// Answer the question.
if err := mgr.AnswerQuestion("subagent-1", "8080"); err != nil {
t.Fatalf("AnswerQuestion: %v", err)
}
answer := <-mgr.tasks["subagent-1"].inCh
if answer != "8080" {
t.Errorf("answer = %q, want %q", answer, "8080")
}
// Answer non-existent task.
if err := mgr.AnswerQuestion("subagent-99", "x"); err == nil {
t.Error("expected error for non-existent task")
}
// Answer task without channels.
if err := mgr.AnswerQuestion("subagent-2", "x"); err == nil {
t.Error("expected error for task without escalation channel")
}
}

View file

@ -0,0 +1,192 @@
package tools
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestExtractPlanContext(t *testing.T) {
tmpDir := t.TempDir()
memDir := filepath.Join(tmpDir, "memory")
if err := os.MkdirAll(memDir, 0o755); err != nil {
t.Fatal(err)
}
memContent := `# Active Plan
> Task: Implement authentication
> Status: executing
> Phase: 1
## Context
The project uses JWT tokens for auth.
Database is PostgreSQL.
## Phase 1: Setup
- [x] Add middleware
- [ ] Add JWT validation
## Commands
build: go build ./...
test: go test ./...
## Orchestration
### Delegated
- auth-scout: investigate patterns
### Findings
- Found existing middleware in pkg/auth
`
if err := os.WriteFile(filepath.Join(memDir, "MEMORY.md"), []byte(memContent), 0o644); err != nil {
t.Fatal(err)
}
ctx := extractPlanContext(tmpDir)
if ctx == "" {
t.Fatal("extractPlanContext returned empty")
}
// Should contain the task line.
if !strings.Contains(ctx, "> Task: Implement authentication") {
t.Error("missing task line")
}
// Should contain Context section.
if !strings.Contains(ctx, "JWT tokens") {
t.Error("missing Context section content")
}
// Should contain Commands section.
if !strings.Contains(ctx, "go build") {
t.Error("missing Commands section content")
}
// Should contain Orchestration section.
if !strings.Contains(ctx, "auth-scout") {
t.Error("missing Orchestration section content")
}
}
func TestExtractPlanContext_NoFile(t *testing.T) {
ctx := extractPlanContext(t.TempDir())
if ctx != "" {
t.Errorf("expected empty for missing MEMORY.md, got %q", ctx)
}
}
func TestExtractSection(t *testing.T) {
content := `## Context
Some context here.
## Commands
build: go build
## Other
stuff`
section := extractSection(content, "## Context")
if !strings.Contains(section, "Some context here.") {
t.Errorf("Context section = %q, missing content", section)
}
if strings.Contains(section, "## Commands") {
t.Errorf("Context section leaked into next section")
}
section = extractSection(content, "## Commands")
if !strings.Contains(section, "go build") {
t.Errorf("Commands section = %q, missing content", section)
}
section = extractSection(content, "## Nonexistent")
if section != "" {
t.Errorf("expected empty for nonexistent section, got %q", section)
}
}
func TestBuildSubagentSystemPrompt(t *testing.T) {
// With no workspace/MEMORY.md, should return base prompt unchanged.
base := "You are a subagent."
got := buildSubagentSystemPrompt(base, t.TempDir())
if got != base {
t.Errorf("expected base prompt unchanged, got %q", got)
}
// With MEMORY.md, should append environment context.
tmpDir := t.TempDir()
memDir := filepath.Join(tmpDir, "memory")
os.MkdirAll(memDir, 0o755)
os.WriteFile(filepath.Join(memDir, "MEMORY.md"), []byte(`# Plan
> Task: Test task
## Context
Test context info.
`), 0o644)
got = buildSubagentSystemPrompt(base, tmpDir)
if !strings.Contains(got, base) {
t.Error("result should contain base prompt")
}
if !strings.Contains(got, "Environment Context") {
t.Error("result should contain Environment Context header")
}
if !strings.Contains(got, "Test context info") {
t.Error("result should contain MEMORY.md context")
}
}

View file

@ -0,0 +1,98 @@
package tools
import (
"testing"
)
func TestSubagentPlanStateString(t *testing.T) {
tests := []struct {
state SubagentPlanState
want string
}{
{PlanNone, "none"},
{PlanClarifying, "clarifying"},
{PlanReview, "review"},
{PlanExecuting, "executing"},
{PlanCompleted, "completed"},
}
for _, tt := range tests {
got := tt.state.String()
if got != tt.want {
t.Errorf("SubagentPlanState(%d).String() = %q, want %q", tt.state, got, tt.want)
}
}
}
func TestFormatPlanSteps(t *testing.T) {
steps := []string{"Read config", "Add middleware", "Write tests"}
got := formatPlanSteps(steps)
want := "1. Read config\n2. Add middleware\n3. Write tests\n"
if got != want {
t.Errorf("formatPlanSteps = %q, want %q", got, want)
}
}
func TestClarifyingSystemPrompt(t *testing.T) {
prompt := clarifyingSystemPrompt()
if prompt == "" {
t.Error("clarifyingSystemPrompt returned empty string")
}
// Should mention ask_conductor and submit_plan.
for _, keyword := range []string{"ask_conductor", "submit_plan", "CLARIFYING"} {
if !containsString(prompt, keyword) {
t.Errorf("clarifyingSystemPrompt missing keyword %q", keyword)
}
}
}
func TestExecutingSystemPrompt(t *testing.T) {
prompt := executingSystemPrompt()
if prompt == "" {
t.Error("executingSystemPrompt returned empty string")
}
if !containsString(prompt, "EXECUTING") {
t.Error("executingSystemPrompt missing keyword EXECUTING")
}
}
func TestExploratorySystemPrompt(t *testing.T) {
scoutPrompt := exploratorySystemPrompt(PresetScout)
if scoutPrompt == "" {
t.Error("exploratorySystemPrompt(scout) returned empty")
}
defaultPrompt := exploratorySystemPrompt("unknown")
if defaultPrompt == "" {
t.Error("exploratorySystemPrompt(unknown) returned empty")
}
if scoutPrompt == defaultPrompt {
t.Error("scout and unknown prompts should differ")
}
}
func TestDeliberateTaskChannelsCreated(t *testing.T) {
// Verify that channels and initial state are correct for deliberate presets.
task := &SubagentTask{
inCh: make(chan string, 1),
outCh: make(chan ContainerMessage, 4),
}
if task.inCh == nil || task.outCh == nil {
t.Fatal("expected channels to be non-nil for deliberate task")
}
if task.PlanState != PlanNone {
t.Errorf("initial PlanState = %v, want PlanNone", task.PlanState)
}
}
// containsString checks if s contains substr.
func containsString(s, substr string) bool {
return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsSubstr(s, substr))
}
func containsSubstr(s, sub string) bool {
for i := 0; i <= len(s)-len(sub); i++ {
if s[i:i+len(sub)] == sub {
return true
}
}
return false
}

186
pkg/tools/submit_plan.go Normal file
View file

@ -0,0 +1,186 @@
package tools
import (
"context"
"encoding/json"
"fmt"
"strings"
)
// SubmitPlanTool allows a subagent to submit a plan for conductor review.
// The subagent blocks until the conductor approves or rejects.
type SubmitPlanTool struct {
taskID string
conductorKey string
subagentKey string
outCh chan<- ContainerMessage
inCh <-chan string
recorder SessionRecorder
setPlan func(goal string, steps []string) // callback to record plan on task
}
func NewSubmitPlanTool(
taskID, conductorKey, subagentKey string,
outCh chan<- ContainerMessage,
inCh <-chan string,
recorder SessionRecorder,
) *SubmitPlanTool {
return &SubmitPlanTool{
taskID: taskID,
conductorKey: conductorKey,
subagentKey: subagentKey,
outCh: outCh,
inCh: inCh,
recorder: recorder,
}
}
// SetPlanCallback sets the function called when a plan is approved to record
// the goal and steps on the parent SubagentTask.
func (t *SubmitPlanTool) SetPlanCallback(fn func(goal string, steps []string)) {
t.setPlan = fn
}
func (t *SubmitPlanTool) Name() string { return "submit_plan" }
func (t *SubmitPlanTool) Description() string {
return "Submit your execution plan for conductor review. Blocks until the conductor approves or rejects. On rejection, revise and resubmit."
}
func (t *SubmitPlanTool) Parameters() map[string]any {
return map[string]any{
"type": "object",
"properties": map[string]any{
"goal": map[string]any{
"type": "string",
"description": "The goal of the plan",
},
"steps": map[string]any{
"type": "array",
"description": "Ordered list of steps to execute",
"items": map[string]any{"type": "string"},
},
},
"required": []string{"goal", "steps"},
}
}
func (t *SubmitPlanTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
goal, _ := args["goal"].(string)
if goal == "" {
return ErrorResult("required parameter \"goal\" (string) is missing")
}
stepsRaw, _ := args["steps"]
var steps []string
switch v := stepsRaw.(type) {
case []any:
steps = make([]string, 0, len(v))
for _, s := range v {
if str, ok := s.(string); ok {
steps = append(steps, str)
}
}
case []string:
steps = v
}
if len(steps) == 0 {
return ErrorResult("required parameter \"steps\" (array of strings) is missing or empty")
}
// Build plan text for recording and display.
var b strings.Builder
b.WriteString("Goal: ")
b.WriteString(goal)
b.WriteByte('\n')
for i, step := range steps {
fmt.Fprintf(&b, "%d. %s\n", i+1, step)
}
planText := b.String()
// Record in session DAG.
if t.recorder != nil {
_ = t.recorder.RecordPlanSubmit(t.conductorKey, t.subagentKey, t.taskID, planText)
}
// Encode plan as JSON for the conductor.
planJSON, _ := json.Marshal(map[string]any{"goal": goal, "steps": steps})
// Send plan_review to conductor.
select {
case t.outCh <- ContainerMessage{Type: "plan_review", Content: string(planJSON), TaskID: t.taskID}:
case <-ctx.Done():
return ErrorResult(fmt.Sprintf("context canceled while submitting plan: %v", ctx.Err()))
}
// Wait for conductor's decision.
select {
case decision := <-t.inCh:
if strings.HasPrefix(decision, "approved") {
if t.setPlan != nil {
t.setPlan(goal, steps)
}
return &ToolResult{
ForLLM: "Plan approved by conductor. Proceed with execution.",
ForUser: "Plan approved",
}
}
return &ToolResult{
ForLLM: fmt.Sprintf("Plan rejected by conductor: %s\nRevise your plan and resubmit.", decision),
ForUser: fmt.Sprintf("Plan rejected: %s", decision),
}
case <-ctx.Done():
return ErrorResult(fmt.Sprintf("context canceled while waiting for review: %v", ctx.Err()))
}
}

View file

@ -0,0 +1,229 @@
package tools
import (
"context"
"testing"
"time"
)
func TestSubmitPlanTool_Execute_Approved(t *testing.T) {
outCh := make(chan ContainerMessage, 4)
inCh := make(chan string, 1)
tool := NewSubmitPlanTool("subagent-1", "conductor:main", "subagent:subagent-1", outCh, inCh, nil)
var gotGoal string
var gotSteps []string
tool.SetPlanCallback(func(goal string, steps []string) {
gotGoal = goal
gotSteps = steps
})
if tool.Name() != "submit_plan" {
t.Errorf("Name() = %q, want %q", tool.Name(), "submit_plan")
}
// Simulate conductor approving in background.
go func() {
msg := <-outCh
if msg.Type != "plan_review" {
t.Errorf("msg.Type = %q, want %q", msg.Type, "plan_review")
}
inCh <- "approved"
}()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
result := tool.Execute(ctx, map[string]any{
"goal": "Add auth",
"steps": []any{"Add middleware", "Add JWT"},
})
if result.IsError {
t.Fatalf("unexpected error: %s", result.ForLLM)
}
if gotGoal != "Add auth" {
t.Errorf("setPlan goal = %q, want %q", gotGoal, "Add auth")
}
if len(gotSteps) != 2 {
t.Errorf("setPlan steps count = %d, want 2", len(gotSteps))
}
}
func TestSubmitPlanTool_Execute_Rejected(t *testing.T) {
outCh := make(chan ContainerMessage, 4)
inCh := make(chan string, 1)
tool := NewSubmitPlanTool("subagent-1", "conductor:main", "subagent:subagent-1", outCh, inCh, nil)
var planSet bool
tool.SetPlanCallback(func(goal string, steps []string) {
planSet = true
})
go func() {
<-outCh
inCh <- "rejected: needs more detail on step 2"
}()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
result := tool.Execute(ctx, map[string]any{
"goal": "Add auth",
"steps": []any{"Add middleware"},
})
if result.IsError {
t.Fatalf("unexpected error: %s", result.ForLLM)
}
if planSet {
t.Error("setPlan should NOT be called on rejection")
}
if result.ForLLM == "" {
t.Error("ForLLM should contain rejection message")
}
}
func TestSubmitPlanTool_MissingParams(t *testing.T) {
tool := NewSubmitPlanTool("subagent-1", "", "", nil, nil, nil)
result := tool.Execute(context.Background(), map[string]any{})
if !result.IsError {
t.Error("expected error for missing goal")
}
result = tool.Execute(context.Background(), map[string]any{"goal": "test"})
if !result.IsError {
t.Error("expected error for missing steps")
}
}
func TestSubmitPlanTool_ContextCanceled(t *testing.T) {
outCh := make(chan ContainerMessage) // unbuffered
inCh := make(chan string)
tool := NewSubmitPlanTool("subagent-1", "", "", outCh, inCh, nil)
ctx, cancel := context.WithCancel(context.Background())
cancel()
result := tool.Execute(ctx, map[string]any{
"goal": "test",
"steps": []any{"step1"},
})
if !result.IsError {
t.Error("expected error on canceled context")
}
}
func TestAnswerSubagentTool_Execute(t *testing.T) {
mgr := &SubagentManager{
tasks: map[string]*SubagentTask{
"subagent-1": {
ID: "subagent-1",
inCh: make(chan string, 1),
},
},
}
tool := NewAnswerSubagentTool(mgr)
if tool.Name() != "answer_subagent" {
t.Errorf("Name() = %q, want %q", tool.Name(), "answer_subagent")
}
result := tool.Execute(context.Background(), map[string]any{
"task_id": "subagent-1",
"answer": "Use port 8080",
})
if result.IsError {
t.Fatalf("unexpected error: %s", result.ForLLM)
}
// Verify the answer was sent.
answer := <-mgr.tasks["subagent-1"].inCh
if answer != "Use port 8080" {
t.Errorf("answer = %q, want %q", answer, "Use port 8080")
}
}
func TestAnswerSubagentTool_MissingParams(t *testing.T) {
tool := NewAnswerSubagentTool(nil)
result := tool.Execute(context.Background(), map[string]any{})
if !result.IsError {
t.Error("expected error for missing task_id")
}
result = tool.Execute(context.Background(), map[string]any{"task_id": "x"})
if !result.IsError {
t.Error("expected error for missing answer")
}
}
func TestReviewSubagentPlanTool_Execute(t *testing.T) {
mgr := &SubagentManager{
tasks: map[string]*SubagentTask{
"subagent-1": {
ID: "subagent-1",
inCh: make(chan string, 1),
},
},
}
tool := NewReviewSubagentPlanTool(mgr)
if tool.Name() != "review_subagent_plan" {
t.Errorf("Name() = %q, want %q", tool.Name(), "review_subagent_plan")
}
result := tool.Execute(context.Background(), map[string]any{
"task_id": "subagent-1",
"decision": "approved",
})
if result.IsError {
t.Fatalf("unexpected error: %s", result.ForLLM)
}
decision := <-mgr.tasks["subagent-1"].inCh
if decision != "approved" {
t.Errorf("decision = %q, want %q", decision, "approved")
}
}

View file

@ -1,4 +1,4 @@
# TASKS-2: Subagent Orchestration (Container Model) # TASKS-2: Subagent Orchestration (Container Model) ✅ 実装済み
## TASKS-1 反映メモ (2026-03-05) ## TASKS-1 反映メモ (2026-03-05)
@ -78,15 +78,15 @@ TASKS-2 の下地はかなり実装済み。以下を前提として差分のみ
| **Async Callback** | `pkg/agent/loop.go` `processRequest` | spawn 完了 → MessageBus → conductor に結果注入 | | **Async Callback** | `pkg/agent/loop.go` `processRequest` | spawn 完了 → MessageBus → conductor に結果注入 |
| **Orchestration Nudge** | `pkg/agent/loop.go` `buildOrchReminder()` | plan 実行中に spawn/subagent 使用を促すリマインダ | | **Orchestration Nudge** | `pkg/agent/loop.go` `buildOrchReminder()` | plan 実行中に spawn/subagent 使用を促すリマインダ |
### 未実装 ❌ → TASKS-2 スコープ ### 実装完了 ✅ (2026-03-05)
| # | 要素 | 概要 | | # | 要素 | 概要 |
|---|---|---| |---|---|---|
| 1 | **ContainerMessage channel** | subagent→conductor の question/status/result 双方向通信 | | 1 | **ContainerMessage channel** | `ContainerMessage` + `inCh`/`outCh` on `SubagentTask` |
| 2 | **Escalation chain** | subagent question → conductor 回答 or → human escalate | | 2 | **Escalation chain** | `ask_conductor` / `answer_subagent` tools + conductor question injection |
| 3 | **Deliberate Plan Mode** | coder/worker/coordinator の clarifying→review→executing 状態遷移 | | 3 | **Deliberate Plan Mode** | `SubagentPlanState` + `runDeliberateTask()` (clarifying→review→executing) |
| 4 | **SubagentEnvironment injection** | MEMORY.md からの自動コンテキスト注入 | | 4 | **SubagentEnvironment injection** | `extractPlanContext()` + `buildSubagentSystemPrompt()` |
| 5 | **MEMORY.md Orchestration section** | conductor guidance に delegated/findings/decisions 追記 | | 5 | **MEMORY.md Orchestration section** | `orchestrationGuidance` 拡張 (Delegated/Findings/Decisions) |
--- ---