mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-12 04:13:55 +00:00
* feat(collaboration): isolate delegated artifacts and child runs Isolate delegated inputs and outputs behind secure artifact exchange lifecycles. Scope Agent Link tasks by tenant and root agent, and enforce delegation spawn-tree boundaries. Add process-wide child-run admission and preserve logical media paths across native, MCP, and sandbox execution. * fix(collaboration): harden delegated task isolation Enforce tenant and root-agent task scope across migrations and stores. Add exactly-once async completion delivery, delegated sandbox boundaries, and confined artifact and media recovery across runtime surfaces. * fix(collaboration): recover interrupted async tasks * fix(collaboration): normalize persisted child-run status --------- Co-authored-by: ntduc <ntduc@cpp.ai.vn>
298 lines
8.4 KiB
Go
298 lines
8.4 KiB
Go
package tools
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/bus"
|
|
)
|
|
|
|
var ErrAnnounceQueueDrainTimeout = errors.New("announce_queue_drain_timeout")
|
|
|
|
// AnnounceQueueItem represents a single subagent result waiting to be announced.
|
|
type AnnounceQueueItem struct {
|
|
SubagentID string
|
|
CompletionID uuid.UUID
|
|
DurablyPersisted bool
|
|
ParentTaskID string
|
|
Depth int
|
|
Label string
|
|
Status string // "completed", "failed", "cancelled"
|
|
Result string
|
|
Media []bus.MediaFile // media files from tool results
|
|
Runtime time.Duration
|
|
Iterations int
|
|
InputTokens int64
|
|
OutputTokens int64
|
|
}
|
|
|
|
// 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
|
|
OriginSenderID string // real acting sender; preserves permission attribution through re-ingress (#915)
|
|
OriginRole string // caller's RBAC role; bypasses per-user grants for admin/operator/owner (#915)
|
|
OriginSessionKey string // exact parent session key (WS uses non-standard format)
|
|
OriginTenantID uuid.UUID // parent tenant for announce routing
|
|
RootAgentID uuid.UUID
|
|
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)
|
|
closed bool
|
|
drainWG sync.WaitGroup
|
|
}
|
|
|
|
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),
|
|
) *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,
|
|
}
|
|
}
|
|
|
|
// 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()
|
|
if aq.closed {
|
|
return
|
|
}
|
|
|
|
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)
|
|
aq.startDrainLocked(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.startDrainLocked(sessionKey, items, sqMeta)
|
|
aq.mu.Unlock()
|
|
})
|
|
}
|
|
|
|
func (aq *AnnounceQueue) startDrainLocked(
|
|
sessionKey string,
|
|
items []AnnounceQueueItem,
|
|
meta AnnounceMetadata,
|
|
) {
|
|
if aq.closed {
|
|
return
|
|
}
|
|
aq.drainWG.Add(1)
|
|
go func() {
|
|
defer aq.drainWG.Done()
|
|
aq.drain(sessionKey, items, meta)
|
|
}()
|
|
}
|
|
|
|
// CloseContext closes intake, drops pending debounce batches, and waits for any
|
|
// drain callback that already started. Dropping pending parent-resume messages
|
|
// during process shutdown avoids starting new agent runs against dependencies
|
|
// that are being torn down; task terminal state remains durable.
|
|
func (aq *AnnounceQueue) CloseContext(ctx context.Context) error {
|
|
if aq == nil {
|
|
return nil
|
|
}
|
|
if ctx == nil {
|
|
ctx = context.Background()
|
|
}
|
|
aq.mu.Lock()
|
|
if !aq.closed {
|
|
aq.closed = true
|
|
for _, queue := range aq.queues {
|
|
if queue.timer != nil {
|
|
queue.timer.Stop()
|
|
}
|
|
}
|
|
clear(aq.queues)
|
|
}
|
|
aq.mu.Unlock()
|
|
|
|
done := make(chan struct{})
|
|
go func() {
|
|
aq.drainWG.Wait()
|
|
close(done)
|
|
}()
|
|
select {
|
|
case <-done:
|
|
return nil
|
|
case <-ctx.Done():
|
|
return fmt.Errorf("%w: %v", ErrAnnounceQueueDrainTimeout, ctx.Err())
|
|
}
|
|
}
|
|
|
|
// 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.
|
|
// roster provides deterministic task labels+statuses to prevent LLM hallucination.
|
|
func FormatBatchedAnnounce(items []AnnounceQueueItem, roster SubagentRoster) 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(roster)
|
|
|
|
return fmt.Sprintf(
|
|
"[System Message] A subagent task %q just %s (task=%s, parent=%s, depth=%d).\n\n"+
|
|
"Result:\n%s\n\n"+
|
|
"Stats: runtime %s, iterations %d, tokens %d in / %d out\n\n"+
|
|
"%s",
|
|
item.Label, statusLabel, item.SubagentID, item.ParentTaskID, item.Depth, item.Result,
|
|
item.Runtime.Round(time.Millisecond), item.Iterations,
|
|
item.InputTokens, item.OutputTokens,
|
|
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 (task=%s, parent=%s, depth=%d, runtime %s, iterations %d, tokens %d/%d)\nResult: %s\n",
|
|
i+1, item.Label, statusLabel, item.SubagentID, item.ParentTaskID, item.Depth,
|
|
item.Runtime.Round(time.Millisecond), item.Iterations,
|
|
item.InputTokens, item.OutputTokens,
|
|
item.Result,
|
|
))
|
|
}
|
|
|
|
sb.WriteString("---\n\n")
|
|
sb.WriteString(BuildReplyInstruction(roster))
|
|
|
|
return sb.String()
|
|
}
|
|
|
|
// buildReplyInstruction generates the instruction block for the parent LLM,
|
|
// including a deterministic roster of all subagent tasks with their statuses.
|
|
func BuildReplyInstruction(roster SubagentRoster) string {
|
|
running := roster.Active
|
|
if running == 0 {
|
|
for _, entry := range roster.Entries {
|
|
if entry.Status == TaskStatusRunning {
|
|
running++
|
|
}
|
|
}
|
|
}
|
|
|
|
// Build roster block
|
|
var rosterBlock string
|
|
if len(roster.Entries) > 0 {
|
|
var sb strings.Builder
|
|
sb.WriteString(fmt.Sprintf(
|
|
"Subagent roster (%d active / %d max; %d retained total):\n",
|
|
running, roster.MaxPerAgent, roster.Total,
|
|
))
|
|
for _, e := range roster.Entries {
|
|
sb.WriteString(fmt.Sprintf(" [%-9s] %s\n", e.Status, e.Label))
|
|
}
|
|
rosterBlock = sb.String()
|
|
}
|
|
|
|
if running > 0 {
|
|
return fmt.Sprintf(
|
|
"%s\n"+
|
|
"%d subagent(s) still running. "+
|
|
"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.",
|
|
rosterBlock, running,
|
|
)
|
|
}
|
|
|
|
return rosterBlock +
|
|
"\nAll subagent tasks completed. 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."
|
|
}
|