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:
parent
47bdad65fa
commit
7a5200b5ca
18 changed files with 5116 additions and 514 deletions
16
CLAUDE.md
16
CLAUDE.md
|
|
@ -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 0–3 実装済み)
|
## Session DAG (Phase 0–3 実装済み)
|
||||||
|
|
||||||
|
|
@ -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 Mode(TASKS-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 0–3: SQLite SessionStore、LegacyAdapter、Fork/Report、CompactOldTurns、`/session` CLI コマンド、Mini App グラフ UI) |
|
| [`todo/TASKS-3.md`](todo/TASKS-3.md) | ~~**Session DAG (SQLite Store)**~~ ✅ 実装済み(Phase 0–3: 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
2024
pkg/agent/loop.go
2024
pkg/agent/loop.go
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
138
pkg/tools/answer_subagent.go
Normal file
138
pkg/tools/answer_subagent.go
Normal 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
110
pkg/tools/ask_conductor.go
Normal 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()))
|
||||||
|
}
|
||||||
|
}
|
||||||
77
pkg/tools/ask_conductor_test.go
Normal file
77
pkg/tools/ask_conductor_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,21 +8,28 @@ import (
|
||||||
|
|
||||||
type SpawnTool struct {
|
type SpawnTool struct {
|
||||||
manager *SubagentManager
|
manager *SubagentManager
|
||||||
|
|
||||||
originChannel string
|
originChannel string
|
||||||
|
|
||||||
originChatID 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
125
pkg/tools/subagent_container_test.go
Normal file
125
pkg/tools/subagent_container_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
192
pkg/tools/subagent_env_test.go
Normal file
192
pkg/tools/subagent_env_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
98
pkg/tools/subagent_plan_test.go
Normal file
98
pkg/tools/subagent_plan_test.go
Normal 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
186
pkg/tools/submit_plan.go
Normal 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()))
|
||||||
|
}
|
||||||
|
}
|
||||||
229
pkg/tools/submit_plan_test.go
Normal file
229
pkg/tools/submit_plan_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue