diff --git a/config/config.example.json b/config/config.example.json index 77a8c0683..cd75afa83 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -6,7 +6,8 @@ "model": "gpt4", "max_tokens": 8192, "temperature": 0.7, - "max_tool_iterations": 20 + "max_tool_iterations": 20, + "enable_tools": true } }, "model_list": [ diff --git a/pkg/agent/instance.go b/pkg/agent/instance.go index dfbef9fbc..2a743b918 100644 --- a/pkg/agent/instance.go +++ b/pkg/agent/instance.go @@ -24,6 +24,7 @@ type AgentInstance struct { MaxTokens int Temperature float64 ContextWindow int + EnableTools bool Provider providers.LLMProvider Sessions *session.SessionManager ContextBuilder *ContextBuilder @@ -45,6 +46,7 @@ func NewAgentInstance( model := resolveAgentModel(agentCfg, defaults) fallbacks := resolveAgentFallbacks(agentCfg, defaults) + enableTools := resolveAgentEnableTools(agentCfg, defaults) restrict := defaults.RestrictToWorkspace toolsRegistry := tools.NewToolRegistry() @@ -105,6 +107,7 @@ func NewAgentInstance( MaxTokens: maxTokens, Temperature: temperature, ContextWindow: maxTokens, + EnableTools: enableTools, Provider: provider, Sessions: sessionsManager, ContextBuilder: contextBuilder, @@ -144,6 +147,18 @@ func resolveAgentFallbacks(agentCfg *config.AgentConfig, defaults *config.AgentD return defaults.ModelFallbacks } +// resolveAgentEnableTools resolves whether tool calling is enabled for an agent. +func resolveAgentEnableTools(agentCfg *config.AgentConfig, defaults *config.AgentDefaults) bool { + enabled := true + if defaults != nil && defaults.EnableTools != nil { + enabled = *defaults.EnableTools + } + if agentCfg != nil && agentCfg.EnableTools != nil { + enabled = *agentCfg.EnableTools + } + return enabled +} + func expandHome(path string) string { if path == "" { return path diff --git a/pkg/agent/instance_test.go b/pkg/agent/instance_test.go index fcc8e9bea..865061ada 100644 --- a/pkg/agent/instance_test.go +++ b/pkg/agent/instance_test.go @@ -93,3 +93,92 @@ func TestNewAgentInstance_DefaultsTemperatureWhenUnset(t *testing.T) { t.Fatalf("Temperature = %f, want %f", agent.Temperature, 0.7) } } + +func TestNewAgentInstance_EnableToolsDefaultsToTrue(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agent-instance-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + Model: "test-model", + MaxTokens: 1234, + MaxToolIterations: 5, + }, + }, + } + + provider := &mockProvider{} + agent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, provider) + + if !agent.EnableTools { + t.Fatal("EnableTools = false, want true") + } +} + +func TestNewAgentInstance_EnableToolsFromDefaults(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agent-instance-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + disabled := false + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + Model: "test-model", + MaxTokens: 1234, + MaxToolIterations: 5, + EnableTools: &disabled, + }, + }, + } + + provider := &mockProvider{} + agent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, provider) + + if agent.EnableTools { + t.Fatal("EnableTools = true, want false") + } +} + +func TestNewAgentInstance_EnableToolsAgentOverride(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agent-instance-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + disabled := false + enabled := true + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + Model: "test-model", + MaxTokens: 1234, + MaxToolIterations: 5, + EnableTools: &disabled, + }, + }, + } + + agentCfg := &config.AgentConfig{ + ID: "main", + Default: true, + EnableTools: &enabled, + } + + provider := &mockProvider{} + agent := NewAgentInstance(agentCfg, &cfg.Agents.Defaults, cfg, provider) + + if !agent.EnableTools { + t.Fatal("EnableTools = false, want true") + } +} diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index b36f4a0c4..aa61db3a1 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -486,8 +486,11 @@ func (al *AgentLoop) runLLMIteration( "max": agent.MaxIterations, }) - // Build tool definitions - providerToolDefs := agent.Tools.ToProviderDefs() + // Build tool definitions (chat-only mode omits tools entirely). + var providerToolDefs []providers.ToolDefinition + if agent.EnableTools { + providerToolDefs = agent.Tools.ToProviderDefs() + } // Log LLM request details logger.DebugCF("agent", "LLM request", @@ -497,6 +500,7 @@ func (al *AgentLoop) runLLMIteration( "model": agent.Model, "messages_count": len(messages), "tools_count": len(providerToolDefs), + "tools_enabled": agent.EnableTools, "max_tokens": agent.MaxTokens, "temperature": agent.Temperature, "system_prompt_len": len(messages[0].Content), @@ -590,6 +594,25 @@ func (al *AgentLoop) runLLMIteration( return "", iteration, fmt.Errorf("LLM call failed after retries: %w", err) } + if !agent.EnableTools { + if len(response.ToolCalls) > 0 { + logger.WarnCF("agent", "Ignoring tool calls in chat-only mode", + map[string]any{ + "agent_id": agent.ID, + "iteration": iteration, + "tool_calls_received": len(response.ToolCalls), + }) + } + finalContent = response.Content + logger.InfoCF("agent", "LLM response in chat-only mode", + map[string]any{ + "agent_id": agent.ID, + "iteration": iteration, + "content_chars": len(finalContent), + }) + break + } + // Check if no tool calls - we're done if len(response.ToolCalls) == 0 { finalContent = response.Content diff --git a/pkg/agent/loop_test.go b/pkg/agent/loop_test.go index 4414398b1..d0cb1c154 100644 --- a/pkg/agent/loop_test.go +++ b/pkg/agent/loop_test.go @@ -366,6 +366,29 @@ func (m *simpleMockProvider) GetDefaultModel() string { return "mock-model" } +type captureToolsProvider struct { + response string + lastToolsCount int +} + +func (m *captureToolsProvider) Chat( + ctx context.Context, + messages []providers.Message, + tools []providers.ToolDefinition, + model string, + opts map[string]any, +) (*providers.LLMResponse, error) { + m.lastToolsCount = len(tools) + return &providers.LLMResponse{ + Content: m.response, + ToolCalls: []providers.ToolCall{}, + }, nil +} + +func (m *captureToolsProvider) GetDefaultModel() string { + return "mock-capture-model" +} + // mockCustomTool is a simple mock tool for registration testing type mockCustomTool struct{} @@ -437,6 +460,92 @@ func (h testHelper) executeAndGetResponse(tb testing.TB, ctx context.Context, ms const responseTimeout = 3 * time.Second +func TestAgentLoop_ChatOnlyMode_DoesNotPassTools(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agent-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + disabled := false + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + Model: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + EnableTools: &disabled, + }, + }, + } + + msgBus := bus.NewMessageBus() + provider := &captureToolsProvider{response: "chat-only"} + al := NewAgentLoop(cfg, msgBus, provider) + + response, err := al.ProcessDirectWithChannel( + context.Background(), + "hello", + "chat-only-session", + "test", + "chat", + ) + if err != nil { + t.Fatalf("ProcessDirectWithChannel() error = %v", err) + } + + if response != "chat-only" { + t.Fatalf("response = %q, want %q", response, "chat-only") + } + if provider.lastToolsCount != 0 { + t.Fatalf("lastToolsCount = %d, want 0", provider.lastToolsCount) + } +} + +func TestAgentLoop_ToolsEnabled_PassesTools(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agent-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + enabled := true + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + Model: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + EnableTools: &enabled, + }, + }, + } + + msgBus := bus.NewMessageBus() + provider := &captureToolsProvider{response: "with-tools"} + al := NewAgentLoop(cfg, msgBus, provider) + + response, err := al.ProcessDirectWithChannel( + context.Background(), + "hello", + "with-tools-session", + "test", + "chat", + ) + if err != nil { + t.Fatalf("ProcessDirectWithChannel() error = %v", err) + } + + if response != "with-tools" { + t.Fatalf("response = %q, want %q", response, "with-tools") + } + if provider.lastToolsCount == 0 { + t.Fatal("lastToolsCount = 0, want > 0") + } +} + // TestToolResult_SilentToolDoesNotSendUserMessage verifies silent tools don't trigger outbound func TestToolResult_SilentToolDoesNotSendUserMessage(t *testing.T) { tmpDir, err := os.MkdirTemp("", "agent-test-*") diff --git a/pkg/config/config.go b/pkg/config/config.go index 20556011a..319169f07 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -134,6 +134,7 @@ type AgentConfig struct { Name string `json:"name,omitempty"` Workspace string `json:"workspace,omitempty"` Model *AgentModelConfig `json:"model,omitempty"` + EnableTools *bool `json:"enable_tools,omitempty"` Skills []string `json:"skills,omitempty"` Subagents *SubagentsConfig `json:"subagents,omitempty"` } @@ -177,6 +178,7 @@ type AgentDefaults struct { MaxTokens int `json:"max_tokens" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOKENS"` Temperature *float64 `json:"temperature,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_TEMPERATURE"` MaxToolIterations int `json:"max_tool_iterations" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS"` + EnableTools *bool `json:"enable_tools,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_ENABLE_TOOLS"` } type ChannelsConfig struct { diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 0898217d6..f2050b788 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -69,14 +69,16 @@ func TestAgentConfig_FullParse(t *testing.T) { "workspace": "~/.picoclaw/workspace", "model": "glm-4.7", "max_tokens": 8192, - "max_tool_iterations": 20 + "max_tool_iterations": 20, + "enable_tools": true }, "list": [ { "id": "sales", "default": true, "name": "Sales Bot", - "model": "gpt-4" + "model": "gpt-4", + "enable_tools": false }, { "id": "support", @@ -125,6 +127,9 @@ func TestAgentConfig_FullParse(t *testing.T) { if sales.Model == nil || sales.Model.Primary != "gpt-4" { t.Errorf("sales.Model = %+v", sales.Model) } + if sales.EnableTools == nil || *sales.EnableTools { + t.Errorf("sales.EnableTools = %v, want false", sales.EnableTools) + } support := cfg.Agents.List[1] if support.ID != "support" || support.Name != "Support Bot" { @@ -161,6 +166,9 @@ func TestAgentConfig_FullParse(t *testing.T) { if len(links) != 2 { t.Errorf("john links = %v", links) } + if cfg.Agents.Defaults.EnableTools == nil || !*cfg.Agents.Defaults.EnableTools { + t.Errorf("Agents.Defaults.EnableTools = %v, want true", cfg.Agents.Defaults.EnableTools) + } } func TestConfig_BackwardCompat_NoAgentsList(t *testing.T) { @@ -186,6 +194,9 @@ func TestConfig_BackwardCompat_NoAgentsList(t *testing.T) { if len(cfg.Bindings) != 0 { t.Errorf("bindings should be empty, got %d", len(cfg.Bindings)) } + if cfg.Agents.Defaults.EnableTools == nil || !*cfg.Agents.Defaults.EnableTools { + t.Errorf("Agents.Defaults.EnableTools = %v, want true", cfg.Agents.Defaults.EnableTools) + } } // TestDefaultConfig_HeartbeatEnabled verifies heartbeat is enabled by default @@ -233,6 +244,17 @@ func TestDefaultConfig_MaxToolIterations(t *testing.T) { } } +func TestDefaultConfig_EnableTools(t *testing.T) { + cfg := DefaultConfig() + + if cfg.Agents.Defaults.EnableTools == nil { + t.Fatal("EnableTools should not be nil") + } + if !*cfg.Agents.Defaults.EnableTools { + t.Error("EnableTools should default to true") + } +} + // TestDefaultConfig_Temperature verifies temperature has default value func TestDefaultConfig_Temperature(t *testing.T) { cfg := DefaultConfig() diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go index 7654326e7..8cdc496b9 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -17,6 +17,7 @@ func DefaultConfig() *Config { MaxTokens: 8192, Temperature: nil, // nil means use provider default MaxToolIterations: 20, + EnableTools: boolPtr(true), }, }, Bindings: []AgentBinding{}, @@ -314,3 +315,7 @@ func DefaultConfig() *Config { }, } } + +func boolPtr(v bool) *bool { + return &v +} diff --git a/pkg/migrate/config.go b/pkg/migrate/config.go index 2237a1429..31c573c2f 100644 --- a/pkg/migrate/config.go +++ b/pkg/migrate/config.go @@ -84,6 +84,9 @@ func ConvertConfig(data map[string]any) (*config.Config, []string, error) { if v, ok := getFloat(defaults, "max_tool_iterations"); ok { cfg.Agents.Defaults.MaxToolIterations = int(v) } + if v, ok := getBool(defaults, "enable_tools"); ok { + cfg.Agents.Defaults.EnableTools = &v + } if v, ok := getString(defaults, "workspace"); ok { cfg.Agents.Defaults.Workspace = rewriteWorkspacePath(v) }