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,
|
||||
// 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":
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
Enabled: true,
|
||||
},
|
||||
ReadFile: ToolConfig{
|
||||
ReadFile: ReadFileToolConfig{
|
||||
Enabled: true,
|
||||
},
|
||||
Spawn: ToolConfig{
|
||||
|
|
|
|||
|
|
@ -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{}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
"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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue