mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-12 14:15:00 +00:00
Team tasks are not dispatched inline: the turn's PendingTeamDispatch collects them and the post-turn drain assigns and dispatches them once the turn ends. That same tracker also owns the per-(team, chat) create lock taken by team_tasks list/search. Async delegations and async spawns keep the caller's context values (context.WithoutCancel) but run detached — after the caller's turn has already ended and drained. A team lead reached through delegate therefore adds every task it creates to a tracker nobody will drain again, and takes a create lock nobody will release. Two symptoms follow: - tasks stay pending forever and are never dispatched, so the team never starts work. The task ticker does not cover this: it never dispatches pending tasks, it only marks them stale after 2h and asks the lead to retry by hand. - the next team_tasks list/search for the same (team, chat) blocks on the never-released mutex for the remaining lifetime of the process. Give each detached child run its own tracker through the existing InjectTeamDispatch helper, drained when that run ends. The synchronous delegate/spawn paths are deliberately left alone: they execute inside the caller's turn, where the caller's tracker is the correct owner. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
257 lines
9.8 KiB
Go
257 lines
9.8 KiB
Go
// Package tools provides the subagent system for spawning child agent instances.
|
|
//
|
|
// Subagents run in background goroutines with restricted tool access.
|
|
// Key GoClaw constraints:
|
|
// - Depth limit: configurable maxSpawnDepth (default 1)
|
|
// - Max children per parent: configurable (default 5)
|
|
// - Max executing descendants per root agent: configurable (default 20)
|
|
// - Auto-archive after configurable TTL (default 60 min)
|
|
// - Tool deny lists: ALWAYS_DENY + LEAF_DENY at max depth
|
|
// - Results announced back to parent via message bus
|
|
package tools
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/bus"
|
|
orchestration "github.com/nextlevelbuilder/goclaw/internal/childrun"
|
|
"github.com/nextlevelbuilder/goclaw/internal/providers"
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
usagecaps "github.com/nextlevelbuilder/goclaw/internal/usage/caps"
|
|
)
|
|
|
|
var ErrSubagentLifecycleDrainTimeout = errors.New("subagent_lifecycle_drain_timeout")
|
|
|
|
// SubagentConfig configures the subagent system.
|
|
type SubagentConfig struct {
|
|
MaxConcurrent int // executing descendants per root agent (default 20)
|
|
MaxSpawnDepth int // max nesting depth (default 1)
|
|
MaxChildrenPerAgent int // max children per parent (default 5)
|
|
ArchiveAfterMinutes int // auto-archive completed tasks (default 60)
|
|
MaxRetries int // max LLM call retries on error (default 2)
|
|
Model string // model override for subagents (empty = inherit)
|
|
}
|
|
|
|
// Subagent task status constants.
|
|
const (
|
|
TaskStatusQueued = "queued"
|
|
TaskStatusRunning = "running"
|
|
TaskStatusWaiting = string(orchestration.ChildRunWaitingChild)
|
|
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
|
|
TotalInputTokens int64 `json:"totalInputTokens,omitempty"`
|
|
TotalOutputTokens int64 `json:"totalOutputTokens,omitempty"`
|
|
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
|
|
OriginSenderID string `json:"originSenderId,omitempty"` // real acting sender; preserves permission attribution in announce re-ingress (#915)
|
|
OriginRole string `json:"originRole,omitempty"` // parent's RBAC role; bypasses per-user grants for admin/operator/owner in re-ingress (#915)
|
|
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
|
|
Workspace string `json:"-"` // physical root used only to derive safe completion media paths
|
|
MediaPathPrefix string `json:"-"` // logical prefix, e.g. outputs/ in a delegation exchange
|
|
OriginAgentID uuid.UUID `json:"-"` // parent agent UUID for usage caps and scoped tools
|
|
OriginTenantID uuid.UUID `json:"-"` // parent's tenant for announce routing
|
|
RootAgentID uuid.UUID `json:"-"`
|
|
RootAgentKey string `json:"-"`
|
|
ParentTaskID string `json:"parentTaskId,omitempty"`
|
|
OriginTraceID uuid.UUID `json:"-"` // parent trace for announce linking
|
|
OriginRootSpanID uuid.UUID `json:"-"` // parent agent's root span ID
|
|
// Capture caller-specific budgets because one process manager serves many agents.
|
|
OriginContextWindow int `json:"-"`
|
|
OriginMaxTokens int `json:"-"`
|
|
cancelFunc context.CancelFunc `json:"-"` // per-task context cancel
|
|
spawnConfig SubagentConfig `json:"-"` // resolved config at spawn time (per-agent override merged)
|
|
dbID uuid.UUID `json:"-"` // persistent DB UUID (zero if not persisted)
|
|
admissionTicket *orchestration.ChildRunTicket
|
|
}
|
|
|
|
// TaskScope is the authorization boundary for every in-memory task action.
|
|
// A task ID alone never authorizes lookup, cancellation, steering, or cleanup.
|
|
type TaskScope struct {
|
|
TenantID uuid.UUID
|
|
RootAgentID uuid.UUID
|
|
RootAgentKey string
|
|
}
|
|
|
|
// 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
|
|
taskStore store.SubagentTaskStore // optional: durable async completion ledger
|
|
usageCaps *usagecaps.Service
|
|
postTurn PostTurnProcessor // optional: dispatches team tasks a detached subagent creates
|
|
admission *orchestration.ChildRunAdmission
|
|
sweeperOnce sync.Once
|
|
sweeperStop chan struct{}
|
|
sweeperDone chan struct{}
|
|
sweeperStarted bool
|
|
lifecycleMu sync.Mutex
|
|
lifecycleClosed bool
|
|
lifecycleWG sync.WaitGroup
|
|
closeOnce sync.Once
|
|
closeDone chan struct{}
|
|
now func() time.Time
|
|
// Defaults used only when a task was created without caller-specific context.
|
|
contextWindow int
|
|
maxTokens int
|
|
}
|
|
|
|
// 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 NewSubagentManagerWithAdmission(
|
|
provider,
|
|
providerReg,
|
|
model,
|
|
msgBus,
|
|
createTools,
|
|
cfg,
|
|
orchestration.NewChildRunAdmission(32, 128),
|
|
)
|
|
}
|
|
|
|
// NewSubagentManagerWithAdmission creates a manager sharing the process-owned
|
|
// child-run admission controller with other child execution paths.
|
|
func NewSubagentManagerWithAdmission(
|
|
provider providers.Provider,
|
|
providerReg *providers.Registry,
|
|
model string,
|
|
msgBus *bus.MessageBus,
|
|
createTools func() *Registry,
|
|
cfg SubagentConfig,
|
|
admission *orchestration.ChildRunAdmission,
|
|
) *SubagentManager {
|
|
if admission == nil {
|
|
admission = orchestration.NewChildRunAdmission(32, 128)
|
|
}
|
|
return &SubagentManager{
|
|
tasks: make(map[string]*SubagentTask),
|
|
config: cfg,
|
|
provider: provider,
|
|
providerReg: providerReg,
|
|
model: model,
|
|
msgBus: msgBus,
|
|
createTools: createTools,
|
|
admission: admission,
|
|
sweeperStop: make(chan struct{}),
|
|
sweeperDone: make(chan struct{}),
|
|
closeDone: make(chan struct{}),
|
|
now: time.Now,
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// SetTaskStore sets the persistent store for task lifecycle and async retrieval.
|
|
func (sm *SubagentManager) SetTaskStore(s store.SubagentTaskStore) {
|
|
sm.taskStore = s
|
|
}
|
|
|
|
// SetPostTurnProcessor wires post-turn team-task dispatch for async spawns.
|
|
func (sm *SubagentManager) SetPostTurnProcessor(p PostTurnProcessor) {
|
|
sm.postTurn = p
|
|
}
|
|
|
|
func (sm *SubagentManager) SetUsageCapService(s *usagecaps.Service) {
|
|
sm.usageCaps = s
|
|
}
|
|
|
|
// SetAgentBudget records the manager's fallback agent budget. Spawned tasks
|
|
// normally use their caller-specific values captured from context.
|
|
func (sm *SubagentManager) SetAgentBudget(window, maxTokens int) {
|
|
sm.contextWindow = window
|
|
sm.maxTokens = maxTokens
|
|
}
|
|
|
|
// 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.MaxRetries > 0 {
|
|
cfg.MaxRetries = override.MaxRetries
|
|
}
|
|
if override.Model != "" {
|
|
cfg.Model = override.Model
|
|
}
|
|
return cfg
|
|
}
|
|
|
|
// 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",
|
|
"team_tasks", // subagents must not use team orchestration
|
|
}
|
|
|
|
// SubagentDenyLeaf is the additional deny list for subagents at max depth.
|
|
var SubagentDenyLeaf = []string{
|
|
"sessions_list",
|
|
"sessions_history",
|
|
"sessions_spawn",
|
|
"spawn",
|
|
}
|