mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
26f623ed32
* feat(session): add SessionStore interface and JSONL backend adapter Extract a SessionStore interface from the methods the agent loop uses (AddMessage, GetHistory, SetSummary, TruncateHistory, Save, etc.). Both SessionManager and the new JSONLBackend satisfy this interface, allowing the persistence layer to be swapped transparently. JSONLBackend wraps memory.Store and maps its error-returning API to the fire-and-forget contract that the agent loop expects — write errors are logged, reads return empty defaults on failure. Save() triggers compaction to reclaim space after logical truncation. Part of #1169 * test(session): add JSONLBackend integration tests 8 tests covering the full SessionStore contract through the JSONL backend: message roundtrip, tool calls, summary, truncation with compaction, history replacement, empty sessions, session isolation, and the complete summarization flow (SetSummary → TruncateHistory → Save). Includes compile-time interface satisfaction checks for both SessionManager and JSONLBackend. Part of #1169 * feat(agent): wire JSONL session store into agent loop Replace the concrete *SessionManager field with the SessionStore interface and initialize the JSONL backend by default. Legacy .json session files are auto-migrated on first startup. Falls back to SessionManager if the JSONL store cannot be initialized. The agent loop code (loop.go) requires zero changes — all method calls work identically through the interface. Closes #1169 * fix(session): propagate compact error from Save Save() was swallowing the error returned by Compact and always returning nil. Callers checking Save's return value would never see a compaction failure. Return the error directly so the agent loop can log or handle it as needed. * feat(session): add Close to SessionStore interface Add Close() error to SessionStore so callers can release resources through the interface. JSONLBackend already had Close; this adds a no-op implementation to SessionManager for compatibility. * fix(session): close session stores on shutdown and harden migration - Add Close() to AgentInstance, AgentRegistry, and AgentLoop so JSONL file handles are released during gateway shutdown and CLI exit. - Fall back to SessionManager when migration fails, preventing a split state where some sessions live in JSONL and others remain in JSON. - Add defer agentLoop.Close() in the CLI agent command path. - Document SessionStore interface methods (fire-and-forget contract).
331 lines
10 KiB
Go
331 lines
10 KiB
Go
package agent
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"github.com/sipeed/picoclaw/pkg/config"
|
|
"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 := compilePatterns(cfg.Tools.AllowReadPaths)
|
|
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)
|
|
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")
|
|
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 {
|
|
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: 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
|
|
}
|
|
|
|
// 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 {
|
|
log.Printf("memory: init store: %v; using json sessions", err)
|
|
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.
|
|
log.Printf("memory: migration failed: %v; falling back to json sessions", merr)
|
|
store.Close()
|
|
return session.NewSessionManager(dir)
|
|
} else if n > 0 {
|
|
log.Printf("memory: migrated %d session(s) to jsonl", 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
|
|
}
|