mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
844a4eefc7
* fix(agent): make exec tool init failure non-fatal * test(agent): add regression test for invalid exec config fallback
358 lines
11 KiB
Go
358 lines
11 KiB
Go
package agent
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"github.com/sipeed/picoclaw/pkg/config"
|
|
"github.com/sipeed/picoclaw/pkg/logger"
|
|
"github.com/sipeed/picoclaw/pkg/media"
|
|
"github.com/sipeed/picoclaw/pkg/memory"
|
|
"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.SessionStore
|
|
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 := buildAllowReadPatterns(cfg)
|
|
allowWritePaths := compilePatterns(cfg.Tools.AllowWritePaths)
|
|
|
|
toolsRegistry := tools.NewToolRegistry()
|
|
|
|
if cfg.Tools.IsToolEnabled("read_file") {
|
|
maxReadFileSize := cfg.Tools.ReadFile.MaxReadFileSize
|
|
toolsRegistry.Register(tools.NewReadFileTool(workspace, readRestrict, maxReadFileSize, 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, allowReadPaths)
|
|
if err != nil {
|
|
logger.ErrorCF("agent", "Failed to initialize exec tool; continuing without exec",
|
|
map[string]any{"error": err.Error()})
|
|
} else {
|
|
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")
|
|
sessions := initSessionStore(sessionsDir)
|
|
|
|
mcpDiscoveryActive := cfg.Tools.MCP.Enabled && cfg.Tools.MCP.Discovery.Enabled
|
|
contextBuilder := NewContextBuilder(workspace).WithToolDiscovery(
|
|
mcpDiscoveryActive && cfg.Tools.MCP.Discovery.UseBM25,
|
|
mcpDiscoveryActive && cfg.Tools.MCP.Discovery.UseRegex,
|
|
)
|
|
|
|
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 {
|
|
logger.WarnCF("agent", "Routing light model not found; routing disabled",
|
|
map[string]any{"light_model": rc.LightModel, "agent_id": 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: sessions,
|
|
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 buildAllowReadPatterns(cfg *config.Config) []*regexp.Regexp {
|
|
var configured []string
|
|
if cfg != nil {
|
|
configured = cfg.Tools.AllowReadPaths
|
|
}
|
|
|
|
compiled := compilePatterns(configured)
|
|
mediaDirPattern := regexp.MustCompile(mediaTempDirPattern())
|
|
for _, pattern := range compiled {
|
|
if pattern.String() == mediaDirPattern.String() {
|
|
return compiled
|
|
}
|
|
}
|
|
|
|
return append(compiled, mediaDirPattern)
|
|
}
|
|
|
|
func mediaTempDirPattern() string {
|
|
sep := regexp.QuoteMeta(string(os.PathSeparator))
|
|
return "^" + regexp.QuoteMeta(filepath.Clean(media.TempDir())) + "(?:" + sep + "|$)"
|
|
}
|
|
|
|
// Close releases resources held by the agent's session store.
|
|
func (a *AgentInstance) Close() error {
|
|
if a.Sessions != nil {
|
|
return a.Sessions.Close()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// initSessionStore creates the session persistence backend.
|
|
// It uses the JSONL store by default and auto-migrates legacy JSON sessions.
|
|
// Falls back to SessionManager if the JSONL store cannot be initialized or
|
|
// if migration fails (which indicates the store cannot write reliably).
|
|
func initSessionStore(dir string) session.SessionStore {
|
|
store, err := memory.NewJSONLStore(dir)
|
|
if err != nil {
|
|
logger.WarnCF("agent", "Memory JSONL store init failed; falling back to json sessions",
|
|
map[string]any{"error": err.Error()})
|
|
return session.NewSessionManager(dir)
|
|
}
|
|
|
|
if n, merr := memory.MigrateFromJSON(context.Background(), dir, store); merr != nil {
|
|
// Migration failure means the store could not write data.
|
|
// Fall back to SessionManager to avoid a split state where
|
|
// some sessions are in JSONL and others remain in JSON.
|
|
logger.WarnCF("agent", "Memory migration failed; falling back to json sessions",
|
|
map[string]any{"error": merr.Error()})
|
|
store.Close()
|
|
return session.NewSessionManager(dir)
|
|
} else if n > 0 {
|
|
logger.InfoCF("agent", "Memory migrated to JSONL", map[string]any{"sessions_migrated": n})
|
|
}
|
|
|
|
return session.NewJSONLBackend(store)
|
|
}
|
|
|
|
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
|
|
}
|