Files
goclaw/internal/agent/resolver.go
T
viettranx 2504095dfe fix(agents): complete shell deny groups propagation chain
ShellDenyGroups was defined in SystemPromptConfig but lacked full propagation
through parser, Loop fields, context injection, and system prompt population.
Per-agent overrides from other_config JSONB had zero runtime effect.

Changes:
- agent_store.go: Add ParseShellDenyGroups() to extract overrides from JSONB
- loop_types.go: Add shellDenyGroups field to Loop and LoopConfig, wire in NewLoop
- resolver.go: Wire agent-parsed shell deny groups into LoopConfig
- loop.go: Inject shellDenyGroups into context via store.WithShellDenyGroups
- loop_history.go: Populate ShellDenyGroups in system prompt config
- message_test.go: Fix macOS symlink path normalization in test expectations

Fixes test failures on macOS where /var/folders symlinks to /private/var/folders.
2026-03-18 17:04:26 +07:00

391 lines
14 KiB
Go

package agent
import (
"context"
"fmt"
"log/slog"
"os"
"path/filepath"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/bootstrap"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/config"
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"
"github.com/nextlevelbuilder/goclaw/internal/tracing"
)
// ResolverDeps holds shared dependencies for the agent resolver.
type ResolverDeps struct {
AgentStore store.AgentStore
ProviderReg *providers.Registry
Bus bus.EventPublisher
Sessions store.SessionStore
Tools *tools.Registry
ToolPolicy *tools.PolicyEngine
Skills *skills.Loader
HasMemory bool
OnEvent func(AgentEvent)
TraceCollector *tracing.Collector
// Per-user file seeding + dynamic context loading
EnsureUserFiles EnsureUserFilesFunc
ContextFileLoader ContextFileLoaderFunc
BootstrapCleanup BootstrapCleanupFunc
// Security
InjectionAction string // "log", "warn", "block", "off"
MaxMessageChars int
// Global defaults (from config.json) — per-agent DB overrides take priority
CompactionCfg *config.CompactionConfig
ContextPruningCfg *config.ContextPruningConfig
SandboxEnabled bool
SandboxContainerDir string
SandboxWorkspaceAccess string
// Dynamic custom tools
DynamicLoader *tools.DynamicToolLoader
// Inter-agent delegation
AgentLinkStore store.AgentLinkStore
// Agent teams
TeamStore store.TeamStore
DataDir string // global workspace root for team workspace resolution
// Secure CLI credential store for credentialed exec
SecureCLIStore store.SecureCLIStore
// Builtin tool settings
BuiltinToolStore store.BuiltinToolStore
// MCP server store — for per-agent MCP tool loading
MCPStore store.MCPServerStore
// Shared MCP connection pool — eliminates duplicate connections across agents
MCPPool *mcpbridge.Pool
// Skill access store — for per-agent skill visibility filtering
SkillAccessStore store.SkillAccessStore
// Group file writer cache
GroupWriterCache *store.GroupWriterCache
// Persistent media storage for cross-turn image/document access
MediaStore *media.Store
// Model pricing for cost tracking
ModelPricing map[string]*config.ModelPricing
// Tracing store for budget enforcement queries
TracingStore store.TracingStore
}
// NewManagedResolver creates a ResolverFunc that builds Loops from DB agent data.
// Agents are defined in Postgres, not config.json.
func NewManagedResolver(deps ResolverDeps) ResolverFunc {
return func(agentKey string) (Agent, error) {
ctx := context.Background()
// Support lookup by UUID (e.g. from cron jobs that store agent_id as UUID)
var ag *store.AgentData
var err error
if id, parseErr := uuid.Parse(agentKey); parseErr == nil {
ag, err = deps.AgentStore.GetByID(ctx, id)
} else {
ag, err = deps.AgentStore.GetByKey(ctx, agentKey)
}
if err != nil {
return nil, fmt.Errorf("agent not found: %s", agentKey)
}
if ag.Status != store.AgentStatusActive {
return nil, fmt.Errorf("agent %s is inactive", agentKey)
}
// Resolve provider
provider, err := deps.ProviderReg.Get(ag.Provider)
if err != nil {
// Fallback to any available provider
names := deps.ProviderReg.List()
if len(names) == 0 {
return nil, fmt.Errorf("no providers configured for agent %s", agentKey)
}
provider, _ = deps.ProviderReg.Get(names[0])
slog.Warn("agent provider not found, using fallback",
"agent", agentKey, "wanted", ag.Provider, "using", names[0])
if tl := ag.ParseThinkingLevel(); tl != "" && tl != "off" {
slog.Warn("agent thinking may not be supported by fallback provider",
"agent", agentKey, "thinking_level", tl,
"wanted_provider", ag.Provider, "fallback_provider", names[0])
}
}
if provider == nil {
return nil, fmt.Errorf("no provider available for agent %s", agentKey)
}
// Load bootstrap files from DB
contextFiles := bootstrap.LoadFromStore(ctx, deps.AgentStore, ag.ID)
// Inject TEAM.md for all team members (lead + members) so every agent
// knows the team workflow: create/claim/complete tasks via team_tasks tool.
hasTeam := false
isTeamLead := false
if deps.TeamStore != nil {
hasTeamMD := false
for _, cf := range contextFiles {
if cf.Path == bootstrap.TeamFile {
hasTeamMD = true
break
}
}
if !hasTeamMD {
if team, err := deps.TeamStore.GetTeamForAgent(ctx, ag.ID); err == nil && team != nil {
if members, err := deps.TeamStore.ListMembers(ctx, team.ID); err == nil {
hasTeam = true
contextFiles = append(contextFiles, bootstrap.ContextFile{
Path: bootstrap.TeamFile,
Content: buildTeamMD(team, members, ag.ID, tools.IsTeamV2(team)),
})
// Detect lead role for tool policy
for _, m := range members {
if m.AgentID == ag.ID && m.Role == store.TeamRoleLead {
isTeamLead = true
break
}
}
}
}
} else {
hasTeam = true
}
}
// Inject negative context so the model doesn't waste iterations probing
// unavailable capabilities (team_tasks, etc.).
if !hasTeam {
contextFiles = append(contextFiles, bootstrap.ContextFile{
Path: bootstrap.AvailabilityFile,
Content: "You are NOT part of any team. Do not use team_tasks or team_message tools.",
})
}
contextWindow := ag.ContextWindow
if contextWindow <= 0 {
contextWindow = 200000
}
maxIter := ag.MaxToolIterations
if maxIter <= 0 {
maxIter = 20
}
// Per-agent config overrides (fallback to global defaults from config.json)
compactionCfg := deps.CompactionCfg
if c := ag.ParseCompactionConfig(); c != nil {
compactionCfg = c
}
contextPruningCfg := deps.ContextPruningCfg
if c := ag.ParseContextPruning(); c != nil {
contextPruningCfg = c
}
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
workspace := ag.Workspace
if workspace != "" {
workspace = config.ExpandHome(workspace)
if !filepath.IsAbs(workspace) {
workspace, _ = filepath.Abs(workspace)
}
if err := os.MkdirAll(workspace, 0755); err != nil {
slog.Warn("failed to create agent workspace directory", "workspace", workspace, "agent", agentKey, "error", err)
}
}
// Per-agent custom tools (clone registry if agent has custom tools)
toolsReg := deps.Tools
if deps.DynamicLoader != nil {
if agentReg, err := deps.DynamicLoader.LoadForAgent(ctx, deps.Tools, ag.ID); err != nil {
slog.Warn("failed to load custom tools", "agent", agentKey, "error", err)
} else if agentReg != nil {
toolsReg = agentReg
}
}
// Per-agent MCP servers: connect to granted MCP servers and register their tools.
// Uses a per-agent MCP Manager that queries the MCPServerStore for accessible servers.
//
// IMPORTANT: Always clone the registry before MCP registration to prevent
// cross-agent tool leaks. Without cloning, MCP BridgeTools registered for
// one agent pollute the shared deps. Tools and become visible to ALL agents
// (even those without MCP grants), because FilterTools reads from registry.List().
hasMCPTools := false
if deps.MCPStore != nil {
if toolsReg == deps.Tools {
toolsReg = deps.Tools.Clone()
}
var mcpOpts []mcpbridge.ManagerOption
mcpOpts = append(mcpOpts, mcpbridge.WithStore(deps.MCPStore))
if deps.MCPPool != nil {
mcpOpts = append(mcpOpts, mcpbridge.WithPool(deps.MCPPool))
}
mcpMgr := mcpbridge.NewManager(toolsReg, mcpOpts...)
if err := mcpMgr.LoadForAgent(ctx, ag.ID, ""); err != nil {
slog.Warn("failed to load MCP servers for agent", "agent", agentKey, "error", err)
} else if mcpMgr.IsSearchMode() {
// Search mode: too many tools — register mcp_tool_search meta-tool.
// Also wire lazy activator so deferred tools can be called by name directly.
toolsReg.SetDeferredActivator(mcpMgr.ActivateToolIfDeferred)
searchTool := mcpbridge.NewMCPToolSearchTool(mcpMgr)
toolsReg.Register(searchTool)
hasMCPTools = true
slog.Info("mcp.agent.search_mode", "agent", agentKey,
"deferred_tools", len(mcpMgr.DeferredToolInfos()))
} else {
toolNames := mcpMgr.ToolNames()
if len(toolNames) > 0 {
hasMCPTools = true
slog.Info("mcp.agent.tools_loaded", "agent", agentKey, "tools", len(toolNames))
}
}
}
// Per-agent memory: enabled if global memory manager exists AND
// per-agent config doesn't explicitly disable it.
hasMemory := deps.HasMemory
if mc := ag.ParseMemoryConfig(); mc != nil && mc.Enabled != nil {
if !*mc.Enabled {
hasMemory = false
}
}
// Load global builtin tool settings from DB (for settings cascade)
var builtinSettings tools.BuiltinToolSettings
if deps.BuiltinToolStore != nil {
if allTools, err := deps.BuiltinToolStore.List(ctx); err == nil {
builtinSettings = make(tools.BuiltinToolSettings, len(allTools))
for _, t := range allTools {
if len(t.Settings) > 0 && string(t.Settings) != "{}" {
builtinSettings[t.Name] = []byte(t.Settings)
}
}
}
}
// Filter skills by visibility + agent grants.
// Only public skills and explicitly granted internal skills appear in the system prompt.
var skillAllowList []string
if deps.SkillAccessStore != nil {
if accessible, err := deps.SkillAccessStore.ListAccessible(ctx, ag.ID, ""); err == nil {
skillAllowList = make([]string, 0, len(accessible))
for _, sk := range accessible {
skillAllowList = append(skillAllowList, sk.Slug)
}
slog.Debug("skill visibility filter", "agent", agentKey, "accessible", len(skillAllowList))
} else {
slog.Warn("failed to load accessible skills, falling back to all", "agent", agentKey, "error", err)
// nil = fallback to all (better than blocking all skills)
}
}
restrictVal := true // always restrict agents to their workspace
loop := NewLoop(LoopConfig{
ID: ag.AgentKey,
AgentUUID: ag.ID,
AgentType: ag.AgentType,
Provider: provider,
Model: ag.Model,
ContextWindow: contextWindow,
MaxTokens: ag.ParseMaxTokens(),
MaxIterations: maxIter,
Workspace: workspace,
DataDir: deps.DataDir,
RestrictToWs: &restrictVal,
SubagentsCfg: ag.ParseSubagentsConfig(),
MemoryCfg: ag.ParseMemoryConfig(),
SandboxCfg: sandboxCfgOverride,
Bus: deps.Bus,
Sessions: deps.Sessions,
Tools: toolsReg,
ToolPolicy: deps.ToolPolicy,
AgentToolPolicy: agentToolPolicyForTeam(agentToolPolicyWithWorkspace(agentToolPolicyWithMCP(ag.ParseToolsConfig(), hasMCPTools), hasTeam), isTeamLead),
SkillsLoader: deps.Skills,
SkillAllowList: skillAllowList,
HasMemory: hasMemory,
ContextFiles: contextFiles,
EnsureUserFiles: deps.EnsureUserFiles,
ContextFileLoader: deps.ContextFileLoader,
BootstrapCleanup: deps.BootstrapCleanup,
OnEvent: deps.OnEvent,
TraceCollector: deps.TraceCollector,
InjectionAction: deps.InjectionAction,
MaxMessageChars: deps.MaxMessageChars,
CompactionCfg: compactionCfg,
ContextPruningCfg: contextPruningCfg,
SandboxEnabled: sandboxEnabled,
SandboxContainerDir: sandboxContainerDir,
SandboxWorkspaceAccess: sandboxWorkspaceAccess,
BuiltinToolSettings: builtinSettings,
ThinkingLevel: ag.ParseThinkingLevel(),
SelfEvolve: ag.ParseSelfEvolve(),
SkillEvolve: ag.AgentType == "predefined" && ag.ParseSkillEvolve(),
SkillNudgeInterval: ag.ParseSkillNudgeInterval(),
WorkspaceSharing: ag.ParseWorkspaceSharing(),
ShellDenyGroups: ag.ParseShellDenyGroups(),
GroupWriterCache: deps.GroupWriterCache,
TeamStore: deps.TeamStore,
SecureCLIStore: deps.SecureCLIStore,
MediaStore: deps.MediaStore,
ModelPricing: deps.ModelPricing,
BudgetMonthlyCents: derefInt(ag.BudgetMonthlyCents),
TracingStore: deps.TracingStore,
})
slog.Info("resolved agent from DB", "agent", agentKey, "model", ag.Model, "provider", ag.Provider)
return loop, nil
}
}
// InvalidateAgent removes an agent from the router cache, forcing re-resolution.
// Used when agent config is updated via API.
func (r *Router) InvalidateAgent(agentKey string) {
r.mu.Lock()
defer r.mu.Unlock()
delete(r.agents, agentKey)
slog.Debug("invalidated agent cache", "agent", agentKey)
}
// InvalidateAll clears the entire agent cache, forcing all agents to re-resolve.
// Used when global tools change (custom tools reload).
func (r *Router) InvalidateAll() {
r.mu.Lock()
defer r.mu.Unlock()
r.agents = make(map[string]*agentEntry)
slog.Debug("invalidated all agent caches")
}
func derefInt(p *int) int {
if p == nil {
return 0
}
return *p
}