refactor: adopt upstream ToolEntry pattern and structural changes
- base.go: merge base_ext.go back to match upstream layout (context helpers, AsyncExecutor in base.go; StatusProvider + ContextualTool + AsyncTool as fork-only additions) - registry.go: adopt upstream's ToolEntry struct with IsCore/TTL, hidden tools, RegisterHidden, PromoteTools, TickTTL, Version, SnapshotHiddenTools; merge ExecuteWithContext/ToProviderDefs/GetSummaries from registry_ext.go - registry_ext.go: reduced to fork-only GetRuntimeStatus + buildParamHint - config.go: add upstream-only types and fields (VoiceConfig, BuildInfo, ToolDiscoveryConfig, ReadFileToolConfig, MergeAPIKeys, MessageFormat, AllowRemote, APIKeys on search configs, Minimax/LongCat providers, UnmarshalText, QQ MaxMessageLength/SendMarkdown); merge SubagentsConfig and PeerMatch back from config_ext.go - Delete base_ext.go and config_ext.go (contents merged back) - Fix pre-existing anthropic provider build error (FunctionCall.Arguments is now map[string]any, not string) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
77c4f69d2e
commit
c14e3769e2
8 changed files with 508 additions and 359 deletions
|
|
@ -17,6 +17,8 @@ var rrCounter atomic.Uint64
|
||||||
|
|
||||||
// FlexibleStringSlice is a []string that also accepts JSON numbers,
|
// FlexibleStringSlice is a []string that also accepts JSON numbers,
|
||||||
// so allow_from can contain both "123" and 123.
|
// 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
|
type FlexibleStringSlice []string
|
||||||
|
|
||||||
func (f *FlexibleStringSlice) UnmarshalJSON(data []byte) error {
|
func (f *FlexibleStringSlice) UnmarshalJSON(data []byte) error {
|
||||||
|
|
@ -48,6 +50,30 @@ func (f *FlexibleStringSlice) UnmarshalJSON(data []byte) error {
|
||||||
return nil
|
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 {
|
type Config struct {
|
||||||
Agents AgentsConfig `json:"agents"`
|
Agents AgentsConfig `json:"agents"`
|
||||||
Bindings []AgentBinding `json:"bindings,omitempty"`
|
Bindings []AgentBinding `json:"bindings,omitempty"`
|
||||||
|
|
@ -59,6 +85,17 @@ type Config struct {
|
||||||
Tools ToolsConfig `json:"tools"`
|
Tools ToolsConfig `json:"tools"`
|
||||||
Heartbeat HeartbeatConfig `json:"heartbeat"`
|
Heartbeat HeartbeatConfig `json:"heartbeat"`
|
||||||
Devices DevicesConfig `json:"devices"`
|
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
|
// MarshalJSON implements custom JSON marshaling for Config
|
||||||
|
|
@ -141,6 +178,16 @@ type AgentConfig struct {
|
||||||
Subagents *SubagentsConfig `json:"subagents,omitempty"`
|
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 {
|
type BindingMatch struct {
|
||||||
Channel string `json:"channel"`
|
Channel string `json:"channel"`
|
||||||
|
|
@ -311,6 +358,8 @@ type QQConfig struct {
|
||||||
AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_QQ_APP_SECRET"`
|
AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_QQ_APP_SECRET"`
|
||||||
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_QQ_ALLOW_FROM"`
|
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_QQ_ALLOW_FROM"`
|
||||||
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
|
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"`
|
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_QQ_REASONING_CHANNEL_ID"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -341,6 +390,7 @@ type MatrixConfig struct {
|
||||||
AccessToken string `json:"access_token" env:"PICOCLAW_CHANNELS_MATRIX_ACCESS_TOKEN"`
|
AccessToken string `json:"access_token" env:"PICOCLAW_CHANNELS_MATRIX_ACCESS_TOKEN"`
|
||||||
DeviceID string `json:"device_id,omitempty" env:"PICOCLAW_CHANNELS_MATRIX_DEVICE_ID"`
|
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"`
|
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"`
|
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_MATRIX_ALLOW_FROM"`
|
||||||
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
|
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
|
||||||
Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
|
Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
|
||||||
|
|
@ -458,6 +508,10 @@ type DevicesConfig struct {
|
||||||
MonitorUSB bool `json:"monitor_usb" env:"PICOCLAW_DEVICES_MONITOR_USB"`
|
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 {
|
type ProvidersConfig struct {
|
||||||
Anthropic ProviderConfig `json:"anthropic"`
|
Anthropic ProviderConfig `json:"anthropic"`
|
||||||
OpenAI OpenAIProviderConfig `json:"openai"`
|
OpenAI OpenAIProviderConfig `json:"openai"`
|
||||||
|
|
@ -480,6 +534,8 @@ type ProvidersConfig struct {
|
||||||
Qwen ProviderConfig `json:"qwen"`
|
Qwen ProviderConfig `json:"qwen"`
|
||||||
Mistral ProviderConfig `json:"mistral"`
|
Mistral ProviderConfig `json:"mistral"`
|
||||||
Avian ProviderConfig `json:"avian"`
|
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)
|
// 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.Antigravity.APIKey == "" && p.Antigravity.APIBase == "" &&
|
||||||
p.Qwen.APIKey == "" && p.Qwen.APIBase == "" &&
|
p.Qwen.APIKey == "" && p.Qwen.APIBase == "" &&
|
||||||
p.Mistral.APIKey == "" && p.Mistral.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
|
// MarshalJSON implements custom JSON marshaling for ProvidersConfig
|
||||||
|
|
@ -575,6 +633,14 @@ type GatewayConfig struct {
|
||||||
Port int `json:"port" env:"PICOCLAW_GATEWAY_PORT"`
|
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 {
|
type ToolConfig struct {
|
||||||
Enabled bool `json:"enabled" env:"ENABLED"`
|
Enabled bool `json:"enabled" env:"ENABLED"`
|
||||||
}
|
}
|
||||||
|
|
@ -582,12 +648,14 @@ type ToolConfig struct {
|
||||||
type BraveConfig struct {
|
type BraveConfig struct {
|
||||||
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_BRAVE_ENABLED"`
|
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_BRAVE_ENABLED"`
|
||||||
APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_BRAVE_API_KEY"`
|
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"`
|
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_BRAVE_MAX_RESULTS"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type TavilyConfig struct {
|
type TavilyConfig struct {
|
||||||
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_TAVILY_ENABLED"`
|
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_TAVILY_ENABLED"`
|
||||||
APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_TAVILY_API_KEY"`
|
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"`
|
BaseURL string `json:"base_url" env:"PICOCLAW_TOOLS_WEB_TAVILY_BASE_URL"`
|
||||||
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_TAVILY_MAX_RESULTS"`
|
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_TAVILY_MAX_RESULTS"`
|
||||||
}
|
}
|
||||||
|
|
@ -600,6 +668,7 @@ type DuckDuckGoConfig struct {
|
||||||
type PerplexityConfig struct {
|
type PerplexityConfig struct {
|
||||||
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_ENABLED"`
|
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_ENABLED"`
|
||||||
APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_API_KEY"`
|
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"`
|
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_MAX_RESULTS"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -641,6 +710,7 @@ type CronToolsConfig struct {
|
||||||
type ExecConfig struct {
|
type ExecConfig struct {
|
||||||
ToolConfig ` envPrefix:"PICOCLAW_TOOLS_EXEC_"`
|
ToolConfig ` envPrefix:"PICOCLAW_TOOLS_EXEC_"`
|
||||||
EnableDenyPatterns bool ` env:"PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS" json:"enable_deny_patterns"`
|
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"`
|
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"`
|
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)
|
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"`
|
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 {
|
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"`
|
||||||
|
|
@ -675,7 +750,7 @@ type ToolsConfig struct {
|
||||||
InstallSkill ToolConfig `json:"install_skill" envPrefix:"PICOCLAW_TOOLS_INSTALL_SKILL_"`
|
InstallSkill ToolConfig `json:"install_skill" envPrefix:"PICOCLAW_TOOLS_INSTALL_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 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_"`
|
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_"`
|
||||||
SPI ToolConfig `json:"spi" envPrefix:"PICOCLAW_TOOLS_SPI_"`
|
SPI ToolConfig `json:"spi" envPrefix:"PICOCLAW_TOOLS_SPI_"`
|
||||||
|
|
@ -728,6 +803,7 @@ 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"`
|
||||||
// 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"`
|
||||||
}
|
}
|
||||||
|
|
@ -927,6 +1003,29 @@ func (c *Config) ValidateModelList() error {
|
||||||
return nil
|
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 {
|
func (t *ToolsConfig) IsToolEnabled(name string) bool {
|
||||||
switch name {
|
switch name {
|
||||||
case "web":
|
case "web":
|
||||||
|
|
|
||||||
|
|
@ -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"`
|
|
||||||
}
|
|
||||||
|
|
@ -461,7 +461,7 @@ func DefaultConfig() *Config {
|
||||||
Message: ToolConfig{
|
Message: ToolConfig{
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
},
|
},
|
||||||
ReadFile: ToolConfig{
|
ReadFile: ReadFileToolConfig{
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
},
|
},
|
||||||
Spawn: ToolConfig{
|
Spawn: ToolConfig{
|
||||||
|
|
|
||||||
|
|
@ -181,10 +181,8 @@ func buildParams(
|
||||||
}
|
}
|
||||||
for _, tc := range msg.ToolCalls {
|
for _, tc := range msg.ToolCalls {
|
||||||
args := tc.Arguments
|
args := tc.Arguments
|
||||||
if args == nil && tc.Function != nil && tc.Function.Arguments != "" {
|
if args == nil && tc.Function != nil && len(tc.Function.Arguments) > 0 {
|
||||||
if err := json.Unmarshal([]byte(tc.Function.Arguments), &args); err != nil {
|
args = tc.Function.Arguments
|
||||||
args = map[string]any{}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if args == nil {
|
if args == nil {
|
||||||
args = map[string]any{}
|
args = map[string]any{}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,77 @@ type Tool interface {
|
||||||
Execute(ctx context.Context, args map[string]any) *ToolResult
|
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 {
|
func ToolToSchema(tool Tool) map[string]any {
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
"type": "function",
|
"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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -2,18 +2,22 @@ package tools
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
)
|
)
|
||||||
|
|
||||||
// NormalizeToolName keeps only lowercase ASCII letters.
|
// NormalizeToolName keeps only lowercase ASCII letters.
|
||||||
|
|
||||||
// "read_file" → "readfile", "ReadFile" → "readfile", "read-file" → "readfile".
|
// "read_file" → "readfile", "ReadFile" → "readfile", "read-file" → "readfile".
|
||||||
|
|
||||||
func NormalizeToolName(s string) string {
|
func NormalizeToolName(s string) string {
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
|
|
||||||
for _, r := range s {
|
for _, r := range s {
|
||||||
if r >= 'A' && r <= 'Z' {
|
if r >= 'A' && r <= 'Z' {
|
||||||
b.WriteRune(r + 32)
|
b.WriteRune(r + 32)
|
||||||
|
|
@ -21,48 +25,154 @@ func NormalizeToolName(s string) string {
|
||||||
b.WriteRune(r)
|
b.WriteRune(r)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
type ToolRegistry struct {
|
type ToolEntry struct {
|
||||||
tools map[string]Tool
|
Tool Tool
|
||||||
|
IsCore bool
|
||||||
|
TTL int
|
||||||
|
}
|
||||||
|
|
||||||
|
type ToolRegistry struct {
|
||||||
|
tools map[string]*ToolEntry
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
|
version atomic.Uint64 // incremented on Register/RegisterHidden for cache invalidation
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewToolRegistry() *ToolRegistry {
|
func NewToolRegistry() *ToolRegistry {
|
||||||
return &ToolRegistry{
|
return &ToolRegistry{
|
||||||
tools: make(map[string]Tool),
|
tools: make(map[string]*ToolEntry),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ToolRegistry) Register(tool Tool) {
|
func (r *ToolRegistry) Register(tool Tool) {
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
|
|
||||||
defer r.mu.Unlock()
|
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) {
|
func (r *ToolRegistry) Get(name string) (Tool, bool) {
|
||||||
r.mu.RLock()
|
r.mu.RLock()
|
||||||
|
|
||||||
defer r.mu.RUnlock()
|
defer r.mu.RUnlock()
|
||||||
|
|
||||||
// Exact match first
|
// Exact match first
|
||||||
|
if entry, ok := r.tools[name]; ok {
|
||||||
if tool, ok := r.tools[name]; ok {
|
// Hidden tools with expired TTL are not callable.
|
||||||
return tool, true
|
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)
|
norm := NormalizeToolName(name)
|
||||||
|
for _, entry := range r.tools {
|
||||||
for _, tool := range r.tools {
|
if entry.IsCore || entry.TTL > 0 {
|
||||||
if NormalizeToolName(tool.Name()) == norm {
|
if NormalizeToolName(entry.Tool.Name()) == norm {
|
||||||
return tool, true
|
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)
|
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.
|
// sortedToolNames returns tool names in sorted order for deterministic iteration.
|
||||||
|
|
||||||
// This is critical for KV cache stability: non-deterministic map iteration would
|
// This is critical for KV cache stability: non-deterministic map iteration would
|
||||||
|
|
||||||
// produce different system prompts and tool definitions on each call, invalidating
|
// produce different system prompts and tool definitions on each call, invalidating
|
||||||
|
|
||||||
// the LLM's prefix cache even when no tools have changed.
|
// the LLM's prefix cache even when no tools have changed.
|
||||||
|
|
||||||
func (r *ToolRegistry) sortedToolNames() []string {
|
func (r *ToolRegistry) sortedToolNames() []string {
|
||||||
names := make([]string, 0, len(r.tools))
|
names := make([]string, 0, len(r.tools))
|
||||||
|
|
||||||
for name := range r.tools {
|
for name := range r.tools {
|
||||||
names = append(names, name)
|
names = append(names, name)
|
||||||
}
|
}
|
||||||
|
|
||||||
sort.Strings(names)
|
sort.Strings(names)
|
||||||
|
|
||||||
return names
|
return names
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ToolRegistry) GetDefinitions() []map[string]any {
|
func (r *ToolRegistry) GetDefinitions() []map[string]any {
|
||||||
r.mu.RLock()
|
r.mu.RLock()
|
||||||
|
|
||||||
defer r.mu.RUnlock()
|
defer r.mu.RUnlock()
|
||||||
|
|
||||||
sorted := r.sortedToolNames()
|
sorted := r.sortedToolNames()
|
||||||
|
|
||||||
definitions := make([]map[string]any, 0, len(sorted))
|
definitions := make([]map[string]any, 0, len(sorted))
|
||||||
|
|
||||||
for _, name := range 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
|
return definitions
|
||||||
}
|
}
|
||||||
|
|
||||||
// List returns a list of all registered tool names.
|
// List returns a list of all registered tool names.
|
||||||
|
|
||||||
func (r *ToolRegistry) List() []string {
|
func (r *ToolRegistry) List() []string {
|
||||||
r.mu.RLock()
|
r.mu.RLock()
|
||||||
|
|
||||||
defer r.mu.RUnlock()
|
defer r.mu.RUnlock()
|
||||||
|
|
||||||
return r.sortedToolNames()
|
return r.sortedToolNames()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Count returns the number of registered tools.
|
// Count returns the number of registered tools.
|
||||||
|
|
||||||
func (r *ToolRegistry) Count() int {
|
func (r *ToolRegistry) Count() int {
|
||||||
r.mu.RLock()
|
r.mu.RLock()
|
||||||
|
|
||||||
defer r.mu.RUnlock()
|
defer r.mu.RUnlock()
|
||||||
|
|
||||||
return len(r.tools)
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,186 +1,21 @@
|
||||||
package tools
|
package tools
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"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.
|
// --- Fork-only registry extensions ---
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetRuntimeStatus aggregates runtime status from all tools that implement StatusProvider.
|
// GetRuntimeStatus aggregates runtime status from all tools that implement StatusProvider.
|
||||||
|
|
||||||
// Returns empty string if no tool has status to report.
|
// Returns empty string if no tool has status to report.
|
||||||
|
|
||||||
func (r *ToolRegistry) GetRuntimeStatus() string {
|
func (r *ToolRegistry) GetRuntimeStatus() string {
|
||||||
r.mu.RLock()
|
r.mu.RLock()
|
||||||
|
|
||||||
defer r.mu.RUnlock()
|
defer r.mu.RUnlock()
|
||||||
|
|
||||||
var parts []string
|
var parts []string
|
||||||
|
for _, entry := range r.tools {
|
||||||
for _, tool := range r.tools {
|
if sp, ok := entry.Tool.(StatusProvider); ok {
|
||||||
if sp, ok := tool.(StatusProvider); ok {
|
|
||||||
if s := sp.RuntimeStatus(); s != "" {
|
if s := sp.RuntimeStatus(); s != "" {
|
||||||
parts = append(parts, s)
|
parts = append(parts, s)
|
||||||
}
|
}
|
||||||
|
|
@ -195,44 +30,34 @@ func (r *ToolRegistry) GetRuntimeStatus() string {
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildParamHint extracts parameter names from a JSON schema and returns
|
// buildParamHint extracts parameter names from a JSON schema and returns
|
||||||
|
|
||||||
// a hint string like "(task, label?, preset?)". Required params are bare,
|
// a hint string like "(task, label?, preset?)". Required params are bare,
|
||||||
|
|
||||||
// optional params have a trailing "?".
|
// optional params have a trailing "?".
|
||||||
|
|
||||||
func buildParamHint(schema map[string]any) string {
|
func buildParamHint(schema map[string]any) string {
|
||||||
props, _ := schema["properties"].(map[string]any)
|
props, _ := schema["properties"].(map[string]any)
|
||||||
|
|
||||||
if len(props) == 0 {
|
if len(props) == 0 {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
reqSlice, _ := schema["required"].([]string)
|
reqSlice, _ := schema["required"].([]string)
|
||||||
|
|
||||||
reqSet := make(map[string]bool, len(reqSlice))
|
reqSet := make(map[string]bool, len(reqSlice))
|
||||||
|
|
||||||
for _, r := range reqSlice {
|
for _, r := range reqSlice {
|
||||||
reqSet[r] = true
|
reqSet[r] = true
|
||||||
}
|
}
|
||||||
|
|
||||||
names := make([]string, 0, len(props))
|
names := make([]string, 0, len(props))
|
||||||
|
|
||||||
for name := range props {
|
for name := range props {
|
||||||
names = append(names, name)
|
names = append(names, name)
|
||||||
}
|
}
|
||||||
|
|
||||||
sort.Strings(names)
|
sort.Strings(names)
|
||||||
|
|
||||||
parts := make([]string, 0, len(names))
|
parts := make([]string, 0, len(names))
|
||||||
|
|
||||||
// Required params first, then optional
|
// Required params first, then optional
|
||||||
|
|
||||||
for _, name := range names {
|
for _, name := range names {
|
||||||
if reqSet[name] {
|
if reqSet[name] {
|
||||||
parts = append(parts, name)
|
parts = append(parts, name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, name := range names {
|
for _, name := range names {
|
||||||
if !reqSet[name] {
|
if !reqSet[name] {
|
||||||
parts = append(parts, name+"?")
|
parts = append(parts, name+"?")
|
||||||
|
|
@ -241,27 +66,3 @@ func buildParamHint(schema map[string]any) string {
|
||||||
|
|
||||||
return "(" + strings.Join(parts, ", ") + ")"
|
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
|
|
||||||
}
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue