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>
This commit is contained in:
Luan VuandLuvu182 authored and GitHub committed 2026-03-11 16:05:56 +07:00
1 parent 1b99406012
commit 2fdb791802
17 files changed
+204 -41

No files matched your search

+13
View File
@@ -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
+18
View File
@@ -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,
+8
View File
@@ -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,
+9 -4
View File
@@ -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
}
+2 -1
View File
@@ -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
+6 -4
View File
@@ -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.
+7 -1
View File
@@ -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 {
+62
View File
@@ -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
}
+2 -2
View File
@@ -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))
}
+2 -2
View File
@@ -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
}
+2 -2
View File
@@ -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
}
+2 -2
View File
@@ -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
}
+18 -2
View File
@@ -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))
}
+2 -2
View File
@@ -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)
+40 -8
View File
@@ -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()
+5 -5
View File
@@ -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
}
+6 -6
View File
@@ -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},