From 48d2856eff674eb9e8ec65f4f55593df21c8ee85 Mon Sep 17 00:00:00 2001 From: Erik Swedberg Date: Mon, 18 May 2026 10:41:12 +0000 Subject: [PATCH] add subagent done notifications MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a subagent finishes (agentWorking transitions to false), notify the parent conversation by injecting a summary message into its loop. This makes wait:false actually usable: the parent keeps working while subagents run, and gets notified with results when each one finishes — no blocking, no manual 'check on that subagent' prompting. Changes: - server/convo.go: onDone callback field on ConversationManager, called from SetAgentWorking when transitioning to not-working - server/server.go: notifyParentSubagentDone looks up parent, grabs last assistant response (truncated to 500 chars), injects [Subagent 'slug' finished] message into parent's loop - AGENTS.md: guideline to prefer wait:false for subagents Co-authored-by: Shelley --- AGENTS.md | 2 ++ server/convo.go | 8 ++++++ server/server.go | 65 ++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 75 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index cd84eb07..c5793c4a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,3 +37,5 @@ reads the `action` field from the input and dispatches to the right sub-component) - `loop/predictable.go` (the "tool smorgasbord" demo response) - See `ui/src/components/AGENTS.md` for more detail. + +16. Prefer `wait: false` for subagents doing background research. Use `wait: true` only when you need the result to continue, and keep timeouts short (30s max). Never block the conversation for minutes. diff --git a/server/convo.go b/server/convo.go index 8a03fa9b..f219adb8 100644 --- a/server/convo.go +++ b/server/convo.go @@ -73,6 +73,10 @@ type ConversationManager struct { // onStateChange is called when the conversation state changes. // This allows the server to broadcast state changes to all subscribers. onStateChange func(state ConversationState) + + // onDone is called when the agent finishes working (transitions to not working). + // Used by subagents to notify their parent conversation. + onDone func() } // NewConversationManager constructs a manager with dependencies but defers hydration until needed. @@ -134,6 +138,7 @@ func (cm *ConversationManager) SetAgentWorking(working bool) { } cm.agentWorking = working onStateChange := cm.onStateChange + onDone := cm.onDone convID := cm.conversationID modelID := cm.modelID cm.mu.Unlock() @@ -149,6 +154,9 @@ func (cm *ConversationManager) SetAgentWorking(working bool) { Model: modelID, }) } + if !working && onDone != nil { + onDone() + } } // IsAgentWorking returns the current agent working state. diff --git a/server/server.go b/server/server.go index e160ca99..cc9bf465 100644 --- a/server/server.go +++ b/server/server.go @@ -837,6 +837,11 @@ func (s *Server) getOrCreateSubagentConversationManager(ctx context.Context, con existing.Touch() return existing, nil } + // Wire up done notification: when this subagent finishes, notify the parent. + manager.onDone = func() { + go s.notifyParentSubagentDone(conversationID) + } + s.activeConversations[conversationID] = manager s.mu.Unlock() return manager, nil @@ -847,6 +852,66 @@ func (s *Server) getOrCreateSubagentConversationManager(ctx context.Context, con return manager, nil } +// notifyParentSubagentDone injects a message into the parent conversation's loop +// when a subagent finishes, so the parent agent knows to check results. +func (s *Server) notifyParentSubagentDone(subagentConversationID string) { + ctx := context.Background() + + // Look up the subagent's parent conversation ID and slug + var conv generated.Conversation + err := s.db.Queries(ctx, func(q *generated.Queries) error { + var err error + conv, err = q.GetConversation(ctx, subagentConversationID) + return err + }) + if err != nil || conv.ParentConversationID == nil { + return + } + + parentID := *conv.ParentConversationID + slug := "unknown" + if conv.Slug != nil { + slug = *conv.Slug + } + + // Find the parent's loop and inject a notification + s.mu.Lock() + parentManager, ok := s.activeConversations[parentID] + s.mu.Unlock() + if !ok { + return + } + + parentManager.mu.Lock() + loopInstance := parentManager.loop + parentManager.mu.Unlock() + if loopInstance == nil { + return + } + + // Get the subagent's last response for the notification + runner := NewSubagentRunner(s) + response, err := runner.getLastAssistantResponse(ctx, subagentConversationID) + if err != nil || response == "" { + response = "(completed, use subagent tool to read results)" + } + // Truncate long responses + if len(response) > 500 { + response = response[:500] + "..." + } + + notification := llm.Message{ + Role: llm.MessageRoleUser, + Content: []llm.Content{{ + Type: llm.ContentTypeText, + Text: fmt.Sprintf("[Subagent '%s' finished]\n%s", slug, response), + }}, + } + + loopInstance.QueueUserMessage(notification) + s.logger.Info("Notified parent of subagent completion", "subagent", slug, "parent", parentID) +} + // ExtractDisplayData extracts display data from message content for storage func ExtractDisplayData(message llm.Message) interface{} { // Build a map of tool_use_id to tool_name for lookups