From 2fdb791802ed85855c5e363e523cea658f002dba Mon Sep 17 00:00:00 2001 From: Luan Vu Date: Wed, 11 Mar 2026 16:05:56 +0700 Subject: [PATCH] fix: honor per-agent DB settings for restrict, subagents, memory, sandbox (#145) Four per-agent settings stored in the database (and configurable via UI) were silently ignored at runtime because the tool/system layer always used the global config defaults instead. **restrict_to_workspace**: Tools used the global config default baked at startup. Fix: pass per-agent value through context; tools check context override before falling back to constructor default. **subagents_config**: ParseSubagentsConfig() existed but was never called. All agents shared one SubagentManager with global limits. Fix: resolve per-agent config in the agent resolver, store it on each spawned task, and use it for limit checks, deny lists, and system prompt generation. **memory_config**: Only the enabled toggle was read per-agent; search weights (vector_weight, text_weight, max_results, min_score) were hardcoded from PGMemoryStore defaults. Fix: extend MemorySearchOptions with weight overrides, read per-agent config from context in the memory_search tool. **sandbox_config**: Only workspace_access was extracted per-agent; mode, image, memory, CPU, timeout, network settings were discarded. Fix: pass full sandbox.Config through context; Manager.Get() accepts an optional config override for new containers. Co-authored-by: Luvu182 <208665161+Luvu182@users.noreply.github.com> --- internal/agent/loop.go | 13 +++++++ internal/agent/loop_types.go | 18 +++++++++ internal/agent/resolver.go | 8 ++++ internal/sandbox/docker.go | 13 +++++-- internal/sandbox/sandbox.go | 3 +- internal/store/memory_store.go | 10 +++-- internal/store/pg/memory_search.go | 8 +++- internal/tools/context_keys.go | 62 ++++++++++++++++++++++++++++++ internal/tools/edit.go | 4 +- internal/tools/filesystem.go | 4 +- internal/tools/filesystem_list.go | 4 +- internal/tools/filesystem_write.go | 4 +- internal/tools/memory.go | 20 +++++++++- internal/tools/shell.go | 4 +- internal/tools/subagent.go | 48 +++++++++++++++++++---- internal/tools/subagent_config.go | 10 ++--- internal/tools/subagent_exec.go | 12 +++--- 17 files changed, 204 insertions(+), 41 deletions(-) diff --git a/internal/agent/loop.go b/internal/agent/loop.go index cd222e8c..a3f6db2b 100644 --- a/internal/agent/loop.go +++ b/internal/agent/loop.go @@ -73,6 +73,19 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error) if req.ChannelType != "" { ctx = tools.WithToolChannelType(ctx, req.ChannelType) } + // Inject per-agent overrides from DB so tools honor per-agent settings. + if l.restrictToWs != nil { + ctx = tools.WithRestrictToWorkspace(ctx, *l.restrictToWs) + } + if l.subagentsCfg != nil { + ctx = tools.WithSubagentConfig(ctx, l.subagentsCfg) + } + if l.memoryCfg != nil { + ctx = tools.WithMemoryConfig(ctx, l.memoryCfg) + } + if l.sandboxCfg != nil { + ctx = tools.WithSandboxConfig(ctx, l.sandboxCfg) + } // Per-user workspace isolation. // Workspace path comes from user_agent_profiles (includes channel segment diff --git a/internal/agent/loop_types.go b/internal/agent/loop_types.go index 7cd5f4a5..0a739af7 100644 --- a/internal/agent/loop_types.go +++ b/internal/agent/loop_types.go @@ -12,6 +12,7 @@ import ( "github.com/nextlevelbuilder/goclaw/internal/config" "github.com/nextlevelbuilder/goclaw/internal/media" "github.com/nextlevelbuilder/goclaw/internal/providers" + "github.com/nextlevelbuilder/goclaw/internal/sandbox" "github.com/nextlevelbuilder/goclaw/internal/skills" "github.com/nextlevelbuilder/goclaw/internal/store" "github.com/nextlevelbuilder/goclaw/internal/tools" @@ -47,6 +48,12 @@ type Loop struct { maxToolCalls int workspace string + // Per-agent overrides from DB (nil = use global defaults) + restrictToWs *bool + subagentsCfg *config.SubagentsConfig + memoryCfg *config.MemoryConfig + sandboxCfg *sandbox.Config + eventPub bus.EventPublisher // currently unused by Loop; kept for future use sessions store.SessionStore tools *tools.Registry @@ -147,6 +154,13 @@ type LoopConfig struct { MaxIterations int MaxToolCalls int Workspace string + + // Per-agent DB overrides (nil = use global defaults) + RestrictToWs *bool + SubagentsCfg *config.SubagentsConfig + MemoryCfg *config.MemoryConfig + SandboxCfg *sandbox.Config + Bus bus.EventPublisher Sessions store.SessionStore Tools *tools.Registry @@ -248,6 +262,10 @@ func NewLoop(cfg LoopConfig) *Loop { maxIterations: cfg.MaxIterations, maxToolCalls: cfg.MaxToolCalls, workspace: cfg.Workspace, + restrictToWs: cfg.RestrictToWs, + subagentsCfg: cfg.SubagentsCfg, + memoryCfg: cfg.MemoryCfg, + sandboxCfg: cfg.SandboxCfg, eventPub: cfg.Bus, sessions: cfg.Sessions, tools: cfg.Tools, diff --git a/internal/agent/resolver.go b/internal/agent/resolver.go index 98f50124..1177423d 100644 --- a/internal/agent/resolver.go +++ b/internal/agent/resolver.go @@ -15,6 +15,7 @@ import ( mcpbridge "github.com/nextlevelbuilder/goclaw/internal/mcp" "github.com/nextlevelbuilder/goclaw/internal/media" "github.com/nextlevelbuilder/goclaw/internal/providers" + "github.com/nextlevelbuilder/goclaw/internal/sandbox" "github.com/nextlevelbuilder/goclaw/internal/skills" "github.com/nextlevelbuilder/goclaw/internal/store" "github.com/nextlevelbuilder/goclaw/internal/tools" @@ -243,10 +244,12 @@ func NewManagedResolver(deps ResolverDeps) ResolverFunc { sandboxEnabled := deps.SandboxEnabled sandboxContainerDir := deps.SandboxContainerDir sandboxWorkspaceAccess := deps.SandboxWorkspaceAccess + var sandboxCfgOverride *sandbox.Config if c := ag.ParseSandboxConfig(); c != nil { resolved := c.ToSandboxConfig() sandboxContainerDir = resolved.ContainerWorkdir() sandboxWorkspaceAccess = string(resolved.WorkspaceAccess) + sandboxCfgOverride = &resolved } // Expand ~ in workspace path and ensure directory exists @@ -345,6 +348,7 @@ func NewManagedResolver(deps ResolverDeps) ResolverFunc { } } + restrictVal := ag.RestrictToWorkspace loop := NewLoop(LoopConfig{ ID: ag.AgentKey, AgentUUID: ag.ID, @@ -354,6 +358,10 @@ func NewManagedResolver(deps ResolverDeps) ResolverFunc { ContextWindow: contextWindow, MaxIterations: maxIter, Workspace: workspace, + RestrictToWs: &restrictVal, + SubagentsCfg: ag.ParseSubagentsConfig(), + MemoryCfg: ag.ParseMemoryConfig(), + SandboxCfg: sandboxCfgOverride, Bus: deps.Bus, Sessions: deps.Sessions, Tools: toolsReg, diff --git a/internal/sandbox/docker.go b/internal/sandbox/docker.go index f3f5c64e..a6eb61d1 100644 --- a/internal/sandbox/docker.go +++ b/internal/sandbox/docker.go @@ -230,8 +230,13 @@ func NewDockerManager(cfg Config) *DockerManager { } // Get returns an existing sandbox or creates a new one for the given key. -func (m *DockerManager) Get(ctx context.Context, key string, workspace string) (Sandbox, error) { - if m.config.Mode == ModeOff { +// If cfgOverride is non-nil, it is used for new containers instead of the global config. +func (m *DockerManager) Get(ctx context.Context, key string, workspace string, cfgOverride *Config) (Sandbox, error) { + cfg := m.config + if cfgOverride != nil { + cfg = *cfgOverride + } + if cfg.Mode == ModeOff { return nil, ErrSandboxDisabled } @@ -250,12 +255,12 @@ func (m *DockerManager) Get(ctx context.Context, key string, workspace string) ( return sb, nil } - prefix := m.config.ContainerPrefix + prefix := cfg.ContainerPrefix if prefix == "" { prefix = "goclaw-sbx-" } name := prefix + sanitizeKey(key) - sb, err := newDockerSandbox(ctx, name, m.config, workspace) + sb, err := newDockerSandbox(ctx, name, cfg, workspace) if err != nil { return nil, err } diff --git a/internal/sandbox/sandbox.go b/internal/sandbox/sandbox.go index db9d4189..99a409a8 100644 --- a/internal/sandbox/sandbox.go +++ b/internal/sandbox/sandbox.go @@ -171,7 +171,8 @@ type Manager interface { // For session scope: key = sessionKey // For agent scope: key = agentID // For shared scope: key = "shared" - Get(ctx context.Context, key string, workspace string) (Sandbox, error) + // If cfgOverride is non-nil, it is used instead of the global config for new containers. + Get(ctx context.Context, key string, workspace string, cfgOverride *Config) (Sandbox, error) // Release destroys a sandbox by key. Release(ctx context.Context, key string) error diff --git a/internal/store/memory_store.go b/internal/store/memory_store.go index cefbfb3c..5c3115f2 100644 --- a/internal/store/memory_store.go +++ b/internal/store/memory_store.go @@ -24,10 +24,12 @@ type MemorySearchResult struct { // MemorySearchOptions configures a memory search query. type MemorySearchOptions struct { - MaxResults int - MinScore float64 - Source string // "memory", "sessions", "" - PathPrefix string + MaxResults int + MinScore float64 + Source string // "memory", "sessions", "" + PathPrefix string + VectorWeight float64 // per-agent override (0 = use store default) + TextWeight float64 // per-agent override (0 = use store default) } // EmbeddingProvider generates vector embeddings for text. diff --git a/internal/store/pg/memory_search.go b/internal/store/pg/memory_search.go index 2067d599..f5ee6c0e 100644 --- a/internal/store/pg/memory_search.go +++ b/internal/store/pg/memory_search.go @@ -34,8 +34,14 @@ func (s *PGMemoryStore) Search(ctx context.Context, query string, agentID, userI } } - // Merge results — normalize weights when one channel has no results + // Merge results — use per-query overrides if set, else store defaults textW, vecW := s.cfg.TextWeight, s.cfg.VectorWeight + if opts.TextWeight > 0 { + textW = opts.TextWeight + } + if opts.VectorWeight > 0 { + vecW = opts.VectorWeight + } if len(ftsResults) == 0 && len(vecResults) > 0 { textW, vecW = 0, 1.0 } else if len(vecResults) == 0 && len(ftsResults) > 0 { diff --git a/internal/tools/context_keys.go b/internal/tools/context_keys.go index 81318570..555d2ec3 100644 --- a/internal/tools/context_keys.go +++ b/internal/tools/context_keys.go @@ -4,6 +4,7 @@ import ( "context" "github.com/nextlevelbuilder/goclaw/internal/config" + "github.com/nextlevelbuilder/goclaw/internal/sandbox" ) // Tool execution context keys. @@ -164,3 +165,64 @@ func BuiltinToolSettingsFromCtx(ctx context.Context) BuiltinToolSettings { v, _ := ctx.Value(ctxBuiltinToolSettings).(BuiltinToolSettings) return v } + +// --- Per-agent restrict_to_workspace override --- + +const ctxRestrictWs toolContextKey = "tool_restrict_to_workspace" + +// WithRestrictToWorkspace injects a per-agent restrict_to_workspace override into context. +func WithRestrictToWorkspace(ctx context.Context, restrict bool) context.Context { + return context.WithValue(ctx, ctxRestrictWs, restrict) +} + +// RestrictFromCtx returns the per-agent restrict_to_workspace override. +func RestrictFromCtx(ctx context.Context) (bool, bool) { + v, ok := ctx.Value(ctxRestrictWs).(bool) + return v, ok +} + +func effectiveRestrict(ctx context.Context, toolDefault bool) bool { + if v, ok := RestrictFromCtx(ctx); ok { + return v + } + return toolDefault +} + +// --- Per-agent subagent config override --- + +const ctxSubagentCfg toolContextKey = "tool_subagent_config" + +func WithSubagentConfig(ctx context.Context, cfg *config.SubagentsConfig) context.Context { + return context.WithValue(ctx, ctxSubagentCfg, cfg) +} + +func SubagentConfigFromCtx(ctx context.Context) *config.SubagentsConfig { + v, _ := ctx.Value(ctxSubagentCfg).(*config.SubagentsConfig) + return v +} + +// --- Per-agent memory config override --- + +const ctxMemoryCfg toolContextKey = "tool_memory_config" + +func WithMemoryConfig(ctx context.Context, cfg *config.MemoryConfig) context.Context { + return context.WithValue(ctx, ctxMemoryCfg, cfg) +} + +func MemoryConfigFromCtx(ctx context.Context) *config.MemoryConfig { + v, _ := ctx.Value(ctxMemoryCfg).(*config.MemoryConfig) + return v +} + +// --- Per-agent sandbox config override --- + +const ctxSandboxCfg toolContextKey = "tool_sandbox_config" + +func WithSandboxConfig(ctx context.Context, cfg *sandbox.Config) context.Context { + return context.WithValue(ctx, ctxSandboxCfg, cfg) +} + +func SandboxConfigFromCtx(ctx context.Context) *sandbox.Config { + v, _ := ctx.Value(ctxSandboxCfg).(*sandbox.Config) + return v +} diff --git a/internal/tools/edit.go b/internal/tools/edit.go index 680cf1a1..560d11fa 100644 --- a/internal/tools/edit.go +++ b/internal/tools/edit.go @@ -160,7 +160,7 @@ func (t *EditTool) Execute(ctx context.Context, args map[string]any) *Result { if workspace == "" { workspace = t.workspace } - resolved, err := resolvePath(path, workspace, t.restrict) + resolved, err := resolvePath(path, workspace, effectiveRestrict(ctx, t.restrict)) if err != nil { return ErrorResult(err.Error()) } @@ -192,7 +192,7 @@ func (t *EditTool) Execute(ctx context.Context, args map[string]any) *Result { } func (t *EditTool) executeInSandbox(ctx context.Context, path, oldStr, newStr string, replaceAll bool, sandboxKey string) *Result { - sb, err := t.sandboxMgr.Get(ctx, sandboxKey, t.workspace) + sb, err := t.sandboxMgr.Get(ctx, sandboxKey, t.workspace, SandboxConfigFromCtx(ctx)) if err != nil { return ErrorResult(fmt.Sprintf("sandbox error: %v", err)) } diff --git a/internal/tools/filesystem.go b/internal/tools/filesystem.go index c5e99b03..cff20b9c 100644 --- a/internal/tools/filesystem.go +++ b/internal/tools/filesystem.go @@ -146,7 +146,7 @@ func (t *ReadFileTool) Execute(ctx context.Context, args map[string]any) *Result if workspace == "" { workspace = t.workspace } - resolved, err := resolvePathWithAllowed(path, workspace, t.restrict, t.allowedPrefixes) + resolved, err := resolvePathWithAllowed(path, workspace, effectiveRestrict(ctx, t.restrict), t.allowedPrefixes) if err != nil { return ErrorResult(err.Error()) } @@ -177,7 +177,7 @@ func (t *ReadFileTool) executeInSandbox(ctx context.Context, path, sandboxKey st } func (t *ReadFileTool) getFsBridge(ctx context.Context, sandboxKey string) (*sandbox.FsBridge, error) { - sb, err := t.sandboxMgr.Get(ctx, sandboxKey, t.workspace) + sb, err := t.sandboxMgr.Get(ctx, sandboxKey, t.workspace, SandboxConfigFromCtx(ctx)) if err != nil { return nil, err } diff --git a/internal/tools/filesystem_list.go b/internal/tools/filesystem_list.go index e58ff6b1..fbd19b75 100644 --- a/internal/tools/filesystem_list.go +++ b/internal/tools/filesystem_list.go @@ -88,7 +88,7 @@ func (t *ListFilesTool) Execute(ctx context.Context, args map[string]any) *Resul if workspace == "" { workspace = t.workspace } - resolved, err := resolvePath(path, workspace, t.restrict) + resolved, err := resolvePath(path, workspace, effectiveRestrict(ctx, t.restrict)) if err != nil { return ErrorResult(err.Error()) } @@ -142,7 +142,7 @@ func (t *ListFilesTool) executeInSandbox(ctx context.Context, path, sandboxKey s } func (t *ListFilesTool) getFsBridge(ctx context.Context, sandboxKey string) (*sandbox.FsBridge, error) { - sb, err := t.sandboxMgr.Get(ctx, sandboxKey, t.workspace) + sb, err := t.sandboxMgr.Get(ctx, sandboxKey, t.workspace, SandboxConfigFromCtx(ctx)) if err != nil { return nil, err } diff --git a/internal/tools/filesystem_write.go b/internal/tools/filesystem_write.go index f2aabc07..a929e676 100644 --- a/internal/tools/filesystem_write.go +++ b/internal/tools/filesystem_write.go @@ -128,7 +128,7 @@ func (t *WriteFileTool) Execute(ctx context.Context, args map[string]any) *Resul if workspace == "" { workspace = t.workspace } - resolved, err := resolvePath(path, workspace, t.restrict) + resolved, err := resolvePath(path, workspace, effectiveRestrict(ctx, t.restrict)) if err != nil { return ErrorResult(err.Error()) } @@ -177,7 +177,7 @@ func (t *WriteFileTool) executeInSandbox(ctx context.Context, path, content, san } func (t *WriteFileTool) getFsBridge(ctx context.Context, sandboxKey string) (*sandbox.FsBridge, error) { - sb, err := t.sandboxMgr.Get(ctx, sandboxKey, t.workspace) + sb, err := t.sandboxMgr.Get(ctx, sandboxKey, t.workspace, SandboxConfigFromCtx(ctx)) if err != nil { return nil, err } diff --git a/internal/tools/memory.go b/internal/tools/memory.go index ae1bfddc..86468362 100644 --- a/internal/tools/memory.go +++ b/internal/tools/memory.go @@ -73,10 +73,26 @@ func (t *MemorySearchTool) Execute(ctx context.Context, args map[string]any) *Re } userID := store.UserIDFromContext(ctx) - results, err := t.memStore.Search(ctx, query, agentID.String(), userID, store.MemorySearchOptions{ + searchOpts := store.MemorySearchOptions{ MaxResults: maxResults, MinScore: minScore, - }) + } + // Apply per-agent memory config overrides if set + if mc := MemoryConfigFromCtx(ctx); mc != nil { + if mc.MaxResults > 0 && searchOpts.MaxResults <= 0 { + searchOpts.MaxResults = mc.MaxResults + } + if mc.VectorWeight > 0 { + searchOpts.VectorWeight = mc.VectorWeight + } + if mc.TextWeight > 0 { + searchOpts.TextWeight = mc.TextWeight + } + if mc.MinScore > 0 && searchOpts.MinScore <= 0 { + searchOpts.MinScore = mc.MinScore + } + } + results, err := t.memStore.Search(ctx, query, agentID.String(), userID, searchOpts) if err != nil { return ErrorResult(fmt.Sprintf("memory search failed: %v", err)) } diff --git a/internal/tools/shell.go b/internal/tools/shell.go index 06c4c468..91004345 100644 --- a/internal/tools/shell.go +++ b/internal/tools/shell.go @@ -250,7 +250,7 @@ func (t *ExecTool) Execute(ctx context.Context, args map[string]any) *Result { cwd = t.workingDir } if wd, _ := args["working_dir"].(string); wd != "" { - if t.restrict { + if effectiveRestrict(ctx, t.restrict) { resolved, err := resolvePath(wd, t.workingDir, true) if err != nil { return ErrorResult(err.Error()) @@ -315,7 +315,7 @@ func (t *ExecTool) executeOnHost(ctx context.Context, command, cwd string) *Resu // executeInSandbox routes a command through a Docker sandbox container. func (t *ExecTool) executeInSandbox(ctx context.Context, command, cwd, sandboxKey string) *Result { - sb, err := t.sandboxMgr.Get(ctx, sandboxKey, t.workingDir) + sb, err := t.sandboxMgr.Get(ctx, sandboxKey, t.workingDir, SandboxConfigFromCtx(ctx)) if err != nil { if errors.Is(err, sandbox.ErrSandboxDisabled) { return t.executeOnHost(ctx, command, cwd) diff --git a/internal/tools/subagent.go b/internal/tools/subagent.go index 562326a5..017c47d5 100644 --- a/internal/tools/subagent.go +++ b/internal/tools/subagent.go @@ -63,6 +63,7 @@ type SubagentTask struct { 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. @@ -103,6 +104,32 @@ 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 +} + // CountRunningForParent returns the number of running tasks for a parent. func (sm *SubagentManager) CountRunningForParent(parentID string) int { sm.mu.RLock() @@ -147,12 +174,13 @@ func (sm *SubagentManager) Spawn( channel, chatID, peerKind string, callback AsyncCallback, ) (string, error) { + cfg := sm.effectiveConfig(ctx) sm.mu.Lock() // Check depth limit - if depth >= sm.config.MaxSpawnDepth { + if depth >= cfg.MaxSpawnDepth { sm.mu.Unlock() - return "", fmt.Errorf("spawn depth limit reached (%d/%d)", depth, sm.config.MaxSpawnDepth) + return "", fmt.Errorf("spawn depth limit reached (%d/%d)", depth, cfg.MaxSpawnDepth) } // Check concurrent limit @@ -162,9 +190,9 @@ func (sm *SubagentManager) Spawn( running++ } } - if running >= sm.config.MaxConcurrent { + if running >= cfg.MaxConcurrent { sm.mu.Unlock() - return "", fmt.Errorf("max concurrent subagents reached (%d/%d)", running, sm.config.MaxConcurrent) + return "", fmt.Errorf("max concurrent subagents reached (%d/%d)", running, cfg.MaxConcurrent) } // Check per-parent children limit @@ -174,9 +202,9 @@ func (sm *SubagentManager) Spawn( childCount++ } } - if childCount >= sm.config.MaxChildrenPerAgent { + if childCount >= cfg.MaxChildrenPerAgent { sm.mu.Unlock() - return "", fmt.Errorf("max children per agent reached (%d/%d)", childCount, sm.config.MaxChildrenPerAgent) + return "", fmt.Errorf("max children per agent reached (%d/%d)", childCount, cfg.MaxChildrenPerAgent) } id := generateSubagentID() @@ -201,6 +229,7 @@ func (sm *SubagentManager) Spawn( OriginTraceID: tracing.TraceIDFromContext(ctx), OriginRootSpanID: tracing.ParentSpanIDFromContext(ctx), CreatedAt: time.Now().UnixMilli(), + spawnConfig: cfg, } // Create per-task context for real goroutine cancellation taskCtx, taskCancel := context.WithCancel(ctx) @@ -225,11 +254,13 @@ func (sm *SubagentManager) RunSync( task, label string, channel, chatID string, ) (string, int, error) { + cfg := sm.effectiveConfig(ctx) + sm.mu.Lock() - if depth >= sm.config.MaxSpawnDepth { + if depth >= cfg.MaxSpawnDepth { sm.mu.Unlock() - return "", 0, fmt.Errorf("spawn depth limit reached (%d/%d)", depth, sm.config.MaxSpawnDepth) + return "", 0, fmt.Errorf("spawn depth limit reached (%d/%d)", depth, cfg.MaxSpawnDepth) } id := generateSubagentID() @@ -251,6 +282,7 @@ func (sm *SubagentManager) RunSync( OriginTraceID: tracing.TraceIDFromContext(ctx), OriginRootSpanID: tracing.ParentSpanIDFromContext(ctx), CreatedAt: time.Now().UnixMilli(), + spawnConfig: cfg, } sm.tasks[id] = subTask sm.mu.Unlock() diff --git a/internal/tools/subagent_config.go b/internal/tools/subagent_config.go index 23b0d277..5add1cec 100644 --- a/internal/tools/subagent_config.go +++ b/internal/tools/subagent_config.go @@ -14,14 +14,14 @@ func DefaultSubagentConfig() SubagentConfig { } // applyDenyList removes denied tools from the registry based on depth. -func (sm *SubagentManager) applyDenyList(reg *Registry, depth int) { +func (sm *SubagentManager) applyDenyList(reg *Registry, depth int, cfg SubagentConfig) { // Always deny for _, name := range SubagentDenyAlways { reg.Unregister(name) } // Leaf deny (at max depth) - if depth >= sm.config.MaxSpawnDepth { + if depth >= cfg.MaxSpawnDepth { for _, name := range SubagentDenyLeaf { reg.Unregister(name) } @@ -30,13 +30,13 @@ func (sm *SubagentManager) applyDenyList(reg *Registry, depth int) { // buildSubagentSystemPrompt constructs the system prompt for a subagent, // matching the TS buildSubagentSystemPrompt pattern from subagent-announce.ts. -func (sm *SubagentManager) buildSubagentSystemPrompt(task *SubagentTask) string { +func (sm *SubagentManager) buildSubagentSystemPrompt(task *SubagentTask, cfg SubagentConfig) string { parentLabel := "main agent" if task.Depth >= 2 { parentLabel = "parent orchestrator" } - canSpawn := task.Depth < sm.config.MaxSpawnDepth + canSpawn := task.Depth < cfg.MaxSpawnDepth prompt := fmt.Sprintf(`# Subagent Context @@ -86,7 +86,7 @@ You are a leaf worker and CANNOT spawn further sub-agents. Focus on your assigne ## Session Context - Label: %s -- Depth: %d / %d`, task.Label, task.Depth, sm.config.MaxSpawnDepth) +- Depth: %d / %d`, task.Label, task.Depth, cfg.MaxSpawnDepth) return prompt } diff --git a/internal/tools/subagent_exec.go b/internal/tools/subagent_exec.go index cf223382..cb49d531 100644 --- a/internal/tools/subagent_exec.go +++ b/internal/tools/subagent_exec.go @@ -128,8 +128,8 @@ func (sm *SubagentManager) executeTask(ctx context.Context, task *SubagentTask) "status", task.Status, "iterations", iteration) // Schedule auto-archive - if sm.config.ArchiveAfterMinutes > 0 { - go sm.scheduleArchive(task.ID, time.Duration(sm.config.ArchiveAfterMinutes)*time.Minute) + if task.spawnConfig.ArchiveAfterMinutes > 0 { + go sm.scheduleArchive(task.ID, time.Duration(task.spawnConfig.ArchiveAfterMinutes)*time.Minute) } }() @@ -143,15 +143,15 @@ func (sm *SubagentManager) executeTask(ctx context.Context, task *SubagentTask) // Build tools for subagent (no spawn/subagent tools to prevent recursion) toolsReg := sm.createTools() - sm.applyDenyList(toolsReg, task.Depth) + sm.applyDenyList(toolsReg, task.Depth, task.spawnConfig) // Determine model (cascading priority matching TS sessions-spawn-tool.ts): // 1. Per-task model override (highest) // 2. SubagentConfig.Model (global subagent override) // 3. SubagentManager default model (inherited from parent) model = sm.model - if sm.config.Model != "" { - model = sm.config.Model + if task.spawnConfig.Model != "" { + model = task.spawnConfig.Model } if task.Model != "" { model = task.Model @@ -161,7 +161,7 @@ func (sm *SubagentManager) executeTask(ctx context.Context, task *SubagentTask) sm.emitSubagentSpanStart(traceCtx, subRootSpanID, taskStart, task, model) // Build subagent system prompt (matching TS buildSubagentSystemPrompt pattern). - systemPrompt := sm.buildSubagentSystemPrompt(task) + systemPrompt := sm.buildSubagentSystemPrompt(task, task.spawnConfig) messages := []providers.Message{ {Role: "system", Content: systemPrompt},