package agent import ( "fmt" "log" "os" "path/filepath" "regexp" "strings" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/providers" "github.com/sipeed/picoclaw/pkg/routing" "github.com/sipeed/picoclaw/pkg/session" "github.com/sipeed/picoclaw/pkg/tools" ) // AgentInstance represents a fully configured agent with its own workspace, // session manager, context builder, and tool registry. type AgentInstance struct { ID string Name string Model string Fallbacks []string Workspace string MaxIterations int MaxTokens int Temperature float64 ThinkingLevel ThinkingLevel ContextWindow int SummarizeMessageThreshold int SummarizeTokenPercent int Provider providers.LLMProvider Sessions *session.SessionManager ContextBuilder *ContextBuilder Tools *tools.ToolRegistry Subagents *config.SubagentsConfig SkillsFilter []string Candidates []providers.FallbackCandidate // Router is non-nil when model routing is configured and the light model // was successfully resolved. It scores each incoming message and decides // whether to route to LightCandidates or stay with Candidates. Router *routing.Router // LightCandidates holds the resolved provider candidates for the light model. // Pre-computed at agent creation to avoid repeated model_list lookups at runtime. LightCandidates []providers.FallbackCandidate } // NewAgentInstance creates an agent instance from config. func NewAgentInstance( agentCfg *config.AgentConfig, defaults *config.AgentDefaults, cfg *config.Config, provider providers.LLMProvider, ) *AgentInstance { workspace := resolveAgentWorkspace(agentCfg, defaults) os.MkdirAll(workspace, 0o755) model := resolveAgentModel(agentCfg, defaults) fallbacks := resolveAgentFallbacks(agentCfg, defaults) restrict := defaults.RestrictToWorkspace readRestrict := restrict && !defaults.AllowReadOutsideWorkspace // Compile path whitelist patterns from config. allowReadPaths := compilePatterns(cfg.Tools.AllowReadPaths) allowWritePaths := compilePatterns(cfg.Tools.AllowWritePaths) toolsRegistry := tools.NewToolRegistry() if cfg.Tools.IsToolEnabled("read_file") { toolsRegistry.Register(tools.NewReadFileTool(workspace, readRestrict, allowReadPaths)) } if cfg.Tools.IsToolEnabled("write_file") { toolsRegistry.Register(tools.NewWriteFileTool(workspace, restrict, allowWritePaths)) } if cfg.Tools.IsToolEnabled("list_dir") { toolsRegistry.Register(tools.NewListDirTool(workspace, readRestrict, allowReadPaths)) } if cfg.Tools.IsToolEnabled("exec") { execTool, err := tools.NewExecToolWithConfig(workspace, restrict, cfg) if err != nil { log.Fatalf("Critical error: unable to initialize exec tool: %v", err) } toolsRegistry.Register(execTool) } if cfg.Tools.IsToolEnabled("edit_file") { toolsRegistry.Register(tools.NewEditFileTool(workspace, restrict, allowWritePaths)) } if cfg.Tools.IsToolEnabled("append_file") { toolsRegistry.Register(tools.NewAppendFileTool(workspace, restrict, allowWritePaths)) } sessionsDir := filepath.Join(workspace, "sessions") sessionsManager := session.NewSessionManager(sessionsDir) contextBuilder := NewContextBuilder(workspace) agentID := routing.DefaultAgentID agentName := "" var subagents *config.SubagentsConfig var skillsFilter []string if agentCfg != nil { agentID = routing.NormalizeAgentID(agentCfg.ID) agentName = agentCfg.Name subagents = agentCfg.Subagents skillsFilter = agentCfg.Skills } maxIter := defaults.MaxToolIterations if maxIter == 0 { maxIter = 20 } maxTokens := defaults.MaxTokens if maxTokens == 0 { maxTokens = 8192 } temperature := 0.7 if defaults.Temperature != nil { temperature = *defaults.Temperature } var thinkingLevelStr string if mc, err := cfg.GetModelConfig(model); err == nil { thinkingLevelStr = mc.ThinkingLevel } thinkingLevel := parseThinkingLevel(thinkingLevelStr) summarizeMessageThreshold := defaults.SummarizeMessageThreshold if summarizeMessageThreshold == 0 { summarizeMessageThreshold = 20 } summarizeTokenPercent := defaults.SummarizeTokenPercent if summarizeTokenPercent == 0 { summarizeTokenPercent = 75 } // Resolve fallback candidates modelCfg := providers.ModelConfig{ Primary: model, Fallbacks: fallbacks, } resolveFromModelList := func(raw string) (string, bool) { ensureProtocol := func(model string) string { model = strings.TrimSpace(model) if model == "" { return "" } if strings.Contains(model, "/") { return model } return "openai/" + model } raw = strings.TrimSpace(raw) if raw == "" { return "", false } if cfg != nil { if mc, err := cfg.GetModelConfig(raw); err == nil && mc != nil && strings.TrimSpace(mc.Model) != "" { return ensureProtocol(mc.Model), true } for i := range cfg.ModelList { fullModel := strings.TrimSpace(cfg.ModelList[i].Model) if fullModel == "" { continue } if fullModel == raw { return ensureProtocol(fullModel), true } _, modelID := providers.ExtractProtocol(fullModel) if modelID == raw { return ensureProtocol(fullModel), true } } } return "", false } candidates := providers.ResolveCandidatesWithLookup(modelCfg, defaults.Provider, resolveFromModelList) // Model routing setup: pre-resolve light model candidates at creation time // to avoid repeated model_list lookups on every incoming message. var router *routing.Router var lightCandidates []providers.FallbackCandidate if rc := defaults.Routing; rc != nil && rc.Enabled && rc.LightModel != "" { lightModelCfg := providers.ModelConfig{Primary: rc.LightModel} resolved := providers.ResolveCandidatesWithLookup(lightModelCfg, defaults.Provider, resolveFromModelList) if len(resolved) > 0 { router = routing.New(routing.RouterConfig{ LightModel: rc.LightModel, Threshold: rc.Threshold, }) lightCandidates = resolved } else { log.Printf("routing: light_model %q not found in model_list — routing disabled for agent %q", rc.LightModel, agentID) } } return &AgentInstance{ ID: agentID, Name: agentName, Model: model, Fallbacks: fallbacks, Workspace: workspace, MaxIterations: maxIter, MaxTokens: maxTokens, Temperature: temperature, ThinkingLevel: thinkingLevel, ContextWindow: maxTokens, SummarizeMessageThreshold: summarizeMessageThreshold, SummarizeTokenPercent: summarizeTokenPercent, Provider: provider, Sessions: sessionsManager, ContextBuilder: contextBuilder, Tools: toolsRegistry, Subagents: subagents, SkillsFilter: skillsFilter, Candidates: candidates, Router: router, LightCandidates: lightCandidates, } } // resolveAgentWorkspace determines the workspace directory for an agent. func resolveAgentWorkspace(agentCfg *config.AgentConfig, defaults *config.AgentDefaults) string { if agentCfg != nil && strings.TrimSpace(agentCfg.Workspace) != "" { return expandHome(strings.TrimSpace(agentCfg.Workspace)) } // Use the configured default workspace (respects PICOCLAW_HOME) if agentCfg == nil || agentCfg.Default || agentCfg.ID == "" || routing.NormalizeAgentID(agentCfg.ID) == "main" { return expandHome(defaults.Workspace) } // For named agents without explicit workspace, use default workspace with agent ID suffix id := routing.NormalizeAgentID(agentCfg.ID) return filepath.Join(expandHome(defaults.Workspace), "..", "workspace-"+id) } // resolveAgentModel resolves the primary model for an agent. func resolveAgentModel(agentCfg *config.AgentConfig, defaults *config.AgentDefaults) string { if agentCfg != nil && agentCfg.Model != nil && strings.TrimSpace(agentCfg.Model.Primary) != "" { return strings.TrimSpace(agentCfg.Model.Primary) } return defaults.GetModelName() } // resolveAgentFallbacks resolves the fallback models for an agent. func resolveAgentFallbacks(agentCfg *config.AgentConfig, defaults *config.AgentDefaults) []string { if agentCfg != nil && agentCfg.Model != nil && agentCfg.Model.Fallbacks != nil { return agentCfg.Model.Fallbacks } return defaults.ModelFallbacks } func compilePatterns(patterns []string) []*regexp.Regexp { compiled := make([]*regexp.Regexp, 0, len(patterns)) for _, p := range patterns { re, err := regexp.Compile(p) if err != nil { fmt.Printf("Warning: invalid path pattern %q: %v\n", p, err) continue } compiled = append(compiled, re) } return compiled } func expandHome(path string) string { if path == "" { return path } if path[0] == '~' { home, _ := os.UserHomeDir() if len(path) > 1 && path[1] == '/' { return home + path[1:] } return home } return path }