diff --git a/config/config.example.json b/config/config.example.json index 094aa46df..3d94347fc 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -305,6 +305,7 @@ "tools": { "allow_read_paths": null, "allow_write_paths": null, + "max_tools": 128, "web": { "enabled": true, "brave": { diff --git a/pkg/agent/instance.go b/pkg/agent/instance.go index 0c7baa1ee..eab692417 100644 --- a/pkg/agent/instance.go +++ b/pkg/agent/instance.go @@ -70,6 +70,9 @@ func NewAgentInstance( allowWritePaths := compilePatterns(cfg.Tools.AllowWritePaths) toolsRegistry := tools.NewToolRegistry() + if cfg.Tools.MaxTools > 0 { + toolsRegistry.SetMaxTools(cfg.Tools.MaxTools) + } if cfg.Tools.IsToolEnabled("read_file") { maxReadFileSize := cfg.Tools.ReadFile.MaxReadFileSize diff --git a/pkg/config/config.go b/pkg/config/config.go index 190341224..9ca388ccd 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -732,6 +732,7 @@ type ReadFileToolConfig struct { type ToolsConfig struct { AllowReadPaths []string `json:"allow_read_paths" env:"PICOCLAW_TOOLS_ALLOW_READ_PATHS"` AllowWritePaths []string `json:"allow_write_paths" env:"PICOCLAW_TOOLS_ALLOW_WRITE_PATHS"` + MaxTools int `json:"max_tools" env:"PICOCLAW_TOOLS_MAX_TOOLS"` Web WebToolsConfig `json:"web"` Cron CronToolsConfig `json:"cron"` Exec ExecConfig `json:"exec"` diff --git a/pkg/tools/registry.go b/pkg/tools/registry.go index 0635f47d7..36149cd55 100644 --- a/pkg/tools/registry.go +++ b/pkg/tools/registry.go @@ -19,17 +19,36 @@ type ToolEntry struct { } type ToolRegistry struct { - tools map[string]*ToolEntry - mu sync.RWMutex - version atomic.Uint64 // incremented on Register/RegisterHidden for cache invalidation + tools map[string]*ToolEntry + mu sync.RWMutex + version atomic.Uint64 // incremented on Register/RegisterHidden for cache invalidation + maxTools int // 0 means use DefaultMaxTools } +const DefaultMaxTools = 128 + func NewToolRegistry() *ToolRegistry { return &ToolRegistry{ tools: make(map[string]*ToolEntry), } } +// SetMaxTools configures the maximum number of tool definitions returned by +// ToProviderDefs. This prevents exceeding LLM API tool array limits. +// A value of 0 or negative means use DefaultMaxTools. +func (r *ToolRegistry) SetMaxTools(n int) { + r.mu.Lock() + defer r.mu.Unlock() + r.maxTools = n +} + +func (r *ToolRegistry) getMaxTools() int { + if r.maxTools > 0 { + return r.maxTools + } + return DefaultMaxTools +} + func (r *ToolRegistry) Register(tool Tool) { r.mu.Lock() defer r.mu.Unlock() @@ -258,41 +277,77 @@ func (r *ToolRegistry) GetDefinitions() []map[string]any { // ToProviderDefs converts tool definitions to provider-compatible format. // This is the format expected by LLM provider APIs. +// The result is capped at maxTools (default 128) to respect LLM API limits. +// Built-in tools are prioritized over MCP tools when truncation is needed. 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 - } + limit := r.getMaxTools() + toProviderDef := func(entry *ToolEntry) (providers.ToolDefinition, bool) { schema := ToolToSchema(entry.Tool) - - // Safely extract nested values with type checks fn, ok := schema["function"].(map[string]any) if !ok { - continue + return providers.ToolDefinition{}, false } - name, _ := fn["name"].(string) desc, _ := fn["description"].(string) params, _ := fn["parameters"].(map[string]any) - - definitions = append(definitions, providers.ToolDefinition{ + return providers.ToolDefinition{ Type: "function", Function: providers.ToolFunctionDefinition{ Name: name, Description: desc, Parameters: params, }, - }) + }, true } - return definitions + + // Two-pass approach: built-in tools first, then MCP tools. + var builtinDefs, mcpDefs []providers.ToolDefinition + for _, name := range sorted { + entry := r.tools[name] + if !entry.IsCore && entry.TTL <= 0 { + continue + } + def, ok := toProviderDef(entry) + if !ok { + continue + } + if _, isMCP := entry.Tool.(*MCPTool); isMCP { + mcpDefs = append(mcpDefs, def) + } else { + builtinDefs = append(builtinDefs, def) + } + } + + total := len(builtinDefs) + len(mcpDefs) + if total <= limit { + return append(builtinDefs, mcpDefs...) + } + + // Truncation needed: keep all built-in tools, trim MCP tools. + remaining := limit - len(builtinDefs) + if remaining < 0 { + remaining = 0 + } + dropped := len(mcpDefs) - remaining + if remaining < len(mcpDefs) { + mcpDefs = mcpDefs[:remaining] + } + + logger.WarnCF("tools", "Tool count exceeds limit, MCP tools truncated", + map[string]any{ + "total": total, + "limit": limit, + "builtin": len(builtinDefs), + "mcp_kept": len(mcpDefs), + "dropped": dropped, + }) + + return append(builtinDefs, mcpDefs...) } // List returns a list of all registered tool names.