style: fix formatting issues and remove unused constants

This commit addresses the GitHub Actions CI failures caused by the
linters:
- `golines`: Fixed line length wrapping issues in multiple files
  (`pkg/routing/router_test.go`, `pkg/providers/vertex/provider.go`,
  etc.)
- `gci`: Reordered and grouped imports appropriately in modified files
- `unused`: Removed `defaultRequestTimeout` and `defaultThreshold`
  constants which were no longer being used anywhere in the code.

Co-authored-by: TanLuong <28281768+TanLuong@users.noreply.github.com>
This commit is contained in:
google-labs-jules[bot] 2026-03-25 23:19:34 +00:00
parent e41b2722d6
commit 5d86ea43dc
9 changed files with 354 additions and 125 deletions

View file

@ -164,7 +164,10 @@ func registerSharedTools(
if cfg.Tools.IsToolEnabled("web") { if cfg.Tools.IsToolEnabled("web") {
searchTool, err := tools.NewWebSearchTool(tools.WebSearchToolOptions{ 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, BraveMaxResults: cfg.Tools.Web.Brave.MaxResults,
BraveEnabled: cfg.Tools.Web.Brave.Enabled, BraveEnabled: cfg.Tools.Web.Brave.Enabled,
TavilyAPIKeys: config.MergeAPIKeys( TavilyAPIKeys: config.MergeAPIKeys(
@ -197,7 +200,11 @@ func registerSharedTools(
Proxy: cfg.Tools.Web.Proxy, Proxy: cfg.Tools.Web.Proxy,
}) })
if err != nil { 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 { } else if searchTool != nil {
agent.Tools.Register(searchTool) agent.Tools.Register(searchTool)
} }
@ -210,7 +217,11 @@ func registerSharedTools(
cfg.Tools.Web.FetchLimitBytes, cfg.Tools.Web.FetchLimitBytes,
cfg.Tools.Web.PrivateHostWhitelist) cfg.Tools.Web.PrivateHostWhitelist)
if err != nil { 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 { } else {
agent.Tools.Register(fetchTool) agent.Tools.Register(fetchTool)
} }
@ -478,7 +489,12 @@ func (al *AgentLoop) Run(ctx context.Context) error {
"queue_depth": al.pendingSteeringCountForScope(target.SessionKey), "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 { if continueErr != nil {
logger.WarnCF("agent", "Failed to continue queued steering", logger.WarnCF("agent", "Failed to continue queued steering",
map[string]any{ map[string]any{
@ -506,14 +522,22 @@ func (al *AgentLoop) Run(ctx context.Context) error {
"queue_depth": al.pendingSteeringCountForScope(target.SessionKey), "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 { 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{ map[string]any{
"channel": target.Channel, "channel": target.Channel,
"chat_id": target.ChatID, "chat_id": target.ChatID,
"error": continueErr.Error(), "error": continueErr.Error(),
}) },
)
return return
} }
if continued == "" { if continued == "" {
@ -572,11 +596,15 @@ func (al *AgentLoop) drainBusToSteering(ctx context.Context, activeScope, active
msgScope, _, scopeOK := al.resolveSteeringTarget(msg) msgScope, _, scopeOK := al.resolveSteeringTarget(msg)
if !scopeOK || msgScope != activeScope { if !scopeOK || msgScope != activeScope {
if err := al.requeueInboundMessage(msg); err != nil { if err := al.requeueInboundMessage(msg); err != nil {
logger.WarnCF("agent", "Failed to requeue non-steering inbound message", map[string]any{ logger.WarnCF(
"error": err.Error(), "agent",
"channel": msg.Channel, "Failed to requeue non-steering inbound message",
"sender_id": msg.SenderID, map[string]any{
}) "error": err.Error(),
"channel": msg.Channel,
"sender_id": msg.SenderID,
},
)
} }
continue continue
} }
@ -610,7 +638,10 @@ func (al *AgentLoop) Stop() {
al.running.Store(false) 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 == "" { if response == "" {
return return
} }
@ -1060,7 +1091,10 @@ var audioAnnotationRe = regexp.MustCompile(`\[(voice|audio)(?::[^\]]*)?\]`)
// transcribeAudioInMessage resolves audio media refs, transcribes them, and // transcribeAudioInMessage resolves audio media refs, transcribes them, and
// replaces audio annotations in msg.Content with the transcribed text. // replaces audio annotations in msg.Content with the transcribed text.
// Returns the (possibly modified) message and true if audio was transcribed. // 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 { if al.transcriber == nil || al.mediaStore == nil || len(msg.Media) == 0 {
return msg, false return msg, false
} }
@ -1070,7 +1104,11 @@ func (al *AgentLoop) transcribeAudioInMessage(ctx context.Context, msg bus.Inbou
for _, ref := range msg.Media { for _, ref := range msg.Media {
path, meta, err := al.mediaStore.ResolveWithMeta(ref) path, meta, err := al.mediaStore.ResolveWithMeta(ref)
if err != nil { 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 continue
} }
if !utils.IsAudioFile(meta.Filename, meta.ContentType) { if !utils.IsAudioFile(meta.Filename, meta.ContentType) {
@ -1148,7 +1186,11 @@ func (al *AgentLoop) sendTranscriptionFeedback(
ReplyToMessageID: messageID, ReplyToMessageID: messageID,
}) })
if err != nil { 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()},
)
} }
} }
@ -1348,7 +1390,9 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
return al.runAgentLoop(ctx, agent, opts) 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() registry := al.GetRegistry()
route := registry.ResolveRoute(routing.RouteInput{ route := registry.ResolveRoute(routing.RouteInput{
Channel: msg.Channel, Channel: msg.Channel,
@ -1364,7 +1408,10 @@ func (al *AgentLoop) resolveMessageRoute(msg bus.InboundMessage) (routing.Resolv
agent = registry.GetDefaultAgent() agent = registry.GetDefaultAgent()
} }
if agent == nil { 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 return route, agent, nil
@ -2650,12 +2697,15 @@ turnLoop:
} }
if steerMsgs := al.dequeueSteeringMessagesForScope(ts.sessionKey); len(steerMsgs) > 0 { 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{ map[string]any{
"agent_id": ts.agent.ID, "agent_id": ts.agent.ID,
"steering_count": len(steerMsgs), "steering_count": len(steerMsgs),
"session_key": ts.sessionKey, "session_key": ts.sessionKey,
}) },
)
pendingMessages = append(pendingMessages, steerMsgs...) pendingMessages = append(pendingMessages, steerMsgs...)
finalContent = "" finalContent = ""
goto turnLoop goto turnLoop
@ -2757,8 +2807,8 @@ func (al *AgentLoop) selectCandidates(
if targetModelName == agent.Model { if targetModelName == agent.Model {
logger.DebugCF("agent", "Model routing: primary model selected", logger.DebugCF("agent", "Model routing: primary model selected",
map[string]any{ map[string]any{
"agent_id": agent.ID, "agent_id": agent.ID,
"score": score, "score": score,
}) })
return agent.Candidates, resolvedCandidateModel(agent.Candidates, agent.Model) return agent.Candidates, resolvedCandidateModel(agent.Candidates, agent.Model)
} }
@ -2773,17 +2823,24 @@ func (al *AgentLoop) selectCandidates(
return tierCands, resolvedCandidateModel(tierCands, targetModelName) 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{ map[string]any{
"agent_id": agent.ID, "agent_id": agent.ID,
"tier_model": targetModelName, "tier_model": targetModelName,
"score": score, "score": score,
}) },
)
return agent.Candidates, resolvedCandidateModel(agent.Candidates, agent.Model) return agent.Candidates, resolvedCandidateModel(agent.Candidates, agent.Model)
} }
// maybeSummarize triggers summarization if the session history exceeds thresholds. // 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) newHistory := agent.Sessions.GetHistory(sessionKey)
tokenEstimate := al.estimateTokens(newHistory) tokenEstimate := al.estimateTokens(newHistory)
threshold := agent.ContextWindow * agent.SummarizeTokenPercent / 100 threshold := agent.ContextWindow * agent.SummarizeTokenPercent / 100
@ -2817,7 +2874,10 @@ type compressionResult struct {
// prompt is built dynamically by BuildMessages and is NOT stored here. // prompt is built dynamically by BuildMessages and is NOT stored here.
// The compression note is recorded in the session summary so that // The compression note is recorded in the session summary so that
// BuildMessages can include it in the next system prompt. // 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) history := agent.Sessions.GetHistory(sessionKey)
if len(history) <= 2 { if len(history) <= 2 {
return compressionResult{}, false return compressionResult{}, false
@ -2970,7 +3030,11 @@ func formatToolsForLog(toolDefs []providers.ToolDefinition) string {
} }
// summarizeSession summarizes the conversation history for a session. // 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) ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel() defer cancel()
@ -3322,7 +3386,10 @@ func (al *AgentLoop) applyExplicitSkillCommand(
skillName, ok := agent.ContextBuilder.ResolveSkillName(arg) skillName, ok := agent.ContextBuilder.ResolveSkillName(arg)
if !ok { 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 { if len(parts) < 3 {
@ -3349,7 +3416,10 @@ func (al *AgentLoop) applyExplicitSkillCommand(
return true, false, "" 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() registry := al.GetRegistry()
cfg := al.GetConfig() cfg := al.GetConfig()
rt := &commands.Runtime{ rt := &commands.Runtime{
@ -3393,7 +3463,10 @@ func (al *AgentLoop) buildCommandsRuntime(agent *AgentInstance, opts *processOpt
rt.ListSkillNames = agent.ContextBuilder.ListSkillNames rt.ListSkillNames = agent.ContextBuilder.ListSkillNames
} }
rt.GetModelInfo = func() (string, string) { 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) { rt.SwitchModel = func(value string) (string, error) {
value = strings.TrimSpace(value) value = strings.TrimSpace(value)
@ -3407,7 +3480,12 @@ func (al *AgentLoop) buildCommandsRuntime(agent *AgentInstance, opts *processOpt
return "", fmt.Errorf("failed to initialize model %q: %w", value, err) 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 { if len(nextCandidates) == 0 {
return "", fmt.Errorf("model %q did not resolve to any provider candidates", value) return "", fmt.Errorf("model %q did not resolve to any provider candidates", value)
} }

View file

@ -856,9 +856,9 @@ type ModelConfig struct {
Region string `json:"region,omitempty"` // Region (e.g. for Google Vertex AI) Region string `json:"region,omitempty"` // Region (e.g. for Google Vertex AI)
// Optional optimizations // Optional optimizations
RPM int `json:"rpm,omitempty"` // Requests per minute limit 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") MaxTokensField string `json:"max_tokens_field,omitempty"` // Field name for max tokens (e.g., "max_completion_tokens")
RequestTimeout int `json:"request_timeout,omitempty"` RequestTimeout int `json:"request_timeout,omitempty"`
ThinkingLevel string `json:"thinking_level,omitempty"` // Extended thinking: off|low|medium|high|xhigh|adaptive 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 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 ExtraHeaders map[string]string `json:"extra_headers,omitempty"` // Additional headers to inject into request
@ -1034,10 +1034,10 @@ type SearXNGConfig struct {
} }
type GLMSearchConfig 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 apiKey string
secDirty bool 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), // SearchEngine specifies the search backend: "search_std" (default),
// "search_pro", "search_pro_sogou", or "search_pro_quark". // "search_pro", "search_pro_sogou", or "search_pro_quark".
SearchEngine string `json:"search_engine" env:"PICOCLAW_TOOLS_WEB_GLM_SEARCH_ENGINE"` 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 { type WebToolsConfig struct {
ToolConfig ` envPrefix:"PICOCLAW_TOOLS_WEB_"` ToolConfig ` envPrefix:"PICOCLAW_TOOLS_WEB_"`
Brave BraveConfig ` json:"brave"` Brave BraveConfig ` json:"brave"`
Tavily TavilyConfig ` json:"tavily"` Tavily TavilyConfig ` json:"tavily"`
DuckDuckGo DuckDuckGoConfig ` json:"duckduckgo"` DuckDuckGo DuckDuckGoConfig ` json:"duckduckgo"`
@ -1087,13 +1087,13 @@ type WebToolsConfig struct {
// the client-side web_search tool is hidden to avoid duplicate search surfaces, // 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 // and the provider's built-in search is used instead. Falls back to client-side
// search when the provider does not support native search. // 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). // 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. // 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"` Proxy string ` json:"proxy,omitempty" env:"PICOCLAW_TOOLS_WEB_PROXY"`
FetchLimitBytes int64 `json:"fetch_limit_bytes,omitempty" env:"PICOCLAW_TOOLS_WEB_FETCH_LIMIT_BYTES"` FetchLimitBytes int64 ` json:"fetch_limit_bytes,omitempty" env:"PICOCLAW_TOOLS_WEB_FETCH_LIMIT_BYTES"`
Format string `json:"format,omitempty" env:"PICOCLAW_TOOLS_WEB_FORMAT"` Format string ` json:"format,omitempty" env:"PICOCLAW_TOOLS_WEB_FORMAT"`
PrivateHostWhitelist FlexibleStringSlice `json:"private_host_whitelist,omitempty" env:"PICOCLAW_TOOLS_WEB_PRIVATE_HOST_WHITELIST"` PrivateHostWhitelist FlexibleStringSlice ` json:"private_host_whitelist,omitempty" env:"PICOCLAW_TOOLS_WEB_PRIVATE_HOST_WHITELIST"`
} }
type CronToolsConfig struct { type CronToolsConfig struct {
@ -1131,8 +1131,8 @@ type ReadFileToolConfig struct {
} }
type ToolsConfig struct { type ToolsConfig struct {
AllowReadPaths []string `json:"allow_read_paths" env:"PICOCLAW_TOOLS_ALLOW_READ_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"` AllowWritePaths []string `json:"allow_write_paths" env:"PICOCLAW_TOOLS_ALLOW_WRITE_PATHS"`
// FilterSensitiveData controls whether to filter sensitive values (API keys, // FilterSensitiveData controls whether to filter sensitive values (API keys,
// tokens, secrets) from tool results before sending to the LLM. // tokens, secrets) from tool results before sending to the LLM.
// Default: true (enabled) // Default: true (enabled)
@ -1140,29 +1140,29 @@ type ToolsConfig struct {
// FilterMinLength is the minimum content length required for filtering. // FilterMinLength is the minimum content length required for filtering.
// Content shorter than this will be returned unchanged for performance. // Content shorter than this will be returned unchanged for performance.
// Default: 8 // 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"` Web WebToolsConfig `json:"web"`
Cron CronToolsConfig `json:"cron"` Cron CronToolsConfig `json:"cron"`
Exec ExecConfig `json:"exec"` Exec ExecConfig `json:"exec"`
Skills SkillsToolsConfig `json:"skills"` Skills SkillsToolsConfig `json:"skills"`
MediaCleanup MediaCleanupConfig `json:"media_cleanup"` MediaCleanup MediaCleanupConfig `json:"media_cleanup"`
MCP MCPConfig `json:"mcp"` MCP MCPConfig `json:"mcp"`
AppendFile ToolConfig `json:"append_file" envPrefix:"PICOCLAW_TOOLS_APPEND_FILE_"` AppendFile ToolConfig `json:"append_file" envPrefix:"PICOCLAW_TOOLS_APPEND_FILE_"`
EditFile ToolConfig `json:"edit_file" envPrefix:"PICOCLAW_TOOLS_EDIT_FILE_"` EditFile ToolConfig `json:"edit_file" envPrefix:"PICOCLAW_TOOLS_EDIT_FILE_"`
FindSkills ToolConfig `json:"find_skills" envPrefix:"PICOCLAW_TOOLS_FIND_SKILLS_"` FindSkills ToolConfig `json:"find_skills" envPrefix:"PICOCLAW_TOOLS_FIND_SKILLS_"`
I2C ToolConfig `json:"i2c" envPrefix:"PICOCLAW_TOOLS_I2C_"` I2C ToolConfig `json:"i2c" envPrefix:"PICOCLAW_TOOLS_I2C_"`
InstallSkill ToolConfig `json:"install_skill" envPrefix:"PICOCLAW_TOOLS_INSTALL_SKILL_"` InstallSkill ToolConfig `json:"install_skill" envPrefix:"PICOCLAW_TOOLS_INSTALL_SKILL_"`
UpdateSkill ToolConfig `json:"update_skill" envPrefix:"PICOCLAW_TOOLS_UPDATE_SKILL_"` UpdateSkill ToolConfig `json:"update_skill" envPrefix:"PICOCLAW_TOOLS_UPDATE_SKILL_"`
ListDir ToolConfig `json:"list_dir" envPrefix:"PICOCLAW_TOOLS_LIST_DIR_"` ListDir ToolConfig `json:"list_dir" envPrefix:"PICOCLAW_TOOLS_LIST_DIR_"`
Message ToolConfig `json:"message" envPrefix:"PICOCLAW_TOOLS_MESSAGE_"` Message ToolConfig `json:"message" envPrefix:"PICOCLAW_TOOLS_MESSAGE_"`
ReadFile ReadFileToolConfig `json:"read_file" envPrefix:"PICOCLAW_TOOLS_READ_FILE_"` ReadFile ReadFileToolConfig `json:"read_file" envPrefix:"PICOCLAW_TOOLS_READ_FILE_"`
SendFile ToolConfig `json:"send_file" envPrefix:"PICOCLAW_TOOLS_SEND_FILE_"` SendFile ToolConfig `json:"send_file" envPrefix:"PICOCLAW_TOOLS_SEND_FILE_"`
Spawn ToolConfig `json:"spawn" envPrefix:"PICOCLAW_TOOLS_SPAWN_"` Spawn ToolConfig `json:"spawn" envPrefix:"PICOCLAW_TOOLS_SPAWN_"`
SpawnStatus ToolConfig `json:"spawn_status" envPrefix:"PICOCLAW_TOOLS_SPAWN_STATUS_"` SpawnStatus ToolConfig `json:"spawn_status" envPrefix:"PICOCLAW_TOOLS_SPAWN_STATUS_"`
SPI ToolConfig `json:"spi" envPrefix:"PICOCLAW_TOOLS_SPI_"` SPI ToolConfig `json:"spi" envPrefix:"PICOCLAW_TOOLS_SPI_"`
Subagent ToolConfig `json:"subagent" envPrefix:"PICOCLAW_TOOLS_SUBAGENT_"` Subagent ToolConfig `json:"subagent" envPrefix:"PICOCLAW_TOOLS_SUBAGENT_"`
WebFetch ToolConfig `json:"web_fetch" envPrefix:"PICOCLAW_TOOLS_WEB_FETCH_"` WebFetch ToolConfig `json:"web_fetch" envPrefix:"PICOCLAW_TOOLS_WEB_FETCH_"`
WriteFile ToolConfig `json:"write_file" envPrefix:"PICOCLAW_TOOLS_WRITE_FILE_"` WriteFile ToolConfig `json:"write_file" envPrefix:"PICOCLAW_TOOLS_WRITE_FILE_"`
} }
// IsFilterSensitiveDataEnabled returns true if sensitive data filtering is enabled // IsFilterSensitiveDataEnabled returns true if sensitive data filtering is enabled
@ -1254,10 +1254,10 @@ type MCPServerConfig struct {
// MCPConfig defines configuration for all MCP servers // MCPConfig defines configuration for all MCP servers
type MCPConfig struct { type MCPConfig struct {
ToolConfig ` envPrefix:"PICOCLAW_TOOLS_MCP_"` ToolConfig ` envPrefix:"PICOCLAW_TOOLS_MCP_"`
Discovery ToolDiscoveryConfig ` json:"discovery"` Discovery ToolDiscoveryConfig ` json:"discovery"`
// Servers is a map of server name to server configuration // 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) { func LoadConfig(path string) (*Config, error) {
@ -1265,7 +1265,10 @@ func LoadConfig(path string) (*Config, error) {
data, err := os.ReadFile(path) data, err := os.ReadFile(path)
if err != nil { if err != nil {
if os.IsNotExist(err) { 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 return DefaultConfig(), nil
} }
logger.Errorf("failed to read config file: %v", err) logger.Errorf("failed to read config file: %v", err)
@ -1288,7 +1291,10 @@ func LoadConfig(path string) (*Config, error) {
var cfg *Config var cfg *Config
switch versionInfo.Version { switch versionInfo.Version {
case 0: 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) // Legacy config (no version field)
v, e := loadConfigV0(data) v, e := loadConfigV0(data)
if e != nil { if e != nil {
@ -1296,10 +1302,16 @@ func LoadConfig(path string) (*Config, error) {
} }
cfg, e = v.Migrate() cfg, e = v.Migrate()
if e != nil { 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 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) err = makeBackup(path)
if err != nil { if err != nil {
return nil, err return nil, err
@ -1307,13 +1319,19 @@ func LoadConfig(path string) (*Config, error) {
// Load existing security config and merge with migrated one to prevent data loss // Load existing security config and merge with migrated one to prevent data loss
existingSec, secErr := loadSecurityConfig(securityPath(path)) existingSec, secErr := loadSecurityConfig(securityPath(path))
if secErr != nil { 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 { if existingSec != nil && cfg.security != nil {
cfg.security = mergeSecurityConfig(existingSec, cfg.security) cfg.security = mergeSecurityConfig(existingSec, cfg.security)
// Re-apply the merged security config to update all channels and models // Re-apply the merged security config to update all channels and models
if err = applySecurityConfig(cfg, cfg.security); err != nil { 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) { defer func(cfg *Config) {
@ -1345,9 +1363,11 @@ func LoadConfig(path string) (*Config, error) {
for _, m := range cfg.ModelList { for _, m := range cfg.ModelList {
for _, k := range m.apiKeys { for _, k := range m.apiKeys {
if k != "" && !strings.HasPrefix(k, "enc://") && !strings.HasPrefix(k, "file://") { 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", "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 break // Only warn once per model
} }
} }

View file

@ -28,9 +28,7 @@ type (
FunctionCall = protocoltypes.FunctionCall FunctionCall = protocoltypes.FunctionCall
) )
const ( const ()
defaultRequestTimeout = common.DefaultRequestTimeout
)
// Provider implements the LLM provider interface for Google Vertex AI. // Provider implements the LLM provider interface for Google Vertex AI.
// It uses the standard Vertex AI REST API for Gemini models. // It uses the standard Vertex AI REST API for Gemini models.
@ -114,7 +112,6 @@ func (p *Provider) buildURL(model string, action string) string {
return baseURL return baseURL
} }
// parseMediaData converts base64 media data into the Vertex AI inlineData format. // parseMediaData converts base64 media data into the Vertex AI inlineData format.
// It tries to detect mime type from the data URI scheme if present. // It tries to detect mime type from the data URI scheme if present.
func parseMediaData(mediaData string) map[string]any { func parseMediaData(mediaData string) map[string]any {
@ -138,7 +135,11 @@ func parseMediaData(mediaData string) map[string]any {
} }
// buildRequestBody formats the standard messages and tools into the Vertex AI (Gemini) REST payload format. // 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) req := make(map[string]any)
var contents []map[string]any var contents []map[string]any
@ -234,7 +235,7 @@ func (p *Provider) buildRequestBody(messages []Message, tools []ToolDefinition,
parts = append(parts, map[string]any{ parts = append(parts, map[string]any{
"functionResponse": map[string]any{ "functionResponse": map[string]any{
"name": msg.ToolCallID, "name": msg.ToolCallID,
"response": responseObj, "response": responseObj,
}, },
}) })
@ -291,7 +292,6 @@ func (p *Provider) buildRequestBody(messages []Message, tools []ToolDefinition,
return req, nil return req, nil
} }
func (p *Provider) Chat( func (p *Provider) Chat(
ctx context.Context, ctx context.Context,
messages []Message, messages []Message,
@ -440,7 +440,11 @@ func (p *Provider) ChatStream(
if part.FunctionCall != nil { if part.FunctionCall != nil {
argsJSON, _ := json.Marshal(part.FunctionCall.Args) argsJSON, _ := json.Marshal(part.FunctionCall.Args)
toolCall := ToolCall{ 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, Name: part.FunctionCall.Name,
Arguments: part.FunctionCall.Args, Arguments: part.FunctionCall.Args,
Function: &FunctionCall{ Function: &FunctionCall{

View file

@ -8,10 +8,10 @@ import (
"net/url" "net/url"
"testing" "testing"
"github.com/sipeed/picoclaw/pkg/providers/protocoltypes"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"github.com/sipeed/picoclaw/pkg/providers/protocoltypes"
) )
func TestProvider_buildURL(t *testing.T) { 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", 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", name: "Override with base URL without method",
apiBase: "http://localhost:8080/v1/models", apiBase: "http://localhost:8080/v1/models",
model: "gemini-1.0-pro", model: "gemini-1.0-pro",
expected: "http://localhost:8080/v1/models/gemini-1.0-pro:generateContent?key=key", expected: "http://localhost:8080/v1/models/gemini-1.0-pro:generateContent?key=key",
}, },
{ {
name: "Override with full endpoint URL", name: "Override with full endpoint URL",
apiBase: "https://my-custom-proxy.com/my-endpoint:generateContent", apiBase: "https://my-custom-proxy.com/my-endpoint:generateContent",
model: "gemini-1.5-pro", model: "gemini-1.5-pro",
expected: "https://my-custom-proxy.com/my-endpoint:generateContent?key=key", expected: "https://my-custom-proxy.com/my-endpoint:generateContent?key=key",
}, },
} }
@ -59,16 +59,25 @@ func TestProvider_buildURL(t *testing.T) {
} }
} }
func TestProvider_buildRequestBody(t *testing.T) { func TestProvider_buildRequestBody(t *testing.T) {
p := NewProvider("key", "", "", "proj", "us-central1") p := NewProvider("key", "", "", "proj", "us-central1")
messages := []protocoltypes.Message{ messages := []protocoltypes.Message{
{Role: "system", Content: "You are a helpful assistant."}, {Role: "system", Content: "You are a helpful assistant."},
{Role: "user", Content: "Hello!", Media: []string{"data:image/png;base64,iVBORw0KGgo"}}, {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: "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"}, {Role: "tool", ToolCallID: "get_time", Content: "12:00 PM"},
} }
@ -128,7 +137,6 @@ func TestProvider_buildRequestBody(t *testing.T) {
assert.Equal(t, "model", contents[3]["role"]) assert.Equal(t, "model", contents[3]["role"])
} }
func TestProvider_Chat(t *testing.T) { func TestProvider_Chat(t *testing.T) {
// Create a mock server // Create a mock server
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@ -194,7 +202,11 @@ func TestProvider_ChatStream(t *testing.T) {
w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Content-Type", "text/event-stream")
// Write mock chunks // Write mock chunks
w.Write([]byte(`data: {"candidates":[{"content":{"parts":[{"text":"Hello"}]}}]}` + "\n\n")) 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() defer ts.Close()

View file

@ -7,7 +7,6 @@ import (
// defaultThreshold is used when the config threshold is zero or negative. // 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, // At 0.35 a message needs at least one strong signal (code block, long text,
// or an attachment) before the heavy model is chosen. // or an attachment) before the heavy model is chosen.
const defaultThreshold = 0.35
// RoutingTier defines a single tier for model routing. // RoutingTier defines a single tier for model routing.
type RoutingTier struct { type RoutingTier struct {

View file

@ -82,13 +82,19 @@ func TestExtractFeatures_RecentToolCalls(t *testing.T) {
// History longer than lookbackWindow — only last lookbackWindow entries count. // History longer than lookbackWindow — only last lookbackWindow entries count.
history := make([]providers.Message, 10) history := make([]providers.Message, 10)
// Put 2 tool calls at positions 8 and 9 (within the last 6) // 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{ history[9] = providers.Message{
Role: "assistant", Role: "assistant",
ToolCalls: []providers.ToolCall{{Name: "read_file"}, {Name: "write_file"}}, ToolCalls: []providers.ToolCall{{Name: "read_file"}, {Name: "write_file"}},
} }
// Position 3 is outside the lookback window and must NOT be counted // 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) f := ExtractFeatures("test", history)
// 1 (position 8) + 2 (position 9) = 3 // 1 (position 8) + 2 (position 9) = 3
@ -241,9 +247,15 @@ func TestRuleClassifier_ScoreDoesNotExceedOne(t *testing.T) {
// ── Router ─────────────────────────────────────────────────────────────────── // ── Router ───────────────────────────────────────────────────────────────────
func TestRouter_SelectModel_SimpleMessageUsesLight(t *testing.T) { 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" msg := "hi"
model, usedLight, _ := r.SelectModel(msg, nil, "claude-sonnet-4-6") model, usedLight, _ := r.SelectModel(msg, nil, "claude-sonnet-4-6")
if !usedLight { if !usedLight {
@ -255,7 +267,14 @@ func TestRouter_SelectModel_SimpleMessageUsesLight(t *testing.T) {
} }
func TestRouter_SelectModel_CodeBlockUsesPrimary(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```" msg := "```go\nfmt.Println(\"hello\")\n```"
model, usedLight, _ := r.SelectModel(msg, nil, "claude-sonnet-4-6") model, usedLight, _ := r.SelectModel(msg, nil, "claude-sonnet-4-6")
if usedLight { if usedLight {
@ -267,7 +286,14 @@ func TestRouter_SelectModel_CodeBlockUsesPrimary(t *testing.T) {
} }
func TestRouter_SelectModel_AttachmentUsesPrimary(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" msg := "can you analyze this? data:image/png;base64,abc123"
model, usedLight, _ := r.SelectModel(msg, nil, "claude-sonnet-4-6") model, usedLight, _ := r.SelectModel(msg, nil, "claude-sonnet-4-6")
if usedLight { if usedLight {
@ -279,7 +305,14 @@ func TestRouter_SelectModel_AttachmentUsesPrimary(t *testing.T) {
} }
func TestRouter_SelectModel_LongMessageUsesPrimary(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 // >200 token estimate: 210 * 3 = 630 chars
msg := strings.Repeat("word ", 210) msg := strings.Repeat("word ", 210)
model, usedLight, _ := r.SelectModel(msg, nil, "claude-sonnet-4-6") model, usedLight, _ := r.SelectModel(msg, nil, "claude-sonnet-4-6")
@ -294,9 +327,19 @@ func TestRouter_SelectModel_LongMessageUsesPrimary(t *testing.T) {
func TestRouter_SelectModel_DeepToolChainUsesLight(t *testing.T) { func TestRouter_SelectModel_DeepToolChainUsesLight(t *testing.T) {
// Tool calls alone (0.25) don't cross the 0.35 threshold — acceptable behavior. // 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. // 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{ 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"}}}, {Role: "assistant", ToolCalls: []providers.ToolCall{{Name: "exec"}, {Name: "search"}}},
} }
msg := "ok" msg := "ok"
@ -308,7 +351,14 @@ func TestRouter_SelectModel_DeepToolChainUsesLight(t *testing.T) {
func TestRouter_SelectModel_ToolChainPlusMediumUsesHeavy(t *testing.T) { func TestRouter_SelectModel_ToolChainPlusMediumUsesHeavy(t *testing.T) {
// Tool calls (0.25) + medium message (0.15) = 0.40 >= 0.35 → heavy // 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{ history := []providers.Message{
{Role: "assistant", ToolCalls: []providers.ToolCall{ {Role: "assistant", ToolCalls: []providers.ToolCall{
{Name: "a"}, {Name: "b"}, {Name: "c"}, {Name: "d"}, {Name: "a"}, {Name: "b"}, {Name: "c"}, {Name: "d"},
@ -324,7 +374,14 @@ func TestRouter_SelectModel_ToolChainPlusMediumUsesHeavy(t *testing.T) {
func TestRouter_SelectModel_CustomThreshold(t *testing.T) { func TestRouter_SelectModel_CustomThreshold(t *testing.T) {
// Very low threshold: even a short message triggers heavy model // 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 msg := strings.Repeat("word ", 55) // medium message → 0.15 >= 0.05
_, usedLight, _ := r.SelectModel(msg, nil, "claude-sonnet-4-6") _, usedLight, _ := r.SelectModel(msg, nil, "claude-sonnet-4-6")
if usedLight { if usedLight {
@ -334,7 +391,14 @@ func TestRouter_SelectModel_CustomThreshold(t *testing.T) {
func TestRouter_SelectModel_HighThreshold(t *testing.T) { func TestRouter_SelectModel_HighThreshold(t *testing.T) {
// Very high threshold: even code blocks route to light // 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```" msg := "```go\nfmt.Println()\n```"
_, usedLight, _ := r.SelectModel(msg, nil, "claude-sonnet-4-6") _, usedLight, _ := r.SelectModel(msg, nil, "claude-sonnet-4-6")
if !usedLight { if !usedLight {
@ -343,7 +407,14 @@ func TestRouter_SelectModel_HighThreshold(t *testing.T) {
} }
func TestRouter_Tiers(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" { if r.Tiers()[0].Model != "my-fast-model" {
t.Errorf("LightModel: got %q, want %q", "my-fast-model", "my-fast-model") t.Errorf("LightModel: got %q, want %q", "my-fast-model", "my-fast-model")
} }
@ -357,7 +428,12 @@ func (f *fixedScoreClassifier) Score(_ Features) float64 { return f.score }
func TestRouter_CustomClassifier_LowScore_SelectsLight(t *testing.T) { func TestRouter_CustomClassifier_LowScore_SelectsLight(t *testing.T) {
r := newWithClassifier( 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}, &fixedScoreClassifier{score: 0.2},
) )
_, usedLight, _ := r.SelectModel("anything", nil, "heavy") _, usedLight, _ := r.SelectModel("anything", nil, "heavy")
@ -368,7 +444,12 @@ func TestRouter_CustomClassifier_LowScore_SelectsLight(t *testing.T) {
func TestRouter_CustomClassifier_HighScore_SelectsPrimary(t *testing.T) { func TestRouter_CustomClassifier_HighScore_SelectsPrimary(t *testing.T) {
r := newWithClassifier( 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}, &fixedScoreClassifier{score: 0.8},
) )
_, usedLight, _ := r.SelectModel("anything", nil, "heavy") _, usedLight, _ := r.SelectModel("anything", nil, "heavy")
@ -380,7 +461,12 @@ func TestRouter_CustomClassifier_HighScore_SelectsPrimary(t *testing.T) {
func TestRouter_CustomClassifier_ExactThreshold_SelectsPrimary(t *testing.T) { func TestRouter_CustomClassifier_ExactThreshold_SelectsPrimary(t *testing.T) {
// score == threshold → primary (uses >= comparison) // score == threshold → primary (uses >= comparison)
r := newWithClassifier( 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}, &fixedScoreClassifier{score: 0.5},
) )
_, usedLight, _ := r.SelectModel("anything", nil, "heavy") _, usedLight, _ := r.SelectModel("anything", nil, "heavy")
@ -391,7 +477,12 @@ func TestRouter_CustomClassifier_ExactThreshold_SelectsPrimary(t *testing.T) {
func TestRouter_SelectModel_ReturnsScore(t *testing.T) { func TestRouter_SelectModel_ReturnsScore(t *testing.T) {
r := newWithClassifier( 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}, &fixedScoreClassifier{score: 0.42},
) )
_, _, score := r.SelectModel("anything", nil, "heavy") _, _, score := r.SelectModel("anything", nil, "heavy")

View file

@ -69,7 +69,8 @@ func (t *UpdateSkillTool) Execute(ctx context.Context, args map[string]any) *Too
// Prepare the content to append // Prepare the content to append
timestamp := time.Now().Format("2006-01-02 15:04:05") 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, timestamp,
analysis, analysis,
skillsToImprove, skillsToImprove,
@ -92,11 +93,19 @@ func (t *UpdateSkillTool) Execute(ctx context.Context, args map[string]any) *Too
return ErrorResult(fmt.Sprintf("failed to append to SKILL.md: %v", err)) 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. // The response is passed back to the LLM.
// We also populate the ForUser field to notify the user. // We also populate the ForUser field to notify the user.
res := SilentResult(output) 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 return res
} }

View file

@ -65,5 +65,5 @@ func TestUpdateSkillTool_Execute(t *testing.T) {
contentStr2 := string(content2) contentStr2 := string(content2)
assert.Contains(t, contentStr2, "I was slow to find the file.") // Old content still there 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
} }

View file

@ -32,11 +32,11 @@ type modelResponse struct {
Proxy string `json:"proxy,omitempty"` Proxy string `json:"proxy,omitempty"`
AuthMethod string `json:"auth_method,omitempty"` AuthMethod string `json:"auth_method,omitempty"`
// Advanced fields // Advanced fields
ConnectMode string `json:"connect_mode,omitempty"` ConnectMode string `json:"connect_mode,omitempty"`
Workspace string `json:"workspace,omitempty"` Workspace string `json:"workspace,omitempty"`
RPM int `json:"rpm,omitempty"` RPM int `json:"rpm,omitempty"`
MaxTokensField string `json:"max_tokens_field,omitempty"` MaxTokensField string `json:"max_tokens_field,omitempty"`
RequestTimeout int `json:"request_timeout,omitempty"` RequestTimeout int `json:"request_timeout,omitempty"`
ThinkingLevel string `json:"thinking_level,omitempty"` ThinkingLevel string `json:"thinking_level,omitempty"`
ExtraBody map[string]any `json:"extra_body,omitempty"` ExtraBody map[string]any `json:"extra_body,omitempty"`
ExtraHeaders map[string]string `json:"extra_headers,omitempty"` ExtraHeaders map[string]string `json:"extra_headers,omitempty"`
@ -195,7 +195,11 @@ func (h *Handler) handleUpdateModel(w http.ResponseWriter, r *http.Request) {
} }
if idx < 0 || idx >= len(cfg.ModelList) { 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 return
} }
@ -248,7 +252,11 @@ func (h *Handler) handleDeleteModel(w http.ResponseWriter, r *http.Request) {
} }
if idx < 0 || idx >= len(cfg.ModelList) { 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 return
} }
@ -311,11 +319,19 @@ func (h *Handler) handleSetDefaultModel(w http.ResponseWriter, r *http.Request)
} }
} }
if !found { 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 return
} }
if isVirtual { 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 return
} }