Files
goclaw/internal/tools/announce_queue.go
T
viettranx 0f2737ce53 feat(media): persistent media storage, read_document tool, and pipeline refactor
- Add persistent media storage (internal/media/) replacing temp file deletion
- Add MediaRef type for lightweight media references in session messages
- Refactor media pipeline to use bus.MediaFile{Path, MimeType} across all channels
- Add read_document builtin tool for PDF/DOCX/XLSX analysis via Gemini native API
- Move image sanitization from Telegram to shared agent/media layer
- Add media reload for multi-turn conversations (images from last 5 messages)
- Add reply-to-message media resolution for Telegram (re-download on reply)
- Add media inventory to compaction summary to preserve awareness after truncation
- Fix coreToolSummaries for read_image, read_document, create_image tools
- Add real-time trace update events via WebSocket broadcast
- Improve trace detail UI with media refs and tool result display
2026-03-08 14:00:34 +07:00

209 lines
6.1 KiB
Go

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."
}