mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-11 03:13:24 +00:00
* refactor: remove managed/standalone mode distinction from codebase Standalone mode is deprecated; managed mode is now the only mode. Remove redundant "managed mode" qualifiers from comments, docs, and error messages. Error strings now reference "database stores" instead of "managed mode" for clarity. * improve(onboard): streamline onboard process and env setup Simplify onboard wizard, extract helpers to dedicated file, update env example and entrypoint for default managed mode, clean up prepare-env script, update i18n catalogs.
403 lines
14 KiB
Go
403 lines
14 KiB
Go
package agent
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"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/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
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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 DELEGATION.md from delegation links (only if not already present in DB).
|
|
// Uses DELEGATION.md (not AGENTS.md) to avoid collision with per-user AGENTS.md
|
|
// which contains workspace instructions for open agents.
|
|
hasDelegation := false
|
|
if deps.AgentLinkStore != nil {
|
|
hasDelegationMD := false
|
|
for _, cf := range contextFiles {
|
|
if cf.Path == bootstrap.DelegationFile {
|
|
hasDelegationMD = true
|
|
break
|
|
}
|
|
}
|
|
if !hasDelegationMD {
|
|
if allTargets, err := deps.AgentLinkStore.DelegateTargets(ctx, ag.ID); err == nil && len(allTargets) > 0 {
|
|
// Exclude auto-created team links — team members coordinate via
|
|
// team_tasks/team_message, not delegate. Only explicitly created
|
|
// links trigger DELEGATION.md.
|
|
targets := filterManualLinks(allTargets)
|
|
if len(targets) > 0 && len(targets) <= 15 {
|
|
// Static list: all targets directly
|
|
hasDelegation = true
|
|
contextFiles = append(contextFiles, bootstrap.ContextFile{
|
|
Path: bootstrap.DelegationFile,
|
|
Content: buildDelegateAgentsMD(targets),
|
|
})
|
|
} else if len(targets) > 15 {
|
|
// Too many targets: instruct agent to use delegate_search tool
|
|
hasDelegation = true
|
|
contextFiles = append(contextFiles, bootstrap.ContextFile{
|
|
Path: bootstrap.DelegationFile,
|
|
Content: buildDelegateSearchInstruction(len(targets)),
|
|
})
|
|
}
|
|
}
|
|
} else {
|
|
hasDelegation = true
|
|
}
|
|
}
|
|
|
|
// 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),
|
|
})
|
|
// 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, delegate_search, etc.).
|
|
// Note: team agents have delegation targets via team links (TEAM.md),
|
|
// so only inject "no delegation" when both hasDelegation and hasTeam are false.
|
|
if !hasTeam || (!hasDelegation && !hasTeam) {
|
|
var notes []string
|
|
if !hasTeam {
|
|
notes = append(notes, "You are NOT part of any team. Do not use team_tasks or team_message tools.")
|
|
}
|
|
if !hasDelegation && !hasTeam {
|
|
notes = append(notes, "You have NO delegation targets. Do not use spawn with agent parameter or delegate_search tools.")
|
|
}
|
|
contextFiles = append(contextFiles, bootstrap.ContextFile{
|
|
Path: bootstrap.AvailabilityFile,
|
|
Content: strings.Join(notes, "\n"),
|
|
})
|
|
}
|
|
|
|
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
|
|
if c := ag.ParseSandboxConfig(); c != nil {
|
|
resolved := c.ToSandboxConfig()
|
|
sandboxContainerDir = resolved.ContainerWorkdir()
|
|
sandboxWorkspaceAccess = string(resolved.WorkspaceAccess)
|
|
}
|
|
|
|
// 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
|
|
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)
|
|
}
|
|
}
|
|
|
|
loop := NewLoop(LoopConfig{
|
|
ID: ag.AgentKey,
|
|
AgentUUID: ag.ID,
|
|
AgentType: ag.AgentType,
|
|
Provider: provider,
|
|
Model: ag.Model,
|
|
ContextWindow: contextWindow,
|
|
MaxIterations: maxIter,
|
|
Workspace: workspace,
|
|
Bus: deps.Bus,
|
|
Sessions: deps.Sessions,
|
|
Tools: toolsReg,
|
|
ToolPolicy: deps.ToolPolicy,
|
|
AgentToolPolicy: agentToolPolicyForTeam(agentToolPolicyWithMCP(ag.ParseToolsConfig(), hasMCPTools), 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(),
|
|
GroupWriterCache: deps.GroupWriterCache,
|
|
TeamStore: deps.TeamStore,
|
|
MediaStore: deps.MediaStore,
|
|
})
|
|
|
|
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")
|
|
}
|
|
|