diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 0917df7d2..4ca02cd90 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -164,10 +164,7 @@ func registerSharedTools( if cfg.Tools.IsToolEnabled("web") { searchTool, err := tools.NewWebSearchTool(tools.WebSearchToolOptions{ - BraveAPIKeys: config.MergeAPIKeys( - cfg.Tools.Web.Brave.APIKey(), - cfg.Tools.Web.Brave.APIKeys(), - ), + BraveAPIKeys: config.MergeAPIKeys(cfg.Tools.Web.Brave.APIKey(), cfg.Tools.Web.Brave.APIKeys()), BraveMaxResults: cfg.Tools.Web.Brave.MaxResults, BraveEnabled: cfg.Tools.Web.Brave.Enabled, TavilyAPIKeys: config.MergeAPIKeys( @@ -200,11 +197,7 @@ func registerSharedTools( Proxy: cfg.Tools.Web.Proxy, }) if err != nil { - logger.ErrorCF( - "agent", - "Failed to create web search tool", - map[string]any{"error": err.Error()}, - ) + logger.ErrorCF("agent", "Failed to create web search tool", map[string]any{"error": err.Error()}) } else if searchTool != nil { agent.Tools.Register(searchTool) } @@ -217,11 +210,7 @@ func registerSharedTools( cfg.Tools.Web.FetchLimitBytes, cfg.Tools.Web.PrivateHostWhitelist) if err != nil { - logger.ErrorCF( - "agent", - "Failed to create web fetch tool", - map[string]any{"error": err.Error()}, - ) + logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()}) } else { agent.Tools.Register(fetchTool) } @@ -489,12 +478,7 @@ func (al *AgentLoop) Run(ctx context.Context) error { "queue_depth": al.pendingSteeringCountForScope(target.SessionKey), }) - continued, continueErr := al.Continue( - ctx, - target.SessionKey, - target.Channel, - target.ChatID, - ) + continued, continueErr := al.Continue(ctx, target.SessionKey, target.Channel, target.ChatID) if continueErr != nil { logger.WarnCF("agent", "Failed to continue queued steering", map[string]any{ @@ -522,22 +506,14 @@ func (al *AgentLoop) Run(ctx context.Context) error { "queue_depth": al.pendingSteeringCountForScope(target.SessionKey), }) - continued, continueErr := al.Continue( - ctx, - target.SessionKey, - target.Channel, - target.ChatID, - ) + continued, continueErr := al.Continue(ctx, target.SessionKey, target.Channel, target.ChatID) if continueErr != nil { - logger.WarnCF( - "agent", - "Failed to continue queued steering after shutdown drain", + logger.WarnCF("agent", "Failed to continue queued steering after shutdown drain", map[string]any{ "channel": target.Channel, "chat_id": target.ChatID, "error": continueErr.Error(), - }, - ) + }) return } if continued == "" { @@ -596,15 +572,11 @@ func (al *AgentLoop) drainBusToSteering(ctx context.Context, activeScope, active msgScope, _, scopeOK := al.resolveSteeringTarget(msg) if !scopeOK || msgScope != activeScope { if err := al.requeueInboundMessage(msg); err != nil { - logger.WarnCF( - "agent", - "Failed to requeue non-steering inbound message", - map[string]any{ - "error": err.Error(), - "channel": msg.Channel, - "sender_id": msg.SenderID, - }, - ) + logger.WarnCF("agent", "Failed to requeue non-steering inbound message", map[string]any{ + "error": err.Error(), + "channel": msg.Channel, + "sender_id": msg.SenderID, + }) } continue } @@ -638,10 +610,7 @@ func (al *AgentLoop) Stop() { al.running.Store(false) } -func (al *AgentLoop) publishResponseIfNeeded( - ctx context.Context, - channel, chatID, response string, -) { +func (al *AgentLoop) publishResponseIfNeeded(ctx context.Context, channel, chatID, response string) { if response == "" { return } @@ -1091,10 +1060,7 @@ var audioAnnotationRe = regexp.MustCompile(`\[(voice|audio)(?::[^\]]*)?\]`) // transcribeAudioInMessage resolves audio media refs, transcribes them, and // replaces audio annotations in msg.Content with the transcribed text. // Returns the (possibly modified) message and true if audio was transcribed. -func (al *AgentLoop) transcribeAudioInMessage( - ctx context.Context, - msg bus.InboundMessage, -) (bus.InboundMessage, bool) { +func (al *AgentLoop) transcribeAudioInMessage(ctx context.Context, msg bus.InboundMessage) (bus.InboundMessage, bool) { if al.transcriber == nil || al.mediaStore == nil || len(msg.Media) == 0 { return msg, false } @@ -1104,11 +1070,7 @@ func (al *AgentLoop) transcribeAudioInMessage( for _, ref := range msg.Media { path, meta, err := al.mediaStore.ResolveWithMeta(ref) if err != nil { - logger.WarnCF( - "voice", - "Failed to resolve media ref", - map[string]any{"ref": ref, "error": err}, - ) + logger.WarnCF("voice", "Failed to resolve media ref", map[string]any{"ref": ref, "error": err}) continue } if !utils.IsAudioFile(meta.Filename, meta.ContentType) { @@ -1186,11 +1148,7 @@ func (al *AgentLoop) sendTranscriptionFeedback( ReplyToMessageID: messageID, }) if err != nil { - logger.WarnCF( - "voice", - "Failed to send transcription feedback", - map[string]any{"error": err.Error()}, - ) + logger.WarnCF("voice", "Failed to send transcription feedback", map[string]any{"error": err.Error()}) } } @@ -1390,9 +1348,7 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) return al.runAgentLoop(ctx, agent, opts) } -func (al *AgentLoop) resolveMessageRoute( - msg bus.InboundMessage, -) (routing.ResolvedRoute, *AgentInstance, error) { +func (al *AgentLoop) resolveMessageRoute(msg bus.InboundMessage) (routing.ResolvedRoute, *AgentInstance, error) { registry := al.GetRegistry() route := registry.ResolveRoute(routing.RouteInput{ Channel: msg.Channel, @@ -1408,10 +1364,7 @@ func (al *AgentLoop) resolveMessageRoute( agent = registry.GetDefaultAgent() } if agent == nil { - return routing.ResolvedRoute{}, nil, fmt.Errorf( - "no agent available for route (agent_id=%s)", - route.AgentID, - ) + return routing.ResolvedRoute{}, nil, fmt.Errorf("no agent available for route (agent_id=%s)", route.AgentID) } return route, agent, nil @@ -2697,15 +2650,12 @@ turnLoop: } if steerMsgs := al.dequeueSteeringMessagesForScope(ts.sessionKey); len(steerMsgs) > 0 { - logger.InfoCF( - "agent", - "Steering arrived after turn completion; continuing turn before finalizing", + logger.InfoCF("agent", "Steering arrived after turn completion; continuing turn before finalizing", map[string]any{ "agent_id": ts.agent.ID, "steering_count": len(steerMsgs), "session_key": ts.sessionKey, - }, - ) + }) pendingMessages = append(pendingMessages, steerMsgs...) finalContent = "" goto turnLoop @@ -2807,8 +2757,8 @@ func (al *AgentLoop) selectCandidates( if targetModelName == agent.Model { logger.DebugCF("agent", "Model routing: primary model selected", map[string]any{ - "agent_id": agent.ID, - "score": score, + "agent_id": agent.ID, + "score": score, }) return agent.Candidates, resolvedCandidateModel(agent.Candidates, agent.Model) } @@ -2823,24 +2773,17 @@ func (al *AgentLoop) selectCandidates( return tierCands, resolvedCandidateModel(tierCands, targetModelName) } - logger.WarnCF( - "agent", - "Model routing: tier model candidates not found, falling back to primary", + logger.WarnCF("agent", "Model routing: tier model candidates not found, falling back to primary", map[string]any{ "agent_id": agent.ID, "tier_model": targetModelName, "score": score, - }, - ) + }) return agent.Candidates, resolvedCandidateModel(agent.Candidates, agent.Model) } // maybeSummarize triggers summarization if the session history exceeds thresholds. -func (al *AgentLoop) maybeSummarize( - agent *AgentInstance, - sessionKey string, - turnScope turnEventScope, -) { +func (al *AgentLoop) maybeSummarize(agent *AgentInstance, sessionKey string, turnScope turnEventScope) { newHistory := agent.Sessions.GetHistory(sessionKey) tokenEstimate := al.estimateTokens(newHistory) threshold := agent.ContextWindow * agent.SummarizeTokenPercent / 100 @@ -2874,10 +2817,7 @@ type compressionResult struct { // prompt is built dynamically by BuildMessages and is NOT stored here. // The compression note is recorded in the session summary so that // BuildMessages can include it in the next system prompt. -func (al *AgentLoop) forceCompression( - agent *AgentInstance, - sessionKey string, -) (compressionResult, bool) { +func (al *AgentLoop) forceCompression(agent *AgentInstance, sessionKey string) (compressionResult, bool) { history := agent.Sessions.GetHistory(sessionKey) if len(history) <= 2 { return compressionResult{}, false @@ -3030,11 +2970,7 @@ func formatToolsForLog(toolDefs []providers.ToolDefinition) string { } // summarizeSession summarizes the conversation history for a session. -func (al *AgentLoop) summarizeSession( - agent *AgentInstance, - sessionKey string, - turnScope turnEventScope, -) { +func (al *AgentLoop) summarizeSession(agent *AgentInstance, sessionKey string, turnScope turnEventScope) { ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) defer cancel() @@ -3386,10 +3322,7 @@ func (al *AgentLoop) applyExplicitSkillCommand( skillName, ok := agent.ContextBuilder.ResolveSkillName(arg) if !ok { - return true, true, fmt.Sprintf( - "Unknown skill: %s\nUse /list skills to see installed skills.", - arg, - ) + return true, true, fmt.Sprintf("Unknown skill: %s\nUse /list skills to see installed skills.", arg) } if len(parts) < 3 { @@ -3416,10 +3349,7 @@ func (al *AgentLoop) applyExplicitSkillCommand( return true, false, "" } -func (al *AgentLoop) buildCommandsRuntime( - agent *AgentInstance, - opts *processOptions, -) *commands.Runtime { +func (al *AgentLoop) buildCommandsRuntime(agent *AgentInstance, opts *processOptions) *commands.Runtime { registry := al.GetRegistry() cfg := al.GetConfig() rt := &commands.Runtime{ @@ -3463,10 +3393,7 @@ func (al *AgentLoop) buildCommandsRuntime( rt.ListSkillNames = agent.ContextBuilder.ListSkillNames } rt.GetModelInfo = func() (string, string) { - return agent.Model, resolvedCandidateProvider( - agent.Candidates, - cfg.Agents.Defaults.Provider, - ) + return agent.Model, resolvedCandidateProvider(agent.Candidates, cfg.Agents.Defaults.Provider) } rt.SwitchModel = func(value string) (string, error) { value = strings.TrimSpace(value) @@ -3480,12 +3407,7 @@ func (al *AgentLoop) buildCommandsRuntime( return "", fmt.Errorf("failed to initialize model %q: %w", value, err) } - nextCandidates := resolveModelCandidates( - cfg, - cfg.Agents.Defaults.Provider, - modelCfg.Model, - agent.Fallbacks, - ) + nextCandidates := resolveModelCandidates(cfg, cfg.Agents.Defaults.Provider, modelCfg.Model, agent.Fallbacks) if len(nextCandidates) == 0 { return "", fmt.Errorf("model %q did not resolve to any provider candidates", value) } diff --git a/pkg/config/config.go b/pkg/config/config.go index 502039317..fe32b3df8 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -856,9 +856,9 @@ type ModelConfig struct { Region string `json:"region,omitempty"` // Region (e.g. for Google Vertex AI) // Optional optimizations - RPM int `json:"rpm,omitempty"` // Requests per minute limit - MaxTokensField string `json:"max_tokens_field,omitempty"` // Field name for max tokens (e.g., "max_completion_tokens") - RequestTimeout int `json:"request_timeout,omitempty"` + RPM int `json:"rpm,omitempty"` // Requests per minute limit + MaxTokensField string `json:"max_tokens_field,omitempty"` // Field name for max tokens (e.g., "max_completion_tokens") + RequestTimeout int `json:"request_timeout,omitempty"` ThinkingLevel string `json:"thinking_level,omitempty"` // Extended thinking: off|low|medium|high|xhigh|adaptive ExtraBody map[string]any `json:"extra_body,omitempty"` // Additional fields to inject into request body ExtraHeaders map[string]string `json:"extra_headers,omitempty"` // Additional headers to inject into request @@ -1034,10 +1034,10 @@ type SearXNGConfig struct { } type GLMSearchConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_GLM_ENABLED"` + Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_GLM_ENABLED"` apiKey string secDirty bool - BaseURL string `json:"base_url" env:"PICOCLAW_TOOLS_WEB_GLM_BASE_URL"` + BaseURL string `json:"base_url" env:"PICOCLAW_TOOLS_WEB_GLM_BASE_URL"` // SearchEngine specifies the search backend: "search_std" (default), // "search_pro", "search_pro_sogou", or "search_pro_quark". SearchEngine string `json:"search_engine" env:"PICOCLAW_TOOLS_WEB_GLM_SEARCH_ENGINE"` @@ -1074,7 +1074,7 @@ func (c *BaiduSearchConfig) SetAPIKey(key string) { } type WebToolsConfig struct { - ToolConfig ` envPrefix:"PICOCLAW_TOOLS_WEB_"` + ToolConfig ` envPrefix:"PICOCLAW_TOOLS_WEB_"` Brave BraveConfig ` json:"brave"` Tavily TavilyConfig ` json:"tavily"` DuckDuckGo DuckDuckGoConfig ` json:"duckduckgo"` @@ -1087,13 +1087,13 @@ type WebToolsConfig struct { // the client-side web_search tool is hidden to avoid duplicate search surfaces, // and the provider's built-in search is used instead. Falls back to client-side // search when the provider does not support native search. - PreferNative bool ` json:"prefer_native" env:"PICOCLAW_TOOLS_WEB_PREFER_NATIVE"` + PreferNative bool `json:"prefer_native" env:"PICOCLAW_TOOLS_WEB_PREFER_NATIVE"` // Proxy is an optional proxy URL for web tools (http/https/socks5/socks5h). // For authenticated proxies, prefer HTTP_PROXY/HTTPS_PROXY env vars instead of embedding credentials in config. - Proxy string ` json:"proxy,omitempty" env:"PICOCLAW_TOOLS_WEB_PROXY"` - FetchLimitBytes int64 ` json:"fetch_limit_bytes,omitempty" env:"PICOCLAW_TOOLS_WEB_FETCH_LIMIT_BYTES"` - Format string ` json:"format,omitempty" env:"PICOCLAW_TOOLS_WEB_FORMAT"` - PrivateHostWhitelist FlexibleStringSlice ` json:"private_host_whitelist,omitempty" env:"PICOCLAW_TOOLS_WEB_PRIVATE_HOST_WHITELIST"` + Proxy string `json:"proxy,omitempty" env:"PICOCLAW_TOOLS_WEB_PROXY"` + FetchLimitBytes int64 `json:"fetch_limit_bytes,omitempty" env:"PICOCLAW_TOOLS_WEB_FETCH_LIMIT_BYTES"` + Format string `json:"format,omitempty" env:"PICOCLAW_TOOLS_WEB_FORMAT"` + PrivateHostWhitelist FlexibleStringSlice `json:"private_host_whitelist,omitempty" env:"PICOCLAW_TOOLS_WEB_PRIVATE_HOST_WHITELIST"` } type CronToolsConfig struct { @@ -1131,8 +1131,8 @@ type ReadFileToolConfig struct { } type ToolsConfig struct { - AllowReadPaths []string `json:"allow_read_paths" env:"PICOCLAW_TOOLS_ALLOW_READ_PATHS"` - AllowWritePaths []string `json:"allow_write_paths" env:"PICOCLAW_TOOLS_ALLOW_WRITE_PATHS"` + AllowReadPaths []string `json:"allow_read_paths" env:"PICOCLAW_TOOLS_ALLOW_READ_PATHS"` + AllowWritePaths []string `json:"allow_write_paths" env:"PICOCLAW_TOOLS_ALLOW_WRITE_PATHS"` // FilterSensitiveData controls whether to filter sensitive values (API keys, // tokens, secrets) from tool results before sending to the LLM. // Default: true (enabled) @@ -1140,29 +1140,29 @@ type ToolsConfig struct { // FilterMinLength is the minimum content length required for filtering. // Content shorter than this will be returned unchanged for performance. // Default: 8 - FilterMinLength int `json:"filter_min_length" env:"PICOCLAW_TOOLS_FILTER_MIN_LENGTH"` + FilterMinLength int `json:"filter_min_length" env:"PICOCLAW_TOOLS_FILTER_MIN_LENGTH"` Web WebToolsConfig `json:"web"` Cron CronToolsConfig `json:"cron"` Exec ExecConfig `json:"exec"` Skills SkillsToolsConfig `json:"skills"` MediaCleanup MediaCleanupConfig `json:"media_cleanup"` MCP MCPConfig `json:"mcp"` - AppendFile ToolConfig `json:"append_file" envPrefix:"PICOCLAW_TOOLS_APPEND_FILE_"` - EditFile ToolConfig `json:"edit_file" envPrefix:"PICOCLAW_TOOLS_EDIT_FILE_"` - FindSkills ToolConfig `json:"find_skills" envPrefix:"PICOCLAW_TOOLS_FIND_SKILLS_"` - I2C ToolConfig `json:"i2c" envPrefix:"PICOCLAW_TOOLS_I2C_"` - InstallSkill ToolConfig `json:"install_skill" envPrefix:"PICOCLAW_TOOLS_INSTALL_SKILL_"` - UpdateSkill ToolConfig `json:"update_skill" envPrefix:"PICOCLAW_TOOLS_UPDATE_SKILL_"` - ListDir ToolConfig `json:"list_dir" envPrefix:"PICOCLAW_TOOLS_LIST_DIR_"` - Message ToolConfig `json:"message" envPrefix:"PICOCLAW_TOOLS_MESSAGE_"` - ReadFile ReadFileToolConfig `json:"read_file" envPrefix:"PICOCLAW_TOOLS_READ_FILE_"` - SendFile ToolConfig `json:"send_file" envPrefix:"PICOCLAW_TOOLS_SEND_FILE_"` - Spawn ToolConfig `json:"spawn" envPrefix:"PICOCLAW_TOOLS_SPAWN_"` - SpawnStatus ToolConfig `json:"spawn_status" envPrefix:"PICOCLAW_TOOLS_SPAWN_STATUS_"` - SPI ToolConfig `json:"spi" envPrefix:"PICOCLAW_TOOLS_SPI_"` - Subagent ToolConfig `json:"subagent" envPrefix:"PICOCLAW_TOOLS_SUBAGENT_"` - WebFetch ToolConfig `json:"web_fetch" envPrefix:"PICOCLAW_TOOLS_WEB_FETCH_"` - WriteFile ToolConfig `json:"write_file" envPrefix:"PICOCLAW_TOOLS_WRITE_FILE_"` + AppendFile ToolConfig `json:"append_file" envPrefix:"PICOCLAW_TOOLS_APPEND_FILE_"` + EditFile ToolConfig `json:"edit_file" envPrefix:"PICOCLAW_TOOLS_EDIT_FILE_"` + FindSkills ToolConfig `json:"find_skills" envPrefix:"PICOCLAW_TOOLS_FIND_SKILLS_"` + I2C ToolConfig `json:"i2c" envPrefix:"PICOCLAW_TOOLS_I2C_"` + InstallSkill ToolConfig `json:"install_skill" envPrefix:"PICOCLAW_TOOLS_INSTALL_SKILL_"` + UpdateSkill ToolConfig `json:"update_skill" envPrefix:"PICOCLAW_TOOLS_UPDATE_SKILL_"` + ListDir ToolConfig `json:"list_dir" envPrefix:"PICOCLAW_TOOLS_LIST_DIR_"` + Message ToolConfig `json:"message" envPrefix:"PICOCLAW_TOOLS_MESSAGE_"` + ReadFile ReadFileToolConfig `json:"read_file" envPrefix:"PICOCLAW_TOOLS_READ_FILE_"` + SendFile ToolConfig `json:"send_file" envPrefix:"PICOCLAW_TOOLS_SEND_FILE_"` + Spawn ToolConfig `json:"spawn" envPrefix:"PICOCLAW_TOOLS_SPAWN_"` + SpawnStatus ToolConfig `json:"spawn_status" envPrefix:"PICOCLAW_TOOLS_SPAWN_STATUS_"` + SPI ToolConfig `json:"spi" envPrefix:"PICOCLAW_TOOLS_SPI_"` + Subagent ToolConfig `json:"subagent" envPrefix:"PICOCLAW_TOOLS_SUBAGENT_"` + WebFetch ToolConfig `json:"web_fetch" envPrefix:"PICOCLAW_TOOLS_WEB_FETCH_"` + WriteFile ToolConfig `json:"write_file" envPrefix:"PICOCLAW_TOOLS_WRITE_FILE_"` } // IsFilterSensitiveDataEnabled returns true if sensitive data filtering is enabled @@ -1254,10 +1254,10 @@ type MCPServerConfig struct { // MCPConfig defines configuration for all MCP servers type MCPConfig struct { - ToolConfig ` envPrefix:"PICOCLAW_TOOLS_MCP_"` + ToolConfig ` envPrefix:"PICOCLAW_TOOLS_MCP_"` Discovery ToolDiscoveryConfig ` json:"discovery"` // Servers is a map of server name to server configuration - Servers map[string]MCPServerConfig ` json:"servers,omitempty"` + Servers map[string]MCPServerConfig `json:"servers,omitempty"` } func LoadConfig(path string) (*Config, error) { @@ -1265,10 +1265,7 @@ func LoadConfig(path string) (*Config, error) { data, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { - logger.WarnF( - "config file not found, using default config", - map[string]any{"path": path}, - ) + logger.WarnF("config file not found, using default config", map[string]any{"path": path}) return DefaultConfig(), nil } logger.Errorf("failed to read config file: %v", err) @@ -1291,10 +1288,7 @@ func LoadConfig(path string) (*Config, error) { var cfg *Config switch versionInfo.Version { case 0: - logger.InfoF( - "config migrate start", - map[string]any{"from": versionInfo.Version, "to": CurrentVersion}, - ) + logger.InfoF("config migrate start", map[string]any{"from": versionInfo.Version, "to": CurrentVersion}) // Legacy config (no version field) v, e := loadConfigV0(data) if e != nil { @@ -1302,16 +1296,10 @@ func LoadConfig(path string) (*Config, error) { } cfg, e = v.Migrate() if e != nil { - logger.ErrorF( - "config migrate fail", - map[string]any{"from": versionInfo.Version, "to": CurrentVersion}, - ) + logger.ErrorF("config migrate fail", map[string]any{"from": versionInfo.Version, "to": CurrentVersion}) return nil, e } - logger.InfoF( - "config migrate success", - map[string]any{"from": versionInfo.Version, "to": CurrentVersion}, - ) + logger.InfoF("config migrate success", map[string]any{"from": versionInfo.Version, "to": CurrentVersion}) err = makeBackup(path) if err != nil { return nil, err @@ -1319,19 +1307,13 @@ func LoadConfig(path string) (*Config, error) { // Load existing security config and merge with migrated one to prevent data loss existingSec, secErr := loadSecurityConfig(securityPath(path)) if secErr != nil { - logger.WarnF( - "failed to load existing security config during migration", - map[string]any{"error": secErr}, - ) + logger.WarnF("failed to load existing security config during migration", map[string]any{"error": secErr}) } if existingSec != nil && cfg.security != nil { cfg.security = mergeSecurityConfig(existingSec, cfg.security) // Re-apply the merged security config to update all channels and models if err = applySecurityConfig(cfg, cfg.security); err != nil { - logger.WarnF( - "failed to re-apply merged security config during migration", - map[string]any{"error": err}, - ) + logger.WarnF("failed to re-apply merged security config during migration", map[string]any{"error": err}) } } defer func(cfg *Config) { @@ -1363,11 +1345,9 @@ func LoadConfig(path string) (*Config, error) { for _, m := range cfg.ModelList { for _, k := range m.apiKeys { if k != "" && !strings.HasPrefix(k, "enc://") && !strings.HasPrefix(k, "file://") { - fmt.Fprintf( - os.Stderr, + fmt.Fprintf(os.Stderr, "picoclaw: warning: model %q has a plaintext api_key; call SaveConfig to encrypt it\n", - m.ModelName, - ) + m.ModelName) break // Only warn once per model } } diff --git a/pkg/providers/vertex/provider.go b/pkg/providers/vertex/provider.go index e69dc85b6..59a3060b2 100644 --- a/pkg/providers/vertex/provider.go +++ b/pkg/providers/vertex/provider.go @@ -28,7 +28,9 @@ type ( FunctionCall = protocoltypes.FunctionCall ) -const () +const ( + defaultRequestTimeout = common.DefaultRequestTimeout +) // Provider implements the LLM provider interface for Google Vertex AI. // It uses the standard Vertex AI REST API for Gemini models. @@ -112,6 +114,7 @@ func (p *Provider) buildURL(model string, action string) string { return baseURL } + // parseMediaData converts base64 media data into the Vertex AI inlineData format. // It tries to detect mime type from the data URI scheme if present. func parseMediaData(mediaData string) map[string]any { @@ -135,11 +138,7 @@ func parseMediaData(mediaData string) map[string]any { } // buildRequestBody formats the standard messages and tools into the Vertex AI (Gemini) REST payload format. -func (p *Provider) buildRequestBody( - messages []Message, - tools []ToolDefinition, - options map[string]any, -) (map[string]any, error) { +func (p *Provider) buildRequestBody(messages []Message, tools []ToolDefinition, options map[string]any) (map[string]any, error) { req := make(map[string]any) var contents []map[string]any @@ -235,7 +234,7 @@ func (p *Provider) buildRequestBody( parts = append(parts, map[string]any{ "functionResponse": map[string]any{ - "name": msg.ToolCallID, + "name": msg.ToolCallID, "response": responseObj, }, }) @@ -292,6 +291,7 @@ func (p *Provider) buildRequestBody( return req, nil } + func (p *Provider) Chat( ctx context.Context, messages []Message, @@ -440,11 +440,7 @@ func (p *Provider) ChatStream( if part.FunctionCall != nil { argsJSON, _ := json.Marshal(part.FunctionCall.Args) toolCall := ToolCall{ - ID: fmt.Sprintf( - "call_%s_%d", - part.FunctionCall.Name, - time.Now().UnixNano(), - ), + ID: fmt.Sprintf("call_%s_%d", part.FunctionCall.Name, time.Now().UnixNano()), Name: part.FunctionCall.Name, Arguments: part.FunctionCall.Args, Function: &FunctionCall{ diff --git a/pkg/providers/vertex/provider_test.go b/pkg/providers/vertex/provider_test.go index 6fac3f3df..65555083b 100644 --- a/pkg/providers/vertex/provider_test.go +++ b/pkg/providers/vertex/provider_test.go @@ -8,10 +8,10 @@ import ( "net/url" "testing" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" "github.com/sipeed/picoclaw/pkg/providers/protocoltypes" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestProvider_buildURL(t *testing.T) { @@ -37,16 +37,16 @@ func TestProvider_buildURL(t *testing.T) { expected: "https://us-central1-aiplatform.googleapis.com/v1/projects/my-project/locations/us-central1/publishers/google/models/gemini-1.5-flash:generateContent", }, { - name: "Override with base URL without method", - apiBase: "http://localhost:8080/v1/models", - model: "gemini-1.0-pro", - expected: "http://localhost:8080/v1/models/gemini-1.0-pro:generateContent?key=key", + name: "Override with base URL without method", + apiBase: "http://localhost:8080/v1/models", + model: "gemini-1.0-pro", + expected: "http://localhost:8080/v1/models/gemini-1.0-pro:generateContent?key=key", }, { - name: "Override with full endpoint URL", - apiBase: "https://my-custom-proxy.com/my-endpoint:generateContent", - model: "gemini-1.5-pro", - expected: "https://my-custom-proxy.com/my-endpoint:generateContent?key=key", + name: "Override with full endpoint URL", + apiBase: "https://my-custom-proxy.com/my-endpoint:generateContent", + model: "gemini-1.5-pro", + expected: "https://my-custom-proxy.com/my-endpoint:generateContent?key=key", }, } @@ -59,25 +59,16 @@ func TestProvider_buildURL(t *testing.T) { } } + func TestProvider_buildRequestBody(t *testing.T) { p := NewProvider("key", "", "", "proj", "us-central1") messages := []protocoltypes.Message{ {Role: "system", Content: "You are a helpful assistant."}, {Role: "user", Content: "Hello!", Media: []string{"data:image/png;base64,iVBORw0KGgo"}}, - { - Role: "assistant", - ToolCalls: []protocoltypes.ToolCall{ - {Name: "get_weather", Arguments: map[string]any{"location": "Tokyo"}}, - }, - }, + {Role: "assistant", ToolCalls: []protocoltypes.ToolCall{{Name: "get_weather", Arguments: map[string]any{"location": "Tokyo"}}}}, {Role: "tool", ToolCallID: "get_weather", Content: "Sunny"}, - { - Role: "assistant", - ToolCalls: []protocoltypes.ToolCall{ - {Name: "get_time", Arguments: map[string]any{"location": "Tokyo"}}, - }, - }, + {Role: "assistant", ToolCalls: []protocoltypes.ToolCall{{Name: "get_time", Arguments: map[string]any{"location": "Tokyo"}}}}, {Role: "tool", ToolCallID: "get_time", Content: "12:00 PM"}, } @@ -137,6 +128,7 @@ func TestProvider_buildRequestBody(t *testing.T) { assert.Equal(t, "model", contents[3]["role"]) } + func TestProvider_Chat(t *testing.T) { // Create a mock server ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -202,11 +194,7 @@ func TestProvider_ChatStream(t *testing.T) { w.Header().Set("Content-Type", "text/event-stream") // Write mock chunks w.Write([]byte(`data: {"candidates":[{"content":{"parts":[{"text":"Hello"}]}}]}` + "\n\n")) - w.Write( - []byte( - `data: {"candidates":[{"content":{"parts":[{"text":", world!"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":5,"totalTokenCount":15}}` + "\n\n", - ), - ) + w.Write([]byte(`data: {"candidates":[{"content":{"parts":[{"text":", world!"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":5,"totalTokenCount":15}}` + "\n\n")) })) defer ts.Close() diff --git a/pkg/routing/router.go b/pkg/routing/router.go index 5a43ecf94..a5d489233 100644 --- a/pkg/routing/router.go +++ b/pkg/routing/router.go @@ -7,6 +7,7 @@ import ( // defaultThreshold is used when the config threshold is zero or negative. // At 0.35 a message needs at least one strong signal (code block, long text, // or an attachment) before the heavy model is chosen. +const defaultThreshold = 0.35 // RoutingTier defines a single tier for model routing. type RoutingTier struct { diff --git a/pkg/routing/router_test.go b/pkg/routing/router_test.go index 05b67f27e..59d719608 100644 --- a/pkg/routing/router_test.go +++ b/pkg/routing/router_test.go @@ -82,19 +82,13 @@ func TestExtractFeatures_RecentToolCalls(t *testing.T) { // History longer than lookbackWindow — only last lookbackWindow entries count. history := make([]providers.Message, 10) // Put 2 tool calls at positions 8 and 9 (within the last 6) - history[8] = providers.Message{ - Role: "assistant", - ToolCalls: []providers.ToolCall{{Name: "exec"}}, - } + history[8] = providers.Message{Role: "assistant", ToolCalls: []providers.ToolCall{{Name: "exec"}}} history[9] = providers.Message{ Role: "assistant", ToolCalls: []providers.ToolCall{{Name: "read_file"}, {Name: "write_file"}}, } // Position 3 is outside the lookback window and must NOT be counted - history[3] = providers.Message{ - Role: "assistant", - ToolCalls: []providers.ToolCall{{Name: "old_tool"}}, - } + history[3] = providers.Message{Role: "assistant", ToolCalls: []providers.ToolCall{{Name: "old_tool"}}} f := ExtractFeatures("test", history) // 1 (position 8) + 2 (position 9) = 3 @@ -247,15 +241,9 @@ func TestRuleClassifier_ScoreDoesNotExceedOne(t *testing.T) { // ── Router ─────────────────────────────────────────────────────────────────── + func TestRouter_SelectModel_SimpleMessageUsesLight(t *testing.T) { - r := New( - RouterConfig{ - Tiers: []RoutingTier{ - {Model: "gemini-flash", Threshold: 0.0}, - {Model: "claude-sonnet-4-6", Threshold: 0.35}, - }, - }, - ) + r := New(RouterConfig{Tiers: []RoutingTier{{Model: "gemini-flash", Threshold: 0.0}, {Model: "claude-sonnet-4-6", Threshold: 0.35}}}) msg := "hi" model, usedLight, _ := r.SelectModel(msg, nil, "claude-sonnet-4-6") if !usedLight { @@ -267,14 +255,7 @@ func TestRouter_SelectModel_SimpleMessageUsesLight(t *testing.T) { } func TestRouter_SelectModel_CodeBlockUsesPrimary(t *testing.T) { - r := New( - RouterConfig{ - Tiers: []RoutingTier{ - {Model: "gemini-flash", Threshold: 0.0}, - {Model: "claude-sonnet-4-6", Threshold: 0.35}, - }, - }, - ) + r := New(RouterConfig{Tiers: []RoutingTier{{Model: "gemini-flash", Threshold: 0.0}, {Model: "claude-sonnet-4-6", Threshold: 0.35}}}) msg := "```go\nfmt.Println(\"hello\")\n```" model, usedLight, _ := r.SelectModel(msg, nil, "claude-sonnet-4-6") if usedLight { @@ -286,14 +267,7 @@ func TestRouter_SelectModel_CodeBlockUsesPrimary(t *testing.T) { } func TestRouter_SelectModel_AttachmentUsesPrimary(t *testing.T) { - r := New( - RouterConfig{ - Tiers: []RoutingTier{ - {Model: "gemini-flash", Threshold: 0.0}, - {Model: "claude-sonnet-4-6", Threshold: 0.35}, - }, - }, - ) + r := New(RouterConfig{Tiers: []RoutingTier{{Model: "gemini-flash", Threshold: 0.0}, {Model: "claude-sonnet-4-6", Threshold: 0.35}}}) msg := "can you analyze this? data:image/png;base64,abc123" model, usedLight, _ := r.SelectModel(msg, nil, "claude-sonnet-4-6") if usedLight { @@ -305,14 +279,7 @@ func TestRouter_SelectModel_AttachmentUsesPrimary(t *testing.T) { } func TestRouter_SelectModel_LongMessageUsesPrimary(t *testing.T) { - r := New( - RouterConfig{ - Tiers: []RoutingTier{ - {Model: "gemini-flash", Threshold: 0.0}, - {Model: "claude-sonnet-4-6", Threshold: 0.35}, - }, - }, - ) + r := New(RouterConfig{Tiers: []RoutingTier{{Model: "gemini-flash", Threshold: 0.0}, {Model: "claude-sonnet-4-6", Threshold: 0.35}}}) // >200 token estimate: 210 * 3 = 630 chars msg := strings.Repeat("word ", 210) model, usedLight, _ := r.SelectModel(msg, nil, "claude-sonnet-4-6") @@ -327,19 +294,9 @@ func TestRouter_SelectModel_LongMessageUsesPrimary(t *testing.T) { func TestRouter_SelectModel_DeepToolChainUsesLight(t *testing.T) { // Tool calls alone (0.25) don't cross the 0.35 threshold — acceptable behavior. // Routing is conservative: only promote to heavy when the signal is unambiguous. - r := New( - RouterConfig{ - Tiers: []RoutingTier{ - {Model: "gemini-flash", Threshold: 0.0}, - {Model: "claude-sonnet-4-6", Threshold: 0.35}, - }, - }, - ) + r := New(RouterConfig{Tiers: []RoutingTier{{Model: "gemini-flash", Threshold: 0.0}, {Model: "claude-sonnet-4-6", Threshold: 0.35}}}) history := []providers.Message{ - { - Role: "assistant", - ToolCalls: []providers.ToolCall{{Name: "read_file"}, {Name: "write_file"}}, - }, + {Role: "assistant", ToolCalls: []providers.ToolCall{{Name: "read_file"}, {Name: "write_file"}}}, {Role: "assistant", ToolCalls: []providers.ToolCall{{Name: "exec"}, {Name: "search"}}}, } msg := "ok" @@ -351,14 +308,7 @@ func TestRouter_SelectModel_DeepToolChainUsesLight(t *testing.T) { func TestRouter_SelectModel_ToolChainPlusMediumUsesHeavy(t *testing.T) { // Tool calls (0.25) + medium message (0.15) = 0.40 >= 0.35 → heavy - r := New( - RouterConfig{ - Tiers: []RoutingTier{ - {Model: "gemini-flash", Threshold: 0.0}, - {Model: "claude-sonnet-4-6", Threshold: 0.35}, - }, - }, - ) + r := New(RouterConfig{Tiers: []RoutingTier{{Model: "gemini-flash", Threshold: 0.0}, {Model: "claude-sonnet-4-6", Threshold: 0.35}}}) history := []providers.Message{ {Role: "assistant", ToolCalls: []providers.ToolCall{ {Name: "a"}, {Name: "b"}, {Name: "c"}, {Name: "d"}, @@ -374,14 +324,7 @@ func TestRouter_SelectModel_ToolChainPlusMediumUsesHeavy(t *testing.T) { func TestRouter_SelectModel_CustomThreshold(t *testing.T) { // Very low threshold: even a short message triggers heavy model - r := New( - RouterConfig{ - Tiers: []RoutingTier{ - {Model: "gemini-flash", Threshold: 0.0}, - {Model: "claude-sonnet-4-6", Threshold: 0.05}, - }, - }, - ) + r := New(RouterConfig{Tiers: []RoutingTier{{Model: "gemini-flash", Threshold: 0.0}, {Model: "claude-sonnet-4-6", Threshold: 0.05}}}) msg := strings.Repeat("word ", 55) // medium message → 0.15 >= 0.05 _, usedLight, _ := r.SelectModel(msg, nil, "claude-sonnet-4-6") if usedLight { @@ -391,14 +334,7 @@ func TestRouter_SelectModel_CustomThreshold(t *testing.T) { func TestRouter_SelectModel_HighThreshold(t *testing.T) { // Very high threshold: even code blocks route to light - r := New( - RouterConfig{ - Tiers: []RoutingTier{ - {Model: "gemini-flash", Threshold: 0.0}, - {Model: "claude-sonnet-4-6", Threshold: 0.99}, - }, - }, - ) + r := New(RouterConfig{Tiers: []RoutingTier{{Model: "gemini-flash", Threshold: 0.0}, {Model: "claude-sonnet-4-6", Threshold: 0.99}}}) msg := "```go\nfmt.Println()\n```" _, usedLight, _ := r.SelectModel(msg, nil, "claude-sonnet-4-6") if !usedLight { @@ -407,14 +343,7 @@ func TestRouter_SelectModel_HighThreshold(t *testing.T) { } func TestRouter_Tiers(t *testing.T) { - r := New( - RouterConfig{ - Tiers: []RoutingTier{ - {Model: "my-fast-model", Threshold: 0.0}, - {Model: "heavy-model", Threshold: 0.35}, - }, - }, - ) + r := New(RouterConfig{Tiers: []RoutingTier{{Model: "my-fast-model", Threshold: 0.0}, {Model: "heavy-model", Threshold: 0.35}}}) if r.Tiers()[0].Model != "my-fast-model" { t.Errorf("LightModel: got %q, want %q", "my-fast-model", "my-fast-model") } @@ -428,12 +357,7 @@ func (f *fixedScoreClassifier) Score(_ Features) float64 { return f.score } func TestRouter_CustomClassifier_LowScore_SelectsLight(t *testing.T) { r := newWithClassifier( - RouterConfig{ - Tiers: []RoutingTier{ - {Model: "light", Threshold: 0.0}, - {Model: "heavy", Threshold: 0.5}, - }, - }, + RouterConfig{Tiers: []RoutingTier{{Model: "light", Threshold: 0.0}, {Model: "heavy", Threshold: 0.5}}}, &fixedScoreClassifier{score: 0.2}, ) _, usedLight, _ := r.SelectModel("anything", nil, "heavy") @@ -444,12 +368,7 @@ func TestRouter_CustomClassifier_LowScore_SelectsLight(t *testing.T) { func TestRouter_CustomClassifier_HighScore_SelectsPrimary(t *testing.T) { r := newWithClassifier( - RouterConfig{ - Tiers: []RoutingTier{ - {Model: "light", Threshold: 0.0}, - {Model: "heavy", Threshold: 0.5}, - }, - }, + RouterConfig{Tiers: []RoutingTier{{Model: "light", Threshold: 0.0}, {Model: "heavy", Threshold: 0.5}}}, &fixedScoreClassifier{score: 0.8}, ) _, usedLight, _ := r.SelectModel("anything", nil, "heavy") @@ -461,12 +380,7 @@ func TestRouter_CustomClassifier_HighScore_SelectsPrimary(t *testing.T) { func TestRouter_CustomClassifier_ExactThreshold_SelectsPrimary(t *testing.T) { // score == threshold → primary (uses >= comparison) r := newWithClassifier( - RouterConfig{ - Tiers: []RoutingTier{ - {Model: "light", Threshold: 0.0}, - {Model: "heavy", Threshold: 0.5}, - }, - }, + RouterConfig{Tiers: []RoutingTier{{Model: "light", Threshold: 0.0}, {Model: "heavy", Threshold: 0.5}}}, &fixedScoreClassifier{score: 0.5}, ) _, usedLight, _ := r.SelectModel("anything", nil, "heavy") @@ -477,12 +391,7 @@ func TestRouter_CustomClassifier_ExactThreshold_SelectsPrimary(t *testing.T) { func TestRouter_SelectModel_ReturnsScore(t *testing.T) { r := newWithClassifier( - RouterConfig{ - Tiers: []RoutingTier{ - {Model: "light", Threshold: 0.0}, - {Model: "heavy", Threshold: 0.5}, - }, - }, + RouterConfig{Tiers: []RoutingTier{{Model: "light", Threshold: 0.0}, {Model: "heavy", Threshold: 0.5}}}, &fixedScoreClassifier{score: 0.42}, ) _, _, score := r.SelectModel("anything", nil, "heavy") diff --git a/pkg/tools/skills_update.go b/pkg/tools/skills_update.go index aab52fba6..f544a5fae 100644 --- a/pkg/tools/skills_update.go +++ b/pkg/tools/skills_update.go @@ -69,8 +69,7 @@ func (t *UpdateSkillTool) Execute(ctx context.Context, args map[string]any) *Too // Prepare the content to append timestamp := time.Now().Format("2006-01-02 15:04:05") - contentToAppend := fmt.Sprintf( - "\n\n## Learned Skill: %s\n\n**Analysis**: %s\n\n**Skills Improved**: %s\n\n%s\n", + contentToAppend := fmt.Sprintf("\n\n## Learned Skill: %s\n\n**Analysis**: %s\n\n**Skills Improved**: %s\n\n%s\n", timestamp, analysis, skillsToImprove, @@ -93,19 +92,11 @@ func (t *UpdateSkillTool) Execute(ctx context.Context, args map[string]any) *Too return ErrorResult(fmt.Sprintf("failed to append to SKILL.md: %v", err)) } - output := fmt.Sprintf( - "Successfully learned and updated SKILL.md.\n\nAnalysis: %s\nSkills Improved: %s\n", - analysis, - skillsToImprove, - ) + output := fmt.Sprintf("Successfully learned and updated SKILL.md.\n\nAnalysis: %s\nSkills Improved: %s\n", analysis, skillsToImprove) // The response is passed back to the LLM. // We also populate the ForUser field to notify the user. res := SilentResult(output) - res.ForUser = fmt.Sprintf( - "I have analyzed our conversation and improved my skills.\n\n**My Analysis**:\n%s\n\n**Skills I've Improved/Added**:\n%s\n\nI have saved these learnings to `SKILL.md`.", - analysis, - skillsToImprove, - ) + res.ForUser = fmt.Sprintf("I have analyzed our conversation and improved my skills.\n\n**My Analysis**:\n%s\n\n**Skills I've Improved/Added**:\n%s\n\nI have saved these learnings to `SKILL.md`.", analysis, skillsToImprove) return res } diff --git a/pkg/tools/skills_update_test.go b/pkg/tools/skills_update_test.go index 562e59427..004898404 100644 --- a/pkg/tools/skills_update_test.go +++ b/pkg/tools/skills_update_test.go @@ -65,5 +65,5 @@ func TestUpdateSkillTool_Execute(t *testing.T) { contentStr2 := string(content2) assert.Contains(t, contentStr2, "I was slow to find the file.") // Old content still there - assert.Contains(t, contentStr2, "Another analysis.") // New content added + assert.Contains(t, contentStr2, "Another analysis.") // New content added } diff --git a/web/backend/api/models.go b/web/backend/api/models.go index 34be999ef..6cf9c03c7 100644 --- a/web/backend/api/models.go +++ b/web/backend/api/models.go @@ -32,11 +32,11 @@ type modelResponse struct { Proxy string `json:"proxy,omitempty"` AuthMethod string `json:"auth_method,omitempty"` // Advanced fields - ConnectMode string `json:"connect_mode,omitempty"` - Workspace string `json:"workspace,omitempty"` - RPM int `json:"rpm,omitempty"` - MaxTokensField string `json:"max_tokens_field,omitempty"` - RequestTimeout int `json:"request_timeout,omitempty"` + ConnectMode string `json:"connect_mode,omitempty"` + Workspace string `json:"workspace,omitempty"` + RPM int `json:"rpm,omitempty"` + MaxTokensField string `json:"max_tokens_field,omitempty"` + RequestTimeout int `json:"request_timeout,omitempty"` ThinkingLevel string `json:"thinking_level,omitempty"` ExtraBody map[string]any `json:"extra_body,omitempty"` ExtraHeaders map[string]string `json:"extra_headers,omitempty"` @@ -195,11 +195,7 @@ func (h *Handler) handleUpdateModel(w http.ResponseWriter, r *http.Request) { } if idx < 0 || idx >= len(cfg.ModelList) { - http.Error( - w, - fmt.Sprintf("Index %d out of range (0-%d)", idx, len(cfg.ModelList)-1), - http.StatusNotFound, - ) + http.Error(w, fmt.Sprintf("Index %d out of range (0-%d)", idx, len(cfg.ModelList)-1), http.StatusNotFound) return } @@ -252,11 +248,7 @@ func (h *Handler) handleDeleteModel(w http.ResponseWriter, r *http.Request) { } if idx < 0 || idx >= len(cfg.ModelList) { - http.Error( - w, - fmt.Sprintf("Index %d out of range (0-%d)", idx, len(cfg.ModelList)-1), - http.StatusNotFound, - ) + http.Error(w, fmt.Sprintf("Index %d out of range (0-%d)", idx, len(cfg.ModelList)-1), http.StatusNotFound) return } @@ -319,19 +311,11 @@ func (h *Handler) handleSetDefaultModel(w http.ResponseWriter, r *http.Request) } } if !found { - http.Error( - w, - fmt.Sprintf("Model %q not found in model_list", req.ModelName), - http.StatusNotFound, - ) + http.Error(w, fmt.Sprintf("Model %q not found in model_list", req.ModelName), http.StatusNotFound) return } if isVirtual { - http.Error( - w, - fmt.Sprintf("Cannot set virtual model %q as default", req.ModelName), - http.StatusBadRequest, - ) + http.Error(w, fmt.Sprintf("Cannot set virtual model %q as default", req.ModelName), http.StatusBadRequest) return }