From dd81b4b2bf7c615453410cc635fc8d32f54d998f Mon Sep 17 00:00:00 2001 From: dj-oyu <68707227+dj-oyu@users.noreply.github.com> Date: Wed, 25 Feb 2026 04:01:17 +0900 Subject: [PATCH] =?UTF-8?q?refactor:=20introduce=20orch.AgentReporter=20?= =?UTF-8?q?=E2=80=94=20decouple=20Broadcaster=20from=20SubagentManager?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add pkg/orch/reporter.go: AgentReporter interface + Noop singleton - Broadcaster now implements AgentReporter (ReportSpawn/StateChange/Conversation/GC) - ToolLoopConfig: replace OnStateChange func with Reporter+AgentID - SubagentManager: accept AgentReporter in constructor, remove internal Broadcaster and GetBroadcaster(); all Publish calls replaced with Report* calls - AgentLoop: add orchBroadcaster/*orchReporter fields, reporter() nil-safe helper, SetOrchReporter/GetOrchBroadcaster public API - NewAgentLoop: create struct before registerSharedTools so al.reporter() is available; auto-detect orchestration from registry config - runAgentLoop: ReportSpawn on entry, defer ReportGC on exit - runLLMIteration: ReportStateChange("waiting") before LLM call, ReportStateChange("toolcall", name) before each tool execution - cmd_gateway.go: wire GetOrchBroadcaster() → handler.SetOrchBroadcaster() - Update subagent_tool_test.go for new constructor signature - Document hierarchy in CLAUDE.md Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 71 +++++++++++++++++++++++++++++ cmd/picoclaw/cmd_gateway.go | 3 ++ pkg/agent/loop.go | 79 +++++++++++++++++++++++++++------ pkg/orch/broadcaster.go | 20 +++++++++ pkg/orch/reporter.go | 21 +++++++++ pkg/tools/subagent.go | 58 ++++++------------------ pkg/tools/subagent_tool_test.go | 21 ++++----- pkg/tools/toolloop.go | 25 ++++++----- 8 files changed, 218 insertions(+), 80 deletions(-) create mode 100644 pkg/orch/reporter.go diff --git a/CLAUDE.md b/CLAUDE.md index cb1c7d43d..74cbcb247 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -354,6 +354,77 @@ pkg/agent/ context.go — conductor identity + orchestration guidance 追加 ``` +### AgentReporter 抽象化 (実装済み 2026-02-25) + +> branch `sub-agent-technical-breakdown` + +`Broadcaster` を `SubagentManager` 内部で生成する密結合を解消し、 +`orch.AgentReporter` インターフェースを中心に置くリファクタリングを実施。 + +#### オーナーシップ + +``` +AgentLoop + ├─ owns: *orch.Broadcaster (orchBroadcaster — nil when disabled) + └─ holds: orch.AgentReporter (orchReporter = Broadcaster or Noop) + ├─ passes to → SubagentManager.reporter + │ └─ passes to → ToolLoopConfig.Reporter + └─ calls directly for main/heartbeat sessions + ├─ runAgentLoop: ReportSpawn / ReportGC + └─ runLLMIteration: ReportStateChange + +cmd_gateway.go + └─ agentLoop.GetOrchBroadcaster() → handler.SetOrchBroadcaster() + +miniapp.Handler + └─ borrows *orch.Broadcaster for Subscribe/Snapshot (WS 配信) +``` + +#### インターフェース (`pkg/orch/reporter.go`) + +```go +type AgentReporter interface { + ReportSpawn(id, label, task string) + ReportStateChange(id, state, tool string) + ReportConversation(from, to, text string) + ReportGC(id, reason string) +} +var Noop AgentReporter = &noopReporter{} // nil-free; 全メソッドが no-op +``` + +`Broadcaster` は `AgentReporter` を満たす (`ReportSpawn` 等が `Publish` のラッパー)。 + +#### Noop パターン + +``` +--orchestration なし: orchReporter = orch.Noop → 全 Report* が空振り (panic なし) +--orchestration あり: orchReporter = *Broadcaster → WS 配信 +``` + +呼び出し側は `if reporter != nil` チェック不要。 + +#### イベント発火の責任分担 + +| 発火元 | イベント | 経由 | +|--------|---------|------| +| `runAgentLoop` | `ReportSpawn` / `ReportGC` | `al.reporter()` | +| `runLLMIteration` | `ReportStateChange("waiting"/"toolcall")` | `al.reporter()` | +| `SubagentManager.Spawn` | `ReportSpawn` | `sm.reporter` | +| `SubagentManager.runTask` | `ReportConversation` / `ReportGC` | `sm.reporter` | +| `RunToolLoop` | `ReportStateChange` | `config.Reporter` | + +main / heartbeat / subagent の全セッションが同一 Broadcaster に発火するため、 +canvas には全エージェントが統一して表示される。 + +#### 変更ファイル + +- `pkg/orch/reporter.go` — **新規** インターフェース + Noop +- `pkg/orch/broadcaster.go` — `ReportSpawn/StateChange/Conversation/GC` 追加 +- `pkg/tools/toolloop.go` — `OnStateChange func` → `Reporter AgentReporter + AgentID` +- `pkg/tools/subagent.go` — constructor に `reporter` 受け取り、内部 broadcaster 廃止、`GetBroadcaster()` 削除 +- `pkg/agent/loop.go` — `orchBroadcaster`/`orchReporter` フィールド追加、`SetOrchReporter`/`GetOrchBroadcaster` 追加、`registerSharedTools` シグネチャに `al *AgentLoop` 追加 +- `cmd/picoclaw/cmd_gateway.go` — `GetOrchBroadcaster()` → `handler.SetOrchBroadcaster()` + --- ## Memory Optimization Notes diff --git a/cmd/picoclaw/cmd_gateway.go b/cmd/picoclaw/cmd_gateway.go index b0a55dcce..2b9567af5 100644 --- a/cmd/picoclaw/cmd_gateway.go +++ b/cmd/picoclaw/cmd_gateway.go @@ -243,6 +243,9 @@ func gatewayCmd() { miniappNotifier = miniapp.NewStateNotifier() handler := miniapp.NewHandler(provider, sender, cfg.Channels.Telegram.Token, miniappNotifier, cfg.Channels.Telegram.AllowFrom, cfg.WorkspacePath()) agentLoop.OnStateChange = miniappNotifier.Notify + if b := agentLoop.GetOrchBroadcaster(); b != nil { + handler.SetOrchBroadcaster(b) + } handler.RegisterRoutes(healthServer.Mux()) // Register dev preview tool for all agents diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index cdcd1f608..4649e71eb 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -25,6 +25,7 @@ import ( "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/constants" "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/orch" "github.com/sipeed/picoclaw/pkg/providers" "github.com/sipeed/picoclaw/pkg/providers/protocoltypes" "github.com/sipeed/picoclaw/pkg/routing" @@ -94,6 +95,8 @@ type AgentLoop struct { promptDirty atomic.Bool // true = rebuild needed on next GetSystemPrompt read OnStateChange func() // called on plan/session/skills mutations OnUserMessage func() // called when a real user message is processed + orchBroadcaster *orch.Broadcaster // nil when --orchestration not set + orchReporter orch.AgentReporter // always non-nil (Noop when disabled) } // processOptions configures how a message is processed @@ -114,9 +117,6 @@ type processOptions struct { func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers.LLMProvider, enableStats ...bool) *AgentLoop { registry := NewAgentRegistry(cfg, provider) - // Register shared tools to all agents - registerSharedTools(cfg, msgBus, registry, provider) - // Set up shared fallback chain cooldown := providers.NewCooldownTracker() fallbackChain := providers.NewFallbackChain(cooldown) @@ -136,17 +136,57 @@ func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers statsTracker = stats.NewTracker(defaultAgent.Workspace) } - return &AgentLoop{ - bus: msgBus, - cfg: cfg, - registry: registry, - state: stateManager, - stats: statsTracker, - summarizing: sync.Map{}, - fallback: fallbackChain, - providerCache: providerCache, - sessions: NewSessionTracker(), + // Determine if orchestration broadcaster is needed (any agent has subagents enabled). + var orchBroadcaster *orch.Broadcaster + var orchReporter orch.AgentReporter = orch.Noop + for _, id := range registry.ListAgentIDs() { + if a, ok := registry.GetAgent(id); ok && a.Subagents != nil && a.Subagents.Enabled { + orchBroadcaster = orch.NewBroadcaster() + orchReporter = orchBroadcaster + break + } } + + al := &AgentLoop{ + bus: msgBus, + cfg: cfg, + registry: registry, + state: stateManager, + stats: statsTracker, + summarizing: sync.Map{}, + fallback: fallbackChain, + providerCache: providerCache, + sessions: NewSessionTracker(), + orchBroadcaster: orchBroadcaster, + orchReporter: orchReporter, + } + + // Register shared tools to all agents (needs al for reporter injection). + registerSharedTools(cfg, msgBus, registry, provider, al) + + return al +} + +// reporter returns the active AgentReporter (never nil). +func (al *AgentLoop) reporter() orch.AgentReporter { + if al.orchReporter == nil { + return orch.Noop + } + return al.orchReporter +} + +// SetOrchReporter wires a Broadcaster as the active reporter. +// Called from cmd_gateway.go when --orchestration is set. +// --orchestration なし → 呼ばれない → reporter() は Noop を返す。 +func (al *AgentLoop) SetOrchReporter(b *orch.Broadcaster) { + al.orchBroadcaster = b + al.orchReporter = b +} + +// GetOrchBroadcaster returns the concrete Broadcaster for miniapp wiring. +// Returns nil when orchestration is disabled. +func (al *AgentLoop) GetOrchBroadcaster() *orch.Broadcaster { + return al.orchBroadcaster } func (al *AgentLoop) notifyStateChange() { @@ -162,6 +202,7 @@ func registerSharedTools( msgBus *bus.MessageBus, registry *AgentRegistry, provider providers.LLMProvider, + al *AgentLoop, ) { for _, agentID := range registry.ListAgentIDs() { agent, ok := registry.GetAgent(agentID) @@ -218,7 +259,7 @@ func registerSharedTools( // Spawn tool — only registered when orchestration is explicitly enabled. if agent.Subagents != nil && agent.Subagents.Enabled { - subagentManager := tools.NewSubagentManager(provider, agent.Model, agent.Workspace, msgBus) + subagentManager := tools.NewSubagentManager(provider, agent.Model, agent.Workspace, msgBus, al.reporter()) subagentManager.SetLLMOptions(agent.MaxTokens, agent.Temperature) spawnTool := tools.NewSpawnTool(subagentManager) currentAgentID := agentID @@ -715,6 +756,10 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt } defer al.releaseSessionLock(opts.SessionKey) + // Report session lifecycle to canvas. + al.reporter().ReportSpawn(opts.SessionKey, opts.Channel, opts.UserMessage) + defer al.reporter().ReportGC(opts.SessionKey, "completed") + // -0. Create cancellable child context and register active task taskCtx, taskCancel := context.WithCancel(ctx) defer taskCancel() @@ -1787,6 +1832,9 @@ func (al *AgentLoop) runLLMIteration( return doCall(ctx, agent.Provider, primaryModel) } + // Report waiting state to canvas before each LLM call. + al.reporter().ReportStateChange(opts.SessionKey, "waiting", "") + // Retry loop for context/token errors maxRetries := 2 for retry := 0; retry <= maxRetries; retry++ { @@ -2136,6 +2184,9 @@ func (al *AgentLoop) runLLMIteration( } } + // Report toolcall state to canvas. + al.reporter().ReportStateChange(opts.SessionKey, "toolcall", tc.Name) + toolStart := time.Now() toolCtx := ctx if wt := agent.GetWorktree(opts.SessionKey); wt != nil { diff --git a/pkg/orch/broadcaster.go b/pkg/orch/broadcaster.go index fe89509bb..70fff1881 100644 --- a/pkg/orch/broadcaster.go +++ b/pkg/orch/broadcaster.go @@ -83,6 +83,26 @@ func (b *Broadcaster) Snapshot() []AgentInfo { return out } +// ReportSpawn implements AgentReporter. +func (b *Broadcaster) ReportSpawn(id, label, task string) { + b.Publish(Event{Type: "agent_spawn", ID: id, Label: label, Task: task}) +} + +// ReportStateChange implements AgentReporter. +func (b *Broadcaster) ReportStateChange(id, state, tool string) { + b.Publish(Event{Type: "agent_state", ID: id, State: state, Tool: tool}) +} + +// ReportConversation implements AgentReporter. +func (b *Broadcaster) ReportConversation(from, to, text string) { + b.Publish(Event{Type: "conversation", From: from, To: to, Text: text}) +} + +// ReportGC implements AgentReporter. +func (b *Broadcaster) ReportGC(id, reason string) { + b.Publish(Event{Type: "agent_gc", ID: id, Reason: reason}) +} + // Publish updates internal agent state and fans out to all subscribers. func (b *Broadcaster) Publish(ev Event) { if ev.Created == 0 { diff --git a/pkg/orch/reporter.go b/pkg/orch/reporter.go new file mode 100644 index 000000000..b8229314d --- /dev/null +++ b/pkg/orch/reporter.go @@ -0,0 +1,21 @@ +package orch + +// AgentReporter is the interface for reporting agent lifecycle events. +// Both Broadcaster (real events) and noopReporter (disabled) implement this. +type AgentReporter interface { + ReportSpawn(id, label, task string) + ReportStateChange(id, state, tool string) + ReportConversation(from, to, text string) + ReportGC(id, reason string) +} + +type noopReporter struct{} + +func (n *noopReporter) ReportSpawn(id, label, task string) {} +func (n *noopReporter) ReportStateChange(id, state, tool string) {} +func (n *noopReporter) ReportConversation(from, to, text string) {} +func (n *noopReporter) ReportGC(id, reason string) {} + +// Noop is the AgentReporter to use when orchestration is disabled. +// Allows nil-free code in callers. +var Noop AgentReporter = &noopReporter{} diff --git a/pkg/tools/subagent.go b/pkg/tools/subagent.go index 634d087ed..3956cf0fd 100644 --- a/pkg/tools/subagent.go +++ b/pkg/tools/subagent.go @@ -37,14 +37,18 @@ type SubagentManager struct { hasMaxTokens bool hasTemperature bool nextID int - broadcaster *orch.Broadcaster + reporter orch.AgentReporter } func NewSubagentManager( provider providers.LLMProvider, defaultModel, workspace string, bus *bus.MessageBus, + reporter orch.AgentReporter, ) *SubagentManager { + if reporter == nil { + reporter = orch.Noop + } return &SubagentManager{ tasks: make(map[string]*SubagentTask), provider: provider, @@ -54,16 +58,10 @@ func NewSubagentManager( tools: NewToolRegistry(), maxIterations: 10, nextID: 1, - broadcaster: orch.NewBroadcaster(), + reporter: reporter, } } -// GetBroadcaster returns the Broadcaster so the miniapp handler can -// subscribe to real-time orchestration events. -func (sm *SubagentManager) GetBroadcaster() *orch.Broadcaster { - return sm.broadcaster -} - // SetLLMOptions sets max tokens and temperature for subagent LLM calls. func (sm *SubagentManager) SetLLMOptions(maxTokens int, temperature float64) { sm.mu.Lock() @@ -112,12 +110,7 @@ func (sm *SubagentManager) Spawn( } sm.tasks[taskID] = subagentTask - sm.broadcaster.Publish(orch.Event{ - Type: "agent_spawn", - ID: taskID, - Label: label, - Task: task, - }) + sm.reporter.ReportSpawn(taskID, label, task) // Start task in background with context cancellation support go sm.runTask(ctx, subagentTask, callback) @@ -130,7 +123,6 @@ func (sm *SubagentManager) Spawn( func (sm *SubagentManager) runTask(ctx context.Context, task *SubagentTask, callback AsyncCallback) { task.Status = "running" - task.Created = time.Now().UnixMilli() // Build system prompt for subagent systemPrompt := `You are a subagent. Complete the given task independently and report the result. @@ -181,12 +173,7 @@ After completing the task, provide a clear summary of what was done.` } // Notify conductor that the subagent is starting - sm.broadcaster.Publish(orch.Event{ - Type: "conversation", - From: "conductor", - To: task.ID, - Text: task.Task, - }) + sm.reporter.ReportConversation("conductor", task.ID, task.Task) loopResult, err := RunToolLoop(ctx, ToolLoopConfig{ Provider: sm.provider, @@ -194,14 +181,8 @@ After completing the task, provide a clear summary of what was done.` Tools: tools, MaxIterations: maxIter, LLMOptions: llmOptions, - OnStateChange: func(state, tool string) { - sm.broadcaster.Publish(orch.Event{ - Type: "agent_state", - ID: task.ID, - State: state, - Tool: tool, - }) - }, + Reporter: sm.reporter, + AgentID: task.ID, }, messages, task.OriginChannel, task.OriginChatID) sm.mu.Lock() @@ -224,11 +205,7 @@ After completing the task, provide a clear summary of what was done.` task.Result = "Task cancelled during execution" gcReason = "cancelled" } - sm.broadcaster.Publish(orch.Event{ - Type: "agent_gc", - ID: task.ID, - Reason: gcReason, - }) + sm.reporter.ReportGC(task.ID, gcReason) result = &ToolResult{ ForLLM: task.Result, ForUser: "", @@ -241,17 +218,8 @@ After completing the task, provide a clear summary of what was done.` task.Status = "completed" task.Result = loopResult.Content // Notify conductor of the result - sm.broadcaster.Publish(orch.Event{ - Type: "conversation", - From: task.ID, - To: "conductor", - Text: loopResult.Content, - }) - sm.broadcaster.Publish(orch.Event{ - Type: "agent_gc", - ID: task.ID, - Reason: "completed", - }) + sm.reporter.ReportConversation(task.ID, "conductor", loopResult.Content) + sm.reporter.ReportGC(task.ID, "completed") result = &ToolResult{ ForLLM: fmt.Sprintf( "Subagent '%s' completed (iterations: %d): %s", diff --git a/pkg/tools/subagent_tool_test.go b/pkg/tools/subagent_tool_test.go index 59bfdffae..30838d843 100644 --- a/pkg/tools/subagent_tool_test.go +++ b/pkg/tools/subagent_tool_test.go @@ -6,6 +6,7 @@ import ( "testing" "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/orch" "github.com/sipeed/picoclaw/pkg/providers" ) @@ -47,7 +48,7 @@ func (m *MockLLMProvider) GetContextWindow() int { func TestSubagentManager_SetLLMOptions_AppliesToRunToolLoop(t *testing.T) { provider := &MockLLMProvider{} - manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil) + manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil, orch.Noop) manager.SetLLMOptions(2048, 0.6) tool := NewSubagentTool(manager) tool.SetContext("cli", "direct") @@ -74,7 +75,7 @@ func TestSubagentManager_SetLLMOptions_AppliesToRunToolLoop(t *testing.T) { // TestSubagentTool_Name verifies tool name func TestSubagentTool_Name(t *testing.T) { provider := &MockLLMProvider{} - manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil) + manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil, orch.Noop) tool := NewSubagentTool(manager) if tool.Name() != "subagent" { @@ -85,7 +86,7 @@ func TestSubagentTool_Name(t *testing.T) { // TestSubagentTool_Description verifies tool description func TestSubagentTool_Description(t *testing.T) { provider := &MockLLMProvider{} - manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil) + manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil, orch.Noop) tool := NewSubagentTool(manager) desc := tool.Description() @@ -100,7 +101,7 @@ func TestSubagentTool_Description(t *testing.T) { // TestSubagentTool_Parameters verifies tool parameters schema func TestSubagentTool_Parameters(t *testing.T) { provider := &MockLLMProvider{} - manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil) + manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil, orch.Noop) tool := NewSubagentTool(manager) params := tool.Parameters() @@ -150,7 +151,7 @@ func TestSubagentTool_Parameters(t *testing.T) { // TestSubagentTool_SetContext verifies context setting func TestSubagentTool_SetContext(t *testing.T) { provider := &MockLLMProvider{} - manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil) + manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil, orch.Noop) tool := NewSubagentTool(manager) tool.SetContext("test-channel", "test-chat") @@ -164,7 +165,7 @@ func TestSubagentTool_SetContext(t *testing.T) { func TestSubagentTool_Execute_Success(t *testing.T) { provider := &MockLLMProvider{} msgBus := bus.NewMessageBus() - manager := NewSubagentManager(provider, "test-model", "/tmp/test", msgBus) + manager := NewSubagentManager(provider, "test-model", "/tmp/test", msgBus, orch.Noop) tool := NewSubagentTool(manager) tool.SetContext("telegram", "chat-123") @@ -220,7 +221,7 @@ func TestSubagentTool_Execute_Success(t *testing.T) { func TestSubagentTool_Execute_NoLabel(t *testing.T) { provider := &MockLLMProvider{} msgBus := bus.NewMessageBus() - manager := NewSubagentManager(provider, "test-model", "/tmp/test", msgBus) + manager := NewSubagentManager(provider, "test-model", "/tmp/test", msgBus, orch.Noop) tool := NewSubagentTool(manager) ctx := context.Background() @@ -243,7 +244,7 @@ func TestSubagentTool_Execute_NoLabel(t *testing.T) { // TestSubagentTool_Execute_MissingTask tests error handling for missing task func TestSubagentTool_Execute_MissingTask(t *testing.T) { provider := &MockLLMProvider{} - manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil) + manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil, orch.Noop) tool := NewSubagentTool(manager) ctx := context.Background() @@ -294,7 +295,7 @@ func TestSubagentTool_Execute_NilManager(t *testing.T) { func TestSubagentTool_Execute_ContextPassing(t *testing.T) { provider := &MockLLMProvider{} msgBus := bus.NewMessageBus() - manager := NewSubagentManager(provider, "test-model", "/tmp/test", msgBus) + manager := NewSubagentManager(provider, "test-model", "/tmp/test", msgBus, orch.Noop) tool := NewSubagentTool(manager) // Set context @@ -323,7 +324,7 @@ func TestSubagentTool_ForUserTruncation(t *testing.T) { // Create a mock provider that returns very long content provider := &MockLLMProvider{} msgBus := bus.NewMessageBus() - manager := NewSubagentManager(provider, "test-model", "/tmp/test", msgBus) + manager := NewSubagentManager(provider, "test-model", "/tmp/test", msgBus, orch.Noop) tool := NewSubagentTool(manager) ctx := context.Background() diff --git a/pkg/tools/toolloop.go b/pkg/tools/toolloop.go index 6eb62ef23..d8793caef 100644 --- a/pkg/tools/toolloop.go +++ b/pkg/tools/toolloop.go @@ -12,6 +12,7 @@ import ( "fmt" "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/orch" "github.com/sipeed/picoclaw/pkg/providers" "github.com/sipeed/picoclaw/pkg/utils" ) @@ -23,11 +24,12 @@ type ToolLoopConfig struct { Tools *ToolRegistry MaxIterations int LLMOptions map[string]any - // OnStateChange is an optional hook for UI feedback. - // Called with ("waiting","") before each LLM call and - // ("toolcall", toolName) when each tool starts executing. - // nil is safe to pass. - OnStateChange func(state, tool string) + // Reporter and AgentID replace the old OnStateChange func. + // Reporter is called with ReportStateChange("waiting","") before each LLM + // call and ReportStateChange("toolcall", toolName) when each tool starts. + // Pass nil or orch.Noop to disable. nil is treated as orch.Noop internally. + Reporter orch.AgentReporter + AgentID string } // ToolLoopResult contains the result of running the tool loop. @@ -44,6 +46,11 @@ func RunToolLoop( messages []providers.Message, channel, chatID string, ) (*ToolLoopResult, error) { + reporter := config.Reporter + if reporter == nil { + reporter = orch.Noop + } + iteration := 0 var finalContent string @@ -68,9 +75,7 @@ func RunToolLoop( llmOpts = map[string]any{} } // 3. Call LLM (hook: waiting for response) - if config.OnStateChange != nil { - config.OnStateChange("waiting", "") - } + reporter.ReportStateChange(config.AgentID, "waiting", "") response, err := config.Provider.Chat(ctx, messages, providerToolDefs, config.Model, llmOpts) if err != nil { logger.ErrorCF("toolloop", "LLM call failed", @@ -138,9 +143,7 @@ func RunToolLoop( "tool": tc.Name, "iteration": iteration, }) - if config.OnStateChange != nil { - config.OnStateChange("toolcall", tc.Name) - } + reporter.ReportStateChange(config.AgentID, "toolcall", tc.Name) // Execute tool (no async callback for subagents - they run independently) var toolResult *ToolResult