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 }