mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-07-28 18:21:24 +00:00
Subagent announce messages told the parent LLM "There are still X active runs" — a bare number. The LLM had to reconstruct which subagents were done vs running from conversation history, causing hallucination when results arrived out of order. Replace with a full roster listing each subagent by label and status, including per-agent config limits. Remove dead CountRunningForParent() and unused rosterFunc field on AnnounceQueue.
351 lines
11 KiB
Go
351 lines
11 KiB
Go
// Package tools provides the subagent system for spawning child agent instances.
|
|
//
|
|
// Subagents run in background goroutines with restricted tool access.
|
|
// Key constraints from OpenClaw spec:
|
|
// - Depth limit: configurable maxSpawnDepth (default 3)
|
|
// - Max children per parent: configurable (default 8)
|
|
// - Auto-archive after configurable TTL (default 30 min)
|
|
// - Tool deny lists: ALWAYS_DENY + LEAF_DENY at max depth
|
|
// - Results announced back to parent via message bus
|
|
package tools
|
|
|
|
import (
|
|
"cmp"
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"slices"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/bus"
|
|
"github.com/nextlevelbuilder/goclaw/internal/providers"
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
"github.com/nextlevelbuilder/goclaw/internal/tracing"
|
|
)
|
|
|
|
// SubagentConfig configures the subagent system.
|
|
type SubagentConfig struct {
|
|
MaxConcurrent int // max concurrent subagents (default 4)
|
|
MaxSpawnDepth int // max nesting depth (default 3)
|
|
MaxChildrenPerAgent int // max children per parent (default 8)
|
|
ArchiveAfterMinutes int // auto-archive completed tasks (default 30)
|
|
Model string // model override for subagents (empty = inherit)
|
|
}
|
|
|
|
// Subagent task status constants.
|
|
const (
|
|
TaskStatusRunning = "running"
|
|
TaskStatusCompleted = "completed"
|
|
TaskStatusFailed = "failed"
|
|
TaskStatusCancelled = "cancelled"
|
|
)
|
|
|
|
// SubagentTask tracks a running or completed subagent.
|
|
type SubagentTask struct {
|
|
ID string `json:"id"`
|
|
ParentID string `json:"parentId"`
|
|
Task string `json:"task"`
|
|
Label string `json:"label"`
|
|
Status string `json:"status"` // "running", "completed", "failed", "cancelled"
|
|
Result string `json:"result,omitempty"`
|
|
Depth int `json:"depth"`
|
|
Model string `json:"model,omitempty"` // model override for this subagent
|
|
OriginChannel string `json:"originChannel,omitempty"`
|
|
OriginChatID string `json:"originChatId,omitempty"`
|
|
OriginPeerKind string `json:"originPeerKind,omitempty"` // "direct" or "group" (for session key building)
|
|
OriginLocalKey string `json:"originLocalKey,omitempty"` // composite key with topic/thread suffix for routing
|
|
OriginUserID string `json:"originUserId,omitempty"` // parent's userID for per-user scoping propagation
|
|
OriginSessionKey string `json:"originSessionKey,omitempty"` // exact parent session key for announce routing (WS uses non-standard format)
|
|
CreatedAt int64 `json:"createdAt"`
|
|
CompletedAt int64 `json:"completedAt,omitempty"`
|
|
Media []bus.MediaFile `json:"-"` // media files from tool results
|
|
OriginTraceID uuid.UUID `json:"-"` // parent trace for announce linking
|
|
OriginRootSpanID uuid.UUID `json:"-"` // parent agent's root span ID
|
|
cancelFunc context.CancelFunc `json:"-"` // per-task context cancel
|
|
spawnConfig SubagentConfig `json:"-"` // resolved config at spawn time (per-agent override merged)
|
|
}
|
|
|
|
// SubagentManager manages the lifecycle of spawned subagents.
|
|
type SubagentManager struct {
|
|
mu sync.RWMutex
|
|
tasks map[string]*SubagentTask
|
|
config SubagentConfig
|
|
provider providers.Provider // default provider (fallback)
|
|
providerReg *providers.Registry // registry for resolving parent's provider
|
|
model string
|
|
msgBus *bus.MessageBus
|
|
|
|
// createTools builds a tool registry for subagents (without spawn/subagent tools).
|
|
createTools func() *Registry
|
|
announceQueue *AnnounceQueue // optional: batches announces with debounce
|
|
}
|
|
|
|
// NewSubagentManager creates a new subagent manager.
|
|
func NewSubagentManager(
|
|
provider providers.Provider,
|
|
providerReg *providers.Registry,
|
|
model string,
|
|
msgBus *bus.MessageBus,
|
|
createTools func() *Registry,
|
|
cfg SubagentConfig,
|
|
) *SubagentManager {
|
|
return &SubagentManager{
|
|
tasks: make(map[string]*SubagentTask),
|
|
config: cfg,
|
|
provider: provider,
|
|
providerReg: providerReg,
|
|
model: model,
|
|
msgBus: msgBus,
|
|
createTools: createTools,
|
|
}
|
|
}
|
|
|
|
// SetAnnounceQueue sets the announce queue for batched announce delivery.
|
|
// If set, runTask() enqueues announces instead of publishing directly.
|
|
func (sm *SubagentManager) SetAnnounceQueue(q *AnnounceQueue) {
|
|
sm.announceQueue = q
|
|
}
|
|
|
|
// effectiveConfig returns the per-agent context override merged with defaults,
|
|
// or falls back to sm.config when no override is present.
|
|
func (sm *SubagentManager) effectiveConfig(ctx context.Context) SubagentConfig {
|
|
override := SubagentConfigFromCtx(ctx)
|
|
if override == nil {
|
|
return sm.config
|
|
}
|
|
cfg := sm.config
|
|
if override.MaxConcurrent > 0 {
|
|
cfg.MaxConcurrent = override.MaxConcurrent
|
|
}
|
|
if override.MaxSpawnDepth > 0 {
|
|
cfg.MaxSpawnDepth = override.MaxSpawnDepth
|
|
}
|
|
if override.MaxChildrenPerAgent > 0 {
|
|
cfg.MaxChildrenPerAgent = override.MaxChildrenPerAgent
|
|
}
|
|
if override.ArchiveAfterMinutes > 0 {
|
|
cfg.ArchiveAfterMinutes = override.ArchiveAfterMinutes
|
|
}
|
|
if override.Model != "" {
|
|
cfg.Model = override.Model
|
|
}
|
|
return cfg
|
|
}
|
|
|
|
// SubagentRosterEntry summarizes one subagent task for the roster.
|
|
type SubagentRosterEntry struct {
|
|
Label string
|
|
Status string // "running", "completed", "failed", "cancelled"
|
|
}
|
|
|
|
// SubagentRoster holds the full roster of subagent tasks for a parent,
|
|
// including per-agent config limits for deterministic LLM context.
|
|
type SubagentRoster struct {
|
|
Entries []SubagentRosterEntry
|
|
Total int // total tasks for this parent
|
|
MaxPerAgent int // from spawnConfig.MaxChildrenPerAgent
|
|
}
|
|
|
|
// RosterForParent returns the full roster of tasks for a parent.
|
|
// Sorted: completed/failed/cancelled first, then running (deterministic output).
|
|
func (sm *SubagentManager) RosterForParent(parentID string) SubagentRoster {
|
|
sm.mu.RLock()
|
|
defer sm.mu.RUnlock()
|
|
|
|
var entries []SubagentRosterEntry
|
|
maxPerAgent := 0
|
|
for _, t := range sm.tasks {
|
|
if t.ParentID != parentID {
|
|
continue
|
|
}
|
|
entries = append(entries, SubagentRosterEntry{
|
|
Label: t.Label,
|
|
Status: t.Status,
|
|
})
|
|
if maxPerAgent == 0 {
|
|
maxPerAgent = t.spawnConfig.MaxChildrenPerAgent
|
|
}
|
|
}
|
|
|
|
// Sort: non-running first (completed/failed/cancelled), then running.
|
|
// Within same group, sort alphabetically by label for determinism.
|
|
slices.SortFunc(entries, func(a, b SubagentRosterEntry) int {
|
|
aRunning := a.Status == TaskStatusRunning
|
|
bRunning := b.Status == TaskStatusRunning
|
|
if aRunning != bRunning {
|
|
if aRunning {
|
|
return 1 // running goes last
|
|
}
|
|
return -1
|
|
}
|
|
return cmp.Compare(a.Label, b.Label)
|
|
})
|
|
|
|
return SubagentRoster{
|
|
Entries: entries,
|
|
Total: len(entries),
|
|
MaxPerAgent: maxPerAgent,
|
|
}
|
|
}
|
|
|
|
// SubagentDenyAlways is the list of tools always denied to subagents.
|
|
var SubagentDenyAlways = []string{
|
|
"gateway",
|
|
"agents_list",
|
|
"whatsapp_login",
|
|
"session_status",
|
|
"cron",
|
|
"memory_search",
|
|
"memory_get",
|
|
"sessions_send",
|
|
}
|
|
|
|
// SubagentDenyLeaf is the additional deny list for subagents at max depth.
|
|
var SubagentDenyLeaf = []string{
|
|
"sessions_list",
|
|
"sessions_history",
|
|
"sessions_spawn",
|
|
"spawn",
|
|
}
|
|
|
|
// Spawn creates a new subagent task that runs asynchronously.
|
|
// Returns immediately with a status message. The subagent runs in a goroutine.
|
|
// modelOverride optionally overrides the LLM model for this subagent (matching TS sessions-spawn-tool.ts).
|
|
func (sm *SubagentManager) Spawn(
|
|
ctx context.Context,
|
|
parentID string,
|
|
depth int,
|
|
task, label, modelOverride string,
|
|
channel, chatID, peerKind string,
|
|
callback AsyncCallback,
|
|
) (string, error) {
|
|
cfg := sm.effectiveConfig(ctx)
|
|
sm.mu.Lock()
|
|
|
|
// Check depth limit
|
|
if depth >= cfg.MaxSpawnDepth {
|
|
sm.mu.Unlock()
|
|
return "", fmt.Errorf("spawn depth limit reached (%d/%d)", depth, cfg.MaxSpawnDepth)
|
|
}
|
|
|
|
// Check concurrent limit
|
|
running := 0
|
|
for _, t := range sm.tasks {
|
|
if t.Status == TaskStatusRunning {
|
|
running++
|
|
}
|
|
}
|
|
if running >= cfg.MaxConcurrent {
|
|
sm.mu.Unlock()
|
|
return "", fmt.Errorf("max concurrent subagents reached (%d/%d)", running, cfg.MaxConcurrent)
|
|
}
|
|
|
|
// Check per-parent children limit
|
|
childCount := 0
|
|
for _, t := range sm.tasks {
|
|
if t.ParentID == parentID {
|
|
childCount++
|
|
}
|
|
}
|
|
if childCount >= cfg.MaxChildrenPerAgent {
|
|
sm.mu.Unlock()
|
|
return "", fmt.Errorf("max children per agent reached (%d/%d)", childCount, cfg.MaxChildrenPerAgent)
|
|
}
|
|
|
|
id := generateSubagentID()
|
|
if label == "" {
|
|
label = truncate(task, 50)
|
|
}
|
|
|
|
subTask := &SubagentTask{
|
|
ID: id,
|
|
ParentID: parentID,
|
|
Task: task,
|
|
Label: label,
|
|
Status: "running",
|
|
Depth: depth + 1,
|
|
Model: modelOverride,
|
|
OriginChannel: channel,
|
|
OriginChatID: chatID,
|
|
OriginPeerKind: peerKind,
|
|
OriginLocalKey: ToolLocalKeyFromCtx(ctx),
|
|
OriginUserID: store.UserIDFromContext(ctx),
|
|
OriginSessionKey: ToolSessionKeyFromCtx(ctx),
|
|
OriginTraceID: tracing.TraceIDFromContext(ctx),
|
|
OriginRootSpanID: tracing.ParentSpanIDFromContext(ctx),
|
|
CreatedAt: time.Now().UnixMilli(),
|
|
spawnConfig: cfg,
|
|
}
|
|
// Detach from parent's cancellation chain so subagent survives after parent run completes.
|
|
// WithoutCancel preserves all context values (agent ID, workspace, trace info, etc.)
|
|
// but parent Done() no longer propagates. Manual cancel via taskCancel() still works.
|
|
detached := context.WithoutCancel(ctx)
|
|
taskCtx, taskCancel := context.WithCancel(detached)
|
|
subTask.cancelFunc = taskCancel
|
|
|
|
sm.tasks[id] = subTask
|
|
sm.mu.Unlock()
|
|
|
|
slog.Info("subagent spawned", "id", id, "parent", parentID, "depth", subTask.Depth, "label", label)
|
|
|
|
go sm.runTask(taskCtx, subTask, callback)
|
|
|
|
return fmt.Sprintf("Spawned subagent '%s' (id=%s, depth=%d) for task: %s",
|
|
label, id, subTask.Depth, truncate(task, 100)), nil
|
|
}
|
|
|
|
// RunSync executes a subagent task synchronously, blocking until completion.
|
|
func (sm *SubagentManager) RunSync(
|
|
ctx context.Context,
|
|
parentID string,
|
|
depth int,
|
|
task, label string,
|
|
channel, chatID string,
|
|
) (string, int, error) {
|
|
cfg := sm.effectiveConfig(ctx)
|
|
|
|
sm.mu.Lock()
|
|
|
|
if depth >= cfg.MaxSpawnDepth {
|
|
sm.mu.Unlock()
|
|
return "", 0, fmt.Errorf("spawn depth limit reached (%d/%d)", depth, cfg.MaxSpawnDepth)
|
|
}
|
|
|
|
id := generateSubagentID()
|
|
if label == "" {
|
|
label = truncate(task, 50)
|
|
}
|
|
|
|
subTask := &SubagentTask{
|
|
ID: id,
|
|
ParentID: parentID,
|
|
Task: task,
|
|
Label: label,
|
|
Status: "running",
|
|
Depth: depth + 1,
|
|
OriginChannel: channel,
|
|
OriginChatID: chatID,
|
|
OriginLocalKey: ToolLocalKeyFromCtx(ctx),
|
|
OriginUserID: store.UserIDFromContext(ctx),
|
|
OriginTraceID: tracing.TraceIDFromContext(ctx),
|
|
OriginRootSpanID: tracing.ParentSpanIDFromContext(ctx),
|
|
CreatedAt: time.Now().UnixMilli(),
|
|
spawnConfig: cfg,
|
|
}
|
|
sm.tasks[id] = subTask
|
|
sm.mu.Unlock()
|
|
|
|
slog.Info("subagent sync started", "id", id, "parent", parentID, "depth", subTask.Depth, "label", label)
|
|
|
|
iterations := sm.executeTask(ctx, subTask)
|
|
|
|
if subTask.Status == TaskStatusFailed {
|
|
return subTask.Result, iterations, fmt.Errorf("subagent failed: %s", subTask.Result)
|
|
}
|
|
|
|
return subTask.Result, iterations, nil
|
|
}
|