package tools import ( "fmt" "strings" "sync" "time" "github.com/nextlevelbuilder/goclaw/internal/bus" ) // AnnounceQueueItem represents a single subagent result waiting to be announced. type AnnounceQueueItem struct { SubagentID string Label string Status string // "completed", "failed", "cancelled" Result string Media []bus.MediaFile // media files from tool results Runtime time.Duration Iterations int } // AnnounceMetadata carries origin info for routing the batched announce. type AnnounceMetadata struct { OriginChannel string OriginChatID string OriginPeerKind string OriginLocalKey string // composite key with topic/thread suffix for routing OriginUserID string OriginSessionKey string // exact parent session key (WS uses non-standard format) ParentAgent string OriginTraceID string // parent trace UUID for announce linking OriginRootSpanID string // parent agent's root span UUID } // AnnounceQueue batches subagent announces per session with debounce, // matching TS subagent-announce-queue.ts "collect" mode. type AnnounceQueue struct { mu sync.Mutex queues map[string]*sessionQueue // session key → queue debounce time.Duration // default 1000ms cap int // max items per session before immediate drain (default 20) onDrain func(sessionKey string, items []AnnounceQueueItem, meta AnnounceMetadata) // countActiveFunc returns the number of still-running subagents for a parent. // Used at drain time for accurate remaining-active count. countActiveFunc func(parentID string) int } type sessionQueue struct { items []AnnounceQueueItem timer *time.Timer meta AnnounceMetadata } // NewAnnounceQueue creates an announce queue with the given debounce and drain callback. func NewAnnounceQueue( debounceMs int, cap int, onDrain func(sessionKey string, items []AnnounceQueueItem, meta AnnounceMetadata), countActive func(parentID string) int, ) *AnnounceQueue { if debounceMs <= 0 { debounceMs = 1000 // TS default } if cap <= 0 { cap = 20 // TS default } return &AnnounceQueue{ queues: make(map[string]*sessionQueue), debounce: time.Duration(debounceMs) * time.Millisecond, cap: cap, onDrain: onDrain, countActiveFunc: countActive, } } // Enqueue adds an announce item to the session queue. // Resets the debounce timer. Drains immediately if cap is reached. func (aq *AnnounceQueue) Enqueue(sessionKey string, item AnnounceQueueItem, meta AnnounceMetadata) { aq.mu.Lock() defer aq.mu.Unlock() sq, ok := aq.queues[sessionKey] if !ok { sq = &sessionQueue{meta: meta} aq.queues[sessionKey] = sq } sq.items = append(sq.items, item) // Drain immediately if cap reached if len(sq.items) >= aq.cap { if sq.timer != nil { sq.timer.Stop() } items := sq.items sqMeta := sq.meta delete(aq.queues, sessionKey) go aq.drain(sessionKey, items, sqMeta) return } // Reset debounce timer if sq.timer != nil { sq.timer.Stop() } sq.timer = time.AfterFunc(aq.debounce, func() { aq.mu.Lock() sq, ok := aq.queues[sessionKey] if !ok { aq.mu.Unlock() return } items := sq.items sqMeta := sq.meta delete(aq.queues, sessionKey) aq.mu.Unlock() aq.drain(sessionKey, items, sqMeta) }) } // drain merges items into a single announce message and calls onDrain. func (aq *AnnounceQueue) drain(sessionKey string, items []AnnounceQueueItem, meta AnnounceMetadata) { if len(items) == 0 || aq.onDrain == nil { return } aq.onDrain(sessionKey, items, meta) } // FormatBatchedAnnounce builds the announce content for a batch of items. // remainingActive is the count of still-running subagents at drain time. func FormatBatchedAnnounce(items []AnnounceQueueItem, remainingActive int) string { if len(items) == 1 { // Single item: use the same format as before (no batching overhead) item := items[0] statusLabel := "completed successfully" if item.Status == "failed" { statusLabel = "failed: " + item.Result } else if item.Status == "cancelled" { statusLabel = "was cancelled" } replyInstruction := buildReplyInstruction(remainingActive) return fmt.Sprintf( "[System Message] A subagent task %q just %s.\n\n"+ "Result:\n%s\n\n"+ "Stats: runtime %s, iterations %d\n\n"+ "%s", item.Label, statusLabel, item.Result, item.Runtime.Round(time.Millisecond), item.Iterations, replyInstruction, ) } // Multiple items: batched format var sb strings.Builder sb.WriteString("[System Message] Multiple subagent tasks completed:\n") for i, item := range items { statusLabel := "completed" if item.Status == "failed" { statusLabel = "failed" } else if item.Status == "cancelled" { statusLabel = "cancelled" } sb.WriteString(fmt.Sprintf( "\n---\nTask #%d: %q %s (runtime %s, iterations %d)\nResult: %s\n", i+1, item.Label, statusLabel, item.Runtime.Round(time.Millisecond), item.Iterations, item.Result, )) } sb.WriteString("---\n\n") sb.WriteString(buildReplyInstruction(remainingActive)) return sb.String() } func buildReplyInstruction(remainingActive int) string { if remainingActive > 0 { runsLabel := "runs" if remainingActive == 1 { runsLabel = "run" } return fmt.Sprintf( "There are still %d active subagent %s for this session. "+ "If they are part of the same workflow, wait for the remaining results "+ "before sending a user update. If they are unrelated, respond normally "+ "using only the result above. "+ "Do NOT copy or echo the [System Message] block verbatim — rewrite in your own voice. "+ "Reply ONLY: NO_REPLY if this result was already delivered to the user.", remainingActive, runsLabel, ) } return "A completed subagent task is ready for user delivery. " + "Convert the result above into your normal assistant voice and " + "send that user-facing update now. Keep this internal context private " + "(don't mention system/log/stats/session details or announce type), " + "and do NOT copy the [System Message] block verbatim. " + "Reply ONLY: NO_REPLY if this exact result was already delivered to the user." }