diff --git a/pkg/config/config.go b/pkg/config/config.go index 07d740b7a..45cf8976e 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -17,6 +17,8 @@ var rrCounter atomic.Uint64 // FlexibleStringSlice is a []string that also accepts JSON numbers, // so allow_from can contain both "123" and 123. +// It also supports parsing comma-separated strings from environment variables, +// including both English (,) and Chinese (,) commas. type FlexibleStringSlice []string func (f *FlexibleStringSlice) UnmarshalJSON(data []byte) error { @@ -48,6 +50,30 @@ func (f *FlexibleStringSlice) UnmarshalJSON(data []byte) error { return nil } +// UnmarshalText implements encoding.TextUnmarshaler to support env variable parsing. +// It handles comma-separated values with both English (,) and Chinese (,) commas. +func (f *FlexibleStringSlice) UnmarshalText(text []byte) error { + if len(text) == 0 { + *f = nil + return nil + } + + s := string(text) + // Replace Chinese comma with English comma, then split + s = strings.ReplaceAll(s, ",", ",") + parts := strings.Split(s, ",") + + result := make([]string, 0, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(part) + if part != "" { + result = append(result, part) + } + } + *f = result + return nil +} + type Config struct { Agents AgentsConfig `json:"agents"` Bindings []AgentBinding `json:"bindings,omitempty"` @@ -59,6 +85,17 @@ type Config struct { Tools ToolsConfig `json:"tools"` Heartbeat HeartbeatConfig `json:"heartbeat"` Devices DevicesConfig `json:"devices"` + Voice VoiceConfig `json:"voice"` + // BuildInfo contains build-time version information + BuildInfo BuildInfo `json:"build_info,omitempty"` +} + +// BuildInfo contains build-time version information +type BuildInfo struct { + Version string `json:"version"` + GitCommit string `json:"git_commit"` + BuildTime string `json:"build_time"` + GoVersion string `json:"go_version"` } // MarshalJSON implements custom JSON marshaling for Config @@ -141,6 +178,16 @@ type AgentConfig struct { Subagents *SubagentsConfig `json:"subagents,omitempty"` } +type SubagentsConfig struct { + Enabled bool `json:"enabled,omitempty"` // Fork-only: gate orchestration + AllowAgents []string `json:"allow_agents,omitempty"` + Model *AgentModelConfig `json:"model,omitempty"` +} + +type PeerMatch struct { + Kind string `json:"kind"` + ID string `json:"id"` +} type BindingMatch struct { Channel string `json:"channel"` @@ -311,6 +358,8 @@ type QQConfig struct { AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_QQ_APP_SECRET"` AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_QQ_ALLOW_FROM"` GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` + MaxMessageLength int `json:"max_message_length" env:"PICOCLAW_CHANNELS_QQ_MAX_MESSAGE_LENGTH"` + SendMarkdown bool `json:"send_markdown" env:"PICOCLAW_CHANNELS_QQ_SEND_MARKDOWN"` ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_QQ_REASONING_CHANNEL_ID"` } @@ -335,13 +384,14 @@ type SlackConfig struct { } type MatrixConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_MATRIX_ENABLED"` - Homeserver string `json:"homeserver" env:"PICOCLAW_CHANNELS_MATRIX_HOMESERVER"` - UserID string `json:"user_id" env:"PICOCLAW_CHANNELS_MATRIX_USER_ID"` - AccessToken string `json:"access_token" env:"PICOCLAW_CHANNELS_MATRIX_ACCESS_TOKEN"` - DeviceID string `json:"device_id,omitempty" env:"PICOCLAW_CHANNELS_MATRIX_DEVICE_ID"` - JoinOnInvite bool `json:"join_on_invite" env:"PICOCLAW_CHANNELS_MATRIX_JOIN_ON_INVITE"` - AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_MATRIX_ALLOW_FROM"` + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_MATRIX_ENABLED"` + Homeserver string `json:"homeserver" env:"PICOCLAW_CHANNELS_MATRIX_HOMESERVER"` + UserID string `json:"user_id" env:"PICOCLAW_CHANNELS_MATRIX_USER_ID"` + AccessToken string `json:"access_token" env:"PICOCLAW_CHANNELS_MATRIX_ACCESS_TOKEN"` + DeviceID string `json:"device_id,omitempty" env:"PICOCLAW_CHANNELS_MATRIX_DEVICE_ID"` + JoinOnInvite bool `json:"join_on_invite" env:"PICOCLAW_CHANNELS_MATRIX_JOIN_ON_INVITE"` + MessageFormat string `json:"message_format,omitempty" env:"PICOCLAW_CHANNELS_MATRIX_MESSAGE_FORMAT"` + AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_MATRIX_ALLOW_FROM"` GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` Placeholder PlaceholderConfig `json:"placeholder,omitempty"` ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_MATRIX_REASONING_CHANNEL_ID"` @@ -458,6 +508,10 @@ type DevicesConfig struct { MonitorUSB bool `json:"monitor_usb" env:"PICOCLAW_DEVICES_MONITOR_USB"` } +type VoiceConfig struct { + EchoTranscription bool `json:"echo_transcription" env:"PICOCLAW_VOICE_ECHO_TRANSCRIPTION"` +} + type ProvidersConfig struct { Anthropic ProviderConfig `json:"anthropic"` OpenAI OpenAIProviderConfig `json:"openai"` @@ -480,6 +534,8 @@ type ProvidersConfig struct { Qwen ProviderConfig `json:"qwen"` Mistral ProviderConfig `json:"mistral"` Avian ProviderConfig `json:"avian"` + Minimax ProviderConfig `json:"minimax"` + LongCat ProviderConfig `json:"longcat"` } // IsEmpty checks if all provider configs are empty (no API keys or API bases set) @@ -505,7 +561,9 @@ func (p ProvidersConfig) IsEmpty() bool { p.Antigravity.APIKey == "" && p.Antigravity.APIBase == "" && p.Qwen.APIKey == "" && p.Qwen.APIBase == "" && p.Mistral.APIKey == "" && p.Mistral.APIBase == "" && - p.Avian.APIKey == "" && p.Avian.APIBase == "" + p.Avian.APIKey == "" && p.Avian.APIBase == "" && + p.Minimax.APIKey == "" && p.Minimax.APIBase == "" && + p.LongCat.APIKey == "" && p.LongCat.APIBase == "" } // MarshalJSON implements custom JSON marshaling for ProvidersConfig @@ -575,21 +633,31 @@ type GatewayConfig struct { Port int `json:"port" env:"PICOCLAW_GATEWAY_PORT"` } +type ToolDiscoveryConfig struct { + Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_DISCOVERY_ENABLED"` + TTL int `json:"ttl" env:"PICOCLAW_TOOLS_DISCOVERY_TTL"` + MaxSearchResults int `json:"max_search_results" env:"PICOCLAW_MAX_SEARCH_RESULTS"` + UseBM25 bool `json:"use_bm25" env:"PICOCLAW_TOOLS_DISCOVERY_USE_BM25"` + UseRegex bool `json:"use_regex" env:"PICOCLAW_TOOLS_DISCOVERY_USE_REGEX"` +} + type ToolConfig struct { Enabled bool `json:"enabled" env:"ENABLED"` } type BraveConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_BRAVE_ENABLED"` - APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_BRAVE_API_KEY"` - MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_BRAVE_MAX_RESULTS"` + Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_BRAVE_ENABLED"` + APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_BRAVE_API_KEY"` + APIKeys []string `json:"api_keys" env:"PICOCLAW_TOOLS_WEB_BRAVE_API_KEYS"` + MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_BRAVE_MAX_RESULTS"` } type TavilyConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_TAVILY_ENABLED"` - APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_TAVILY_API_KEY"` - BaseURL string `json:"base_url" env:"PICOCLAW_TOOLS_WEB_TAVILY_BASE_URL"` - MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_TAVILY_MAX_RESULTS"` + Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_TAVILY_ENABLED"` + APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_TAVILY_API_KEY"` + APIKeys []string `json:"api_keys" env:"PICOCLAW_TOOLS_WEB_TAVILY_API_KEYS"` + BaseURL string `json:"base_url" env:"PICOCLAW_TOOLS_WEB_TAVILY_BASE_URL"` + MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_TAVILY_MAX_RESULTS"` } type DuckDuckGoConfig struct { @@ -598,9 +666,10 @@ type DuckDuckGoConfig struct { } type PerplexityConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_ENABLED"` - APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_API_KEY"` - MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_MAX_RESULTS"` + Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_ENABLED"` + APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_API_KEY"` + APIKeys []string `json:"api_keys" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_API_KEYS"` + MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_MAX_RESULTS"` } type SearXNGConfig struct { @@ -641,6 +710,7 @@ type CronToolsConfig struct { type ExecConfig struct { ToolConfig ` envPrefix:"PICOCLAW_TOOLS_EXEC_"` EnableDenyPatterns bool ` env:"PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS" json:"enable_deny_patterns"` + AllowRemote bool ` env:"PICOCLAW_TOOLS_EXEC_ALLOW_REMOTE" json:"allow_remote"` CustomDenyPatterns []string ` env:"PICOCLAW_TOOLS_EXEC_CUSTOM_DENY_PATTERNS" json:"custom_deny_patterns"` CustomAllowPatterns []string ` env:"PICOCLAW_TOOLS_EXEC_CUSTOM_ALLOW_PATTERNS" json:"custom_allow_patterns"` TimeoutSeconds int ` env:"PICOCLAW_TOOLS_EXEC_TIMEOUT_SECONDS" json:"timeout_seconds"` // 0 means use default (60s) @@ -659,6 +729,11 @@ type MediaCleanupConfig struct { Interval int ` env:"PICOCLAW_MEDIA_CLEANUP_INTERVAL" json:"interval_minutes"` } +type ReadFileToolConfig struct { + Enabled bool `json:"enabled"` + MaxReadFileSize int `json:"max_read_file_size"` +} + 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"` @@ -675,7 +750,7 @@ type ToolsConfig struct { InstallSkill ToolConfig `json:"install_skill" envPrefix:"PICOCLAW_TOOLS_INSTALL_SKILL_"` ListDir ToolConfig `json:"list_dir" envPrefix:"PICOCLAW_TOOLS_LIST_DIR_"` Message ToolConfig `json:"message" envPrefix:"PICOCLAW_TOOLS_MESSAGE_"` - ReadFile ToolConfig `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_"` Spawn ToolConfig `json:"spawn" envPrefix:"PICOCLAW_TOOLS_SPAWN_"` SPI ToolConfig `json:"spi" envPrefix:"PICOCLAW_TOOLS_SPI_"` @@ -727,7 +802,8 @@ 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"` } @@ -927,6 +1003,29 @@ func (c *Config) ValidateModelList() error { return nil } +func MergeAPIKeys(apiKey string, apiKeys []string) []string { + seen := make(map[string]struct{}) + var all []string + + if k := strings.TrimSpace(apiKey); k != "" { + if _, exists := seen[k]; !exists { + seen[k] = struct{}{} + all = append(all, k) + } + } + + for _, k := range apiKeys { + if trimmed := strings.TrimSpace(k); trimmed != "" { + if _, exists := seen[trimmed]; !exists { + seen[trimmed] = struct{}{} + all = append(all, trimmed) + } + } + } + + return all +} + func (t *ToolsConfig) IsToolEnabled(name string) bool { switch name { case "web": diff --git a/pkg/config/config_ext.go b/pkg/config/config_ext.go deleted file mode 100644 index 61933a90f..000000000 --- a/pkg/config/config_ext.go +++ /dev/null @@ -1,14 +0,0 @@ -package config - -// SubagentsConfig holds fork-specific subagent orchestration settings. -type SubagentsConfig struct { - Enabled bool `json:"enabled,omitempty"` - AllowAgents []string `json:"allow_agents,omitempty"` - Model *AgentModelConfig `json:"model,omitempty"` -} - -// PeerMatch identifies a peer by kind (direct/group) and ID. -type PeerMatch struct { - Kind string `json:"kind"` - ID string `json:"id"` -} diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go index b4177f083..cbdebd43c 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -461,7 +461,7 @@ func DefaultConfig() *Config { Message: ToolConfig{ Enabled: true, }, - ReadFile: ToolConfig{ + ReadFile: ReadFileToolConfig{ Enabled: true, }, Spawn: ToolConfig{ diff --git a/pkg/providers/anthropic/provider.go b/pkg/providers/anthropic/provider.go index 6e5260039..5657bac50 100644 --- a/pkg/providers/anthropic/provider.go +++ b/pkg/providers/anthropic/provider.go @@ -181,10 +181,8 @@ func buildParams( } for _, tc := range msg.ToolCalls { args := tc.Arguments - if args == nil && tc.Function != nil && tc.Function.Arguments != "" { - if err := json.Unmarshal([]byte(tc.Function.Arguments), &args); err != nil { - args = map[string]any{} - } + if args == nil && tc.Function != nil && len(tc.Function.Arguments) > 0 { + args = tc.Function.Arguments } if args == nil { args = map[string]any{} diff --git a/pkg/tools/base.go b/pkg/tools/base.go index 3367175d6..fd397ac0e 100644 --- a/pkg/tools/base.go +++ b/pkg/tools/base.go @@ -10,6 +10,77 @@ type Tool interface { Execute(ctx context.Context, args map[string]any) *ToolResult } +// --- Request-scoped tool context (channel / chatID) --- +// +// Carried via context.Value so that concurrent tool calls each receive +// their own immutable copy — no mutable state on singleton tool instances. +// +// Keys are unexported pointer-typed vars — guaranteed collision-free, +// and only accessible through the helper functions below. + +type toolCtxKey struct{ name string } + +var ( + ctxKeyChannel = &toolCtxKey{"channel"} + ctxKeyChatID = &toolCtxKey{"chatID"} +) + +// WithToolContext returns a child context carrying channel and chatID. +func WithToolContext(ctx context.Context, channel, chatID string) context.Context { + ctx = context.WithValue(ctx, ctxKeyChannel, channel) + ctx = context.WithValue(ctx, ctxKeyChatID, chatID) + return ctx +} + +// ToolChannel extracts the channel from ctx, or "" if unset. +func ToolChannel(ctx context.Context) string { + v, _ := ctx.Value(ctxKeyChannel).(string) + return v +} + +// ToolChatID extracts the chatID from ctx, or "" if unset. +func ToolChatID(ctx context.Context) string { + v, _ := ctx.Value(ctxKeyChatID).(string) + return v +} + +// AsyncCallback is a function type that async tools use to notify completion. +// When an async tool finishes its work, it calls this callback with the result. +// +// The ctx parameter allows the callback to be canceled if the agent is shutting down. +// The result parameter contains the tool's execution result. +type AsyncCallback func(ctx context.Context, result *ToolResult) + +// AsyncExecutor is an optional interface that tools can implement to support +// asynchronous execution with completion callbacks. +// +// Unlike the old AsyncTool pattern (SetCallback + Execute), AsyncExecutor +// receives the callback as a parameter of ExecuteAsync. This eliminates the +// data race where concurrent calls could overwrite each other's callbacks +// on a shared tool instance. +// +// This is useful for: +// - Long-running operations that shouldn't block the agent loop +// - Subagent spawns that complete independently +// - Background tasks that need to report results later +// +// Example: +// +// func (t *SpawnTool) ExecuteAsync(ctx context.Context, args map[string]any, cb AsyncCallback) *ToolResult { +// go func() { +// result := t.runSubagent(ctx, args) +// if cb != nil { cb(ctx, result) } +// }() +// return AsyncResult("Subagent spawned, will report back") +// } +type AsyncExecutor interface { + Tool + // ExecuteAsync runs the tool asynchronously. The callback cb will be + // invoked (possibly from another goroutine) when the async operation + // completes. cb is guaranteed to be non-nil by the caller (registry). + ExecuteAsync(ctx context.Context, args map[string]any, cb AsyncCallback) *ToolResult +} + func ToolToSchema(tool Tool) map[string]any { return map[string]any{ "type": "function", @@ -20,3 +91,27 @@ func ToolToSchema(tool Tool) map[string]any { }, } } + +// --- Fork-only extensions below --- + +// ContextualTool is an optional interface for tools that need to know +// which channel/chatID they are executing in. Prefer using ToolChannel(ctx) +// and ToolChatID(ctx) instead — this interface exists for backward compatibility. +type ContextualTool interface { + Tool + SetContext(channel, chatID string) +} + +// AsyncTool is the legacy async interface. Prefer AsyncExecutor instead — +// it passes the callback as a parameter to avoid data races on shared instances. +type AsyncTool interface { + Tool + SetCallback(cb AsyncCallback) +} + +// StatusProvider is an optional interface that tools can implement +// to inject runtime status information into the system prompt. +// Return an empty string to inject nothing. +type StatusProvider interface { + RuntimeStatus() string +} diff --git a/pkg/tools/base_ext.go b/pkg/tools/base_ext.go deleted file mode 100644 index 907ab65e6..000000000 --- a/pkg/tools/base_ext.go +++ /dev/null @@ -1,81 +0,0 @@ -package tools - -import "context" - -// --- Request-scoped tool context (channel / chatID) --- -// -// Carried via context.Value so that concurrent tool calls each receive -// their own immutable copy — no mutable state on singleton tool instances. -// -// Keys are unexported pointer-typed vars — guaranteed collision-free, -// and only accessible through the helper functions below. - -type toolCtxKey struct{ name string } - -var ( - ctxKeyChannel = &toolCtxKey{"channel"} - ctxKeyChatID = &toolCtxKey{"chatID"} -) - -// WithToolContext returns a child context carrying channel and chatID. -func WithToolContext(ctx context.Context, channel, chatID string) context.Context { - ctx = context.WithValue(ctx, ctxKeyChannel, channel) - ctx = context.WithValue(ctx, ctxKeyChatID, chatID) - return ctx -} - -// ToolChannel extracts the channel from ctx, or "" if unset. -func ToolChannel(ctx context.Context) string { - v, _ := ctx.Value(ctxKeyChannel).(string) - return v -} - -// ToolChatID extracts the chatID from ctx, or "" if unset. -func ToolChatID(ctx context.Context) string { - v, _ := ctx.Value(ctxKeyChatID).(string) - return v -} - -// AsyncCallback is a function type that async tools use to notify completion. -// When an async tool finishes its work, it calls this callback with the result. -// -// The ctx parameter allows the callback to be canceled if the agent is shutting down. -// The result parameter contains the tool's execution result. -type AsyncCallback func(ctx context.Context, result *ToolResult) - -// AsyncExecutor is an optional interface that tools can implement to support -// asynchronous execution with completion callbacks. -// -// Unlike the old AsyncTool pattern (SetCallback + Execute), AsyncExecutor -// receives the callback as a parameter of ExecuteAsync. This eliminates the -// data race where concurrent calls could overwrite each other's callbacks -// on a shared tool instance. -// -// This is useful for: -// - Long-running operations that shouldn't block the agent loop -// - Subagent spawns that complete independently -// - Background tasks that need to report results later -// -// Example: -// -// func (t *SpawnTool) ExecuteAsync(ctx context.Context, args map[string]any, cb AsyncCallback) *ToolResult { -// go func() { -// result := t.runSubagent(ctx, args) -// if cb != nil { cb(ctx, result) } -// }() -// return AsyncResult("Subagent spawned, will report back") -// } -type AsyncExecutor interface { - Tool - // ExecuteAsync runs the tool asynchronously. The callback cb will be - // invoked (possibly from another goroutine) when the async operation - // completes. cb is guaranteed to be non-nil by the caller (registry). - ExecuteAsync(ctx context.Context, args map[string]any, cb AsyncCallback) *ToolResult -} - -// StatusProvider is an optional interface that tools can implement -// to inject runtime status information into the system prompt. -// Return an empty string to inject nothing. -type StatusProvider interface { - RuntimeStatus() string -} diff --git a/pkg/tools/registry.go b/pkg/tools/registry.go index eab1e421d..5df7b94e1 100644 --- a/pkg/tools/registry.go +++ b/pkg/tools/registry.go @@ -2,18 +2,22 @@ package tools import ( "context" + "encoding/json" + "fmt" "sort" "strings" "sync" + "sync/atomic" + "time" + + "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/providers" ) // NormalizeToolName keeps only lowercase ASCII letters. - // "read_file" → "readfile", "ReadFile" → "readfile", "read-file" → "readfile". - func NormalizeToolName(s string) string { var b strings.Builder - for _, r := range s { if r >= 'A' && r <= 'Z' { b.WriteRune(r + 32) @@ -21,48 +25,154 @@ func NormalizeToolName(s string) string { b.WriteRune(r) } } - return b.String() } -type ToolRegistry struct { - tools map[string]Tool +type ToolEntry struct { + Tool Tool + IsCore bool + TTL int +} - mu sync.RWMutex +type ToolRegistry struct { + tools map[string]*ToolEntry + mu sync.RWMutex + version atomic.Uint64 // incremented on Register/RegisterHidden for cache invalidation } func NewToolRegistry() *ToolRegistry { return &ToolRegistry{ - tools: make(map[string]Tool), + tools: make(map[string]*ToolEntry), } } func (r *ToolRegistry) Register(tool Tool) { r.mu.Lock() - defer r.mu.Unlock() + name := tool.Name() + if _, exists := r.tools[name]; exists { + logger.WarnCF("tools", "Tool registration overwrites existing tool", + map[string]any{"name": name}) + } + r.tools[name] = &ToolEntry{ + Tool: tool, + IsCore: true, + TTL: 0, // Core tools do not use TTL + } + r.version.Add(1) + logger.DebugCF("tools", "Registered core tool", map[string]any{"name": name}) +} - r.tools[tool.Name()] = tool +// RegisterHidden saves hidden tools (visible only via TTL) +func (r *ToolRegistry) RegisterHidden(tool Tool) { + r.mu.Lock() + defer r.mu.Unlock() + name := tool.Name() + if _, exists := r.tools[name]; exists { + logger.WarnCF("tools", "Hidden tool registration overwrites existing tool", + map[string]any{"name": name}) + } + r.tools[name] = &ToolEntry{ + Tool: tool, + IsCore: false, + TTL: 0, + } + r.version.Add(1) + logger.DebugCF("tools", "Registered hidden tool", map[string]any{"name": name}) +} + +// PromoteTools atomically sets the TTL for multiple non-core tools. +// This prevents a concurrent TickTTL from decrementing between promotions. +func (r *ToolRegistry) PromoteTools(names []string, ttl int) { + r.mu.Lock() + defer r.mu.Unlock() + promoted := 0 + for _, name := range names { + if entry, exists := r.tools[name]; exists { + if !entry.IsCore { + entry.TTL = ttl + promoted++ + } + } + } + logger.DebugCF( + "tools", + "PromoteTools completed", + map[string]any{"requested": len(names), "promoted": promoted, "ttl": ttl}, + ) +} + +// TickTTL decreases TTL only for non-core tools +func (r *ToolRegistry) TickTTL() { + r.mu.Lock() + defer r.mu.Unlock() + for _, entry := range r.tools { + if !entry.IsCore && entry.TTL > 0 { + entry.TTL-- + } + } +} + +// Version returns the current registry version (atomically). +func (r *ToolRegistry) Version() uint64 { + return r.version.Load() +} + +// HiddenToolSnapshot holds a consistent snapshot of hidden tools and the +// registry version at which it was taken. Used by BM25SearchTool cache. +type HiddenToolSnapshot struct { + Docs []HiddenToolDoc + Version uint64 +} + +// HiddenToolDoc is a lightweight representation of a hidden tool for search indexing. +type HiddenToolDoc struct { + Name string + Description string +} + +// SnapshotHiddenTools returns all non-core tools and the current registry +// version under a single read-lock, guaranteeing consistency between the +// two values. +func (r *ToolRegistry) SnapshotHiddenTools() HiddenToolSnapshot { + r.mu.RLock() + defer r.mu.RUnlock() + docs := make([]HiddenToolDoc, 0, len(r.tools)) + for name, entry := range r.tools { + if !entry.IsCore { + docs = append(docs, HiddenToolDoc{ + Name: name, + Description: entry.Tool.Description(), + }) + } + } + return HiddenToolSnapshot{ + Docs: docs, + Version: r.version.Load(), + } } func (r *ToolRegistry) Get(name string) (Tool, bool) { r.mu.RLock() - defer r.mu.RUnlock() // Exact match first - - if tool, ok := r.tools[name]; ok { - return tool, true + if entry, ok := r.tools[name]; ok { + // Hidden tools with expired TTL are not callable. + if !entry.IsCore && entry.TTL <= 0 { + return nil, false + } + return entry.Tool, true } - // Fuzzy fallback: normalize and compare (handles "readfile" → "read_file" etc.) - + // Fork extension: fuzzy fallback — normalize and compare + // (handles "readfile" → "read_file" etc.) norm := NormalizeToolName(name) - - for _, tool := range r.tools { - if NormalizeToolName(tool.Name()) == norm { - return tool, true + for _, entry := range r.tools { + if entry.IsCore || entry.TTL > 0 { + if NormalizeToolName(entry.Tool.Name()) == norm { + return entry.Tool, true + } } } @@ -73,58 +183,199 @@ func (r *ToolRegistry) Execute(ctx context.Context, name string, args map[string return r.ExecuteWithContext(ctx, name, args, "", "", nil) } +// ExecuteWithContext executes a tool with channel/chatID context and optional async callback. +// If the tool implements AsyncExecutor and a non-nil callback is provided, +// ExecuteAsync is called instead of Execute — the callback is a parameter, +// never stored as mutable state on the tool. +func (r *ToolRegistry) ExecuteWithContext( + ctx context.Context, + name string, + args map[string]any, + channel, chatID string, + asyncCallback AsyncCallback, +) *ToolResult { + logger.InfoCF("tool", "Tool execution started", + map[string]any{ + "tool": name, + "args": args, + }) + + tool, ok := r.Get(name) + if !ok { + logger.ErrorCF("tool", "Tool not found", + map[string]any{ + "tool": name, + }) + return ErrorResult(fmt.Sprintf("tool %q not found", name)).WithError(fmt.Errorf("tool not found")) + } + + // Inject channel/chatID into ctx so tools read them via ToolChannel(ctx)/ToolChatID(ctx). + // Always inject — tools validate what they require. + ctx = WithToolContext(ctx, channel, chatID) + + // Legacy ContextualTool support (fork-only, prefer ctx-based injection above) + if contextualTool, ok := tool.(ContextualTool); ok && channel != "" && chatID != "" { + contextualTool.SetContext(channel, chatID) + } + + // If tool implements AsyncExecutor and callback is provided, use ExecuteAsync. + // The callback is a call parameter, not mutable state on the tool instance. + var result *ToolResult + start := time.Now() + if asyncExec, ok := tool.(AsyncExecutor); ok && asyncCallback != nil { + logger.DebugCF("tool", "Executing async tool via ExecuteAsync", + map[string]any{ + "tool": name, + }) + result = asyncExec.ExecuteAsync(ctx, args, asyncCallback) + } else if asyncTool, ok := tool.(AsyncTool); ok && asyncCallback != nil { + // Legacy AsyncTool support (fork-only, prefer AsyncExecutor above) + asyncTool.SetCallback(asyncCallback) + logger.DebugCF("tool", "Async callback injected (legacy)", + map[string]any{ + "tool": name, + }) + result = tool.Execute(ctx, args) + } else { + result = tool.Execute(ctx, args) + } + duration := time.Since(start) + + // Log based on result type + if result.IsError { + logger.ErrorCF("tool", "Tool execution failed", + map[string]any{ + "tool": name, + "duration": duration.Milliseconds(), + "error": result.ForLLM, + }) + } else if result.Async { + logger.InfoCF("tool", "Tool started (async)", + map[string]any{ + "tool": name, + "duration": duration.Milliseconds(), + }) + } else { + logger.InfoCF("tool", "Tool execution completed", + map[string]any{ + "tool": name, + "duration_ms": duration.Milliseconds(), + "result_length": len(result.ForLLM), + }) + } + + return result +} + // sortedToolNames returns tool names in sorted order for deterministic iteration. - // This is critical for KV cache stability: non-deterministic map iteration would - // produce different system prompts and tool definitions on each call, invalidating - // the LLM's prefix cache even when no tools have changed. - func (r *ToolRegistry) sortedToolNames() []string { names := make([]string, 0, len(r.tools)) - for name := range r.tools { names = append(names, name) } - sort.Strings(names) - return names } func (r *ToolRegistry) GetDefinitions() []map[string]any { r.mu.RLock() - defer r.mu.RUnlock() sorted := r.sortedToolNames() - definitions := make([]map[string]any, 0, len(sorted)) - for _, name := range sorted { - definitions = append(definitions, ToolToSchema(r.tools[name])) - } + entry := r.tools[name] + if !entry.IsCore && entry.TTL <= 0 { + continue + } + + definitions = append(definitions, ToolToSchema(entry.Tool)) + } + return definitions +} + +// ToProviderDefs converts tool definitions to provider-compatible format. +// This is the format expected by LLM provider APIs. +func (r *ToolRegistry) ToProviderDefs() []providers.ToolDefinition { + r.mu.RLock() + defer r.mu.RUnlock() + + sorted := r.sortedToolNames() + definitions := make([]providers.ToolDefinition, 0, len(sorted)) + for _, name := range sorted { + entry := r.tools[name] + + if !entry.IsCore && entry.TTL <= 0 { + continue + } + + schema := ToolToSchema(entry.Tool) + + // Safely extract nested values with type checks + fn, ok := schema["function"].(map[string]any) + if !ok { + continue + } + + name, _ := fn["name"].(string) + desc, _ := fn["description"].(string) + params, _ := fn["parameters"].(map[string]any) + + paramsRaw := json.RawMessage(`{}`) + if len(params) > 0 { + if payload, err := json.Marshal(params); err == nil { + paramsRaw = payload + } + } + + definitions = append(definitions, providers.ToolDefinition{ + Type: "function", + Function: providers.ToolFunctionDefinition{ + Name: name, + Description: desc, + Parameters: paramsRaw, + }, + }) + } return definitions } // List returns a list of all registered tool names. - func (r *ToolRegistry) List() []string { r.mu.RLock() - defer r.mu.RUnlock() return r.sortedToolNames() } // Count returns the number of registered tools. - func (r *ToolRegistry) Count() int { r.mu.RLock() - defer r.mu.RUnlock() - return len(r.tools) } + +// GetSummaries returns human-readable summaries of all registered tools. +// Returns a slice of "name - description" strings. +func (r *ToolRegistry) GetSummaries() []string { + r.mu.RLock() + defer r.mu.RUnlock() + + sorted := r.sortedToolNames() + summaries := make([]string, 0, len(sorted)) + for _, name := range sorted { + entry := r.tools[name] + + if !entry.IsCore && entry.TTL <= 0 { + continue + } + + hint := buildParamHint(entry.Tool.Parameters()) + summaries = append(summaries, fmt.Sprintf("- `%s`%s - %s", entry.Tool.Name(), hint, entry.Tool.Description())) + } + return summaries +} diff --git a/pkg/tools/registry_ext.go b/pkg/tools/registry_ext.go index cdc8941b2..e2f88f171 100644 --- a/pkg/tools/registry_ext.go +++ b/pkg/tools/registry_ext.go @@ -1,186 +1,21 @@ package tools import ( - "encoding/json" - "fmt" "sort" "strings" - "time" - - "github.com/sipeed/picoclaw/pkg/logger" - "github.com/sipeed/picoclaw/pkg/providers" ) -// ExecuteWithContext executes a tool with channel/chatID context and optional async callback. - -// If the tool implements AsyncTool and a non-nil callback is provided, - -// the callback will be set on the tool before execution. - -func (r *ToolRegistry) ExecuteWithContext( - ctx context.Context, - - name string, - - args map[string]any, - - channel, chatID string, - - asyncCallback AsyncCallback, -) *ToolResult { - logger.InfoCF("tool", "Tool execution started", - - map[string]any{ - "tool": name, - - "args": args, - }) - - tool, ok := r.Get(name) - - if !ok { - available := strings.Join(r.List(), ", ") - - logger.ErrorCF("tool", "Tool not found", - - map[string]any{ - "tool": name, - }) - - return ErrorResult(fmt.Sprintf( - - "tool %q not found. Available tools: %s", name, available, - )).WithError(fmt.Errorf("tool not found")) - } - - // If tool implements ContextualTool, set context - - if contextualTool, ok := tool.(ContextualTool); ok && channel != "" && chatID != "" { - contextualTool.SetContext(channel, chatID) - } - - // If tool implements AsyncTool and callback is provided, set callback - - if asyncTool, ok := tool.(AsyncTool); ok && asyncCallback != nil { - asyncTool.SetCallback(asyncCallback) - - logger.DebugCF("tool", "Async callback injected", - - map[string]any{ - "tool": name, - }) - } - - start := time.Now() - - result := tool.Execute(ctx, args) - - duration := time.Since(start) - - // Log based on result type - - if result.IsError { - logger.ErrorCF("tool", "Tool execution failed", - - map[string]any{ - "tool": name, - - "duration": duration.Milliseconds(), - - "error": result.ForLLM, - }) - } else if result.Async { - logger.InfoCF("tool", "Tool started (async)", - - map[string]any{ - "tool": name, - - "duration": duration.Milliseconds(), - }) - } else { - logger.InfoCF("tool", "Tool execution completed", - - map[string]any{ - "tool": name, - - "duration_ms": duration.Milliseconds(), - - "result_length": len(result.ForLLM), - }) - } - - return result -} - -// ToProviderDefs converts tool definitions to provider-compatible format. - -// This is the format expected by LLM provider APIs. - -func (r *ToolRegistry) ToProviderDefs() []providers.ToolDefinition { - r.mu.RLock() - - defer r.mu.RUnlock() - - sorted := r.sortedToolNames() - - definitions := make([]providers.ToolDefinition, 0, len(sorted)) - - for _, name := range sorted { - tool := r.tools[name] - - schema := ToolToSchema(tool) - - // Safely extract nested values with type checks - - fn, ok := schema["function"].(map[string]any) - - if !ok { - continue - } - - name, _ := fn["name"].(string) - - desc, _ := fn["description"].(string) - - params, _ := fn["parameters"].(map[string]any) - - paramsRaw := json.RawMessage(`{}`) - - if len(params) > 0 { - if payload, err := json.Marshal(params); err == nil { - paramsRaw = json.RawMessage(payload) - } - } - - definitions = append(definitions, providers.ToolDefinition{ - Type: "function", - - Function: providers.ToolFunctionDefinition{ - Name: name, - - Description: desc, - - Parameters: paramsRaw, - }, - }) - } - - return definitions -} +// --- Fork-only registry extensions --- // GetRuntimeStatus aggregates runtime status from all tools that implement StatusProvider. - // Returns empty string if no tool has status to report. - func (r *ToolRegistry) GetRuntimeStatus() string { r.mu.RLock() - defer r.mu.RUnlock() var parts []string - - for _, tool := range r.tools { - if sp, ok := tool.(StatusProvider); ok { + for _, entry := range r.tools { + if sp, ok := entry.Tool.(StatusProvider); ok { if s := sp.RuntimeStatus(); s != "" { parts = append(parts, s) } @@ -195,44 +30,34 @@ func (r *ToolRegistry) GetRuntimeStatus() string { } // buildParamHint extracts parameter names from a JSON schema and returns - // a hint string like "(task, label?, preset?)". Required params are bare, - // optional params have a trailing "?". - func buildParamHint(schema map[string]any) string { props, _ := schema["properties"].(map[string]any) - if len(props) == 0 { return "" } reqSlice, _ := schema["required"].([]string) - reqSet := make(map[string]bool, len(reqSlice)) - for _, r := range reqSlice { reqSet[r] = true } names := make([]string, 0, len(props)) - for name := range props { names = append(names, name) } - sort.Strings(names) parts := make([]string, 0, len(names)) // Required params first, then optional - for _, name := range names { if reqSet[name] { parts = append(parts, name) } } - for _, name := range names { if !reqSet[name] { parts = append(parts, name+"?") @@ -241,27 +66,3 @@ func buildParamHint(schema map[string]any) string { return "(" + strings.Join(parts, ", ") + ")" } - -// GetSummaries returns human-readable summaries of all registered tools. - -// Returns a slice of "- `name`(params) - description" strings. - -func (r *ToolRegistry) GetSummaries() []string { - r.mu.RLock() - - defer r.mu.RUnlock() - - sorted := r.sortedToolNames() - - summaries := make([]string, 0, len(sorted)) - - for _, name := range sorted { - tool := r.tools[name] - - hint := buildParamHint(tool.Parameters()) - - summaries = append(summaries, fmt.Sprintf("- `%s`%s - %s", tool.Name(), hint, tool.Description())) - } - - return summaries -}