diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 995c5720b4..c9efb318bc 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -85,6 +85,7 @@ type processOptions struct { DefaultResponse string // Response when LLM returns empty EnableSummary bool // Whether to trigger summarization SendResponse bool // Whether to send response via bus + SuppressToolFeedback bool // Whether to suppress inline tool feedback messages NoHistory bool // If true, don't load session history (for heartbeat) SkipInitialSteeringPoll bool // If true, skip the steering poll at loop start (used by Continue) } @@ -1242,14 +1243,15 @@ func (al *AgentLoop) ProcessHeartbeat( return "", fmt.Errorf("no default agent for heartbeat") } return al.runAgentLoop(ctx, agent, processOptions{ - SessionKey: "heartbeat", - Channel: channel, - ChatID: chatID, - UserMessage: content, - DefaultResponse: defaultResponse, - EnableSummary: false, - SendResponse: false, - NoHistory: true, // Don't load session history for heartbeat + SessionKey: "heartbeat", + Channel: channel, + ChatID: chatID, + UserMessage: content, + DefaultResponse: defaultResponse, + EnableSummary: false, + SendResponse: false, + SuppressToolFeedback: true, + NoHistory: true, // Don't load session history for heartbeat }) } @@ -2305,7 +2307,9 @@ turnLoop: ) // Send tool feedback to chat channel if enabled (from HEAD) - if al.cfg.Agents.Defaults.IsToolFeedbackEnabled() && ts.channel != "" { + if al.cfg.Agents.Defaults.IsToolFeedbackEnabled() && + ts.channel != "" && + !ts.opts.SuppressToolFeedback { feedbackPreview := utils.Truncate( string(argsJSON), al.cfg.Agents.Defaults.GetToolFeedbackMaxArgsLength(), diff --git a/pkg/agent/loop_test.go b/pkg/agent/loop_test.go index e0a5dffb3c..341d15a2fa 100644 --- a/pkg/agent/loop_test.go +++ b/pkg/agent/loop_test.go @@ -1018,6 +1018,40 @@ func (m *artifactThenSendProvider) GetDefaultModel() string { return "artifact-then-send-model" } +type toolFeedbackProvider struct { + filePath string + calls int +} + +func (m *toolFeedbackProvider) Chat( + ctx context.Context, + messages []providers.Message, + tools []providers.ToolDefinition, + model string, + opts map[string]any, +) (*providers.LLMResponse, error) { + m.calls++ + if m.calls == 1 { + return &providers.LLMResponse{ + ToolCalls: []providers.ToolCall{{ + ID: "call_heartbeat_read_file", + Type: "function", + Name: "read_file", + Arguments: map[string]any{"path": m.filePath}, + }}, + }, nil + } + + return &providers.LLMResponse{ + Content: "HEARTBEAT_OK", + ToolCalls: []providers.ToolCall{}, + }, nil +} + +func (m *toolFeedbackProvider) GetDefaultModel() string { + return "heartbeat-tool-feedback-model" +} + type toolLimitOnlyProvider struct{} func (m *toolLimitOnlyProvider) Chat( @@ -2313,6 +2347,112 @@ func TestProcessMessage_PublishesReasoningContentToReasoningChannel(t *testing.T } } +func TestProcessHeartbeat_DoesNotPublishToolFeedback(t *testing.T) { + tmpDir := t.TempDir() + heartbeatFile := filepath.Join(tmpDir, "heartbeat-task.txt") + if err := os.WriteFile(heartbeatFile, []byte("heartbeat task"), 0o644); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + ToolFeedback: config.ToolFeedbackConfig{ + Enabled: true, + MaxArgsLength: 300, + }, + }, + }, + Tools: config.ToolsConfig{ + ReadFile: config.ReadFileToolConfig{ + Enabled: true, + }, + }, + } + + msgBus := bus.NewMessageBus() + provider := &toolFeedbackProvider{filePath: heartbeatFile} + al := NewAgentLoop(cfg, msgBus, provider) + + response, err := al.ProcessHeartbeat(context.Background(), "check heartbeat tasks", "telegram", "chat-1") + if err != nil { + t.Fatalf("ProcessHeartbeat() error = %v", err) + } + if response != "HEARTBEAT_OK" { + t.Fatalf("ProcessHeartbeat() response = %q, want %q", response, "HEARTBEAT_OK") + } + + select { + case outbound := <-msgBus.OutboundChan(): + t.Fatalf("expected no outbound tool feedback during heartbeat, got %+v", outbound) + case <-time.After(200 * time.Millisecond): + } +} + +func TestProcessMessage_PublishesToolFeedbackWhenEnabled(t *testing.T) { + tmpDir := t.TempDir() + heartbeatFile := filepath.Join(tmpDir, "tool-feedback.txt") + if err := os.WriteFile(heartbeatFile, []byte("tool feedback task"), 0o644); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + ToolFeedback: config.ToolFeedbackConfig{ + Enabled: true, + MaxArgsLength: 300, + }, + }, + }, + Tools: config.ToolsConfig{ + ReadFile: config.ReadFileToolConfig{ + Enabled: true, + }, + }, + } + + msgBus := bus.NewMessageBus() + provider := &toolFeedbackProvider{filePath: heartbeatFile} + al := NewAgentLoop(cfg, msgBus, provider) + + response, err := al.processMessage(context.Background(), bus.InboundMessage{ + Channel: "telegram", + SenderID: "user-1", + ChatID: "chat-1", + Content: "check tool feedback", + }) + if err != nil { + t.Fatalf("processMessage() error = %v", err) + } + if response != "HEARTBEAT_OK" { + t.Fatalf("processMessage() response = %q, want %q", response, "HEARTBEAT_OK") + } + + select { + case outbound := <-msgBus.OutboundChan(): + if outbound.Channel != "telegram" { + t.Fatalf("tool feedback channel = %q, want %q", outbound.Channel, "telegram") + } + if outbound.ChatID != "chat-1" { + t.Fatalf("tool feedback chatID = %q, want %q", outbound.ChatID, "chat-1") + } + if !strings.Contains(outbound.Content, "`read_file`") { + t.Fatalf("tool feedback content = %q, want read_file preview", outbound.Content) + } + case <-time.After(2 * time.Second): + t.Fatal("expected outbound tool feedback for regular messages") + } +} + func TestResolveMediaRefs_ResolvesToBase64(t *testing.T) { store := media.NewFileMediaStore() dir := t.TempDir()