diff --git a/pkg/agent/instance.go b/pkg/agent/instance.go index 880725660..4a37e3068 100644 --- a/pkg/agent/instance.go +++ b/pkg/agent/instance.go @@ -51,6 +51,10 @@ type AgentInstance struct { // LightProvider is the concrete provider instance for the configured light model. // It is only used when routing selects the light tier for a turn. LightProvider providers.LLMProvider + // SilentProcessing suppresses the automatic empty-response fallback when the + // LLM produces no text output (e.g. only tool calls). The agent still runs + // fully and sends a response when the LLM produces text. + SilentProcessing bool } // NewAgentInstance creates an agent instance from config. @@ -225,6 +229,7 @@ func NewAgentInstance( Router: router, LightCandidates: lightCandidates, LightProvider: lightProvider, + SilentProcessing: defaults.SilentProcessing, } } diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index db476c212..33de89df6 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -1311,6 +1311,10 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) "route_channel": route.Channel, }) + resolvedDefaultResponse := defaultResponse + if agent.SilentProcessing { + resolvedDefaultResponse = "" + } opts := processOptions{ SessionKey: sessionKey, Channel: msg.Channel, @@ -1319,7 +1323,7 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) SenderDisplayName: msg.Sender.DisplayName, UserMessage: msg.Content, Media: msg.Media, - DefaultResponse: defaultResponse, + DefaultResponse: resolvedDefaultResponse, EnableSummary: true, SendResponse: false, } @@ -2672,7 +2676,11 @@ turnLoop: return al.abortTurn(ts) } - if finalContent == "" { + if finalContent == "" && ts.opts.DefaultResponse != "" { + // In silent_processing mode DefaultResponse is "", so both the empty-response + // fallback and the tool-iteration-limit message are suppressed. This is + // intentional: a background observer agent must not surface internal + // diagnostics to the channel. if ts.currentIteration() >= ts.agent.MaxIterations && ts.agent.MaxIterations > 0 { finalContent = toolLimitResponse } else { @@ -2682,7 +2690,7 @@ turnLoop: ts.setPhase(TurnPhaseFinalizing) ts.setFinalContent(finalContent) - if !ts.opts.NoHistory { + if !ts.opts.NoHistory && finalContent != "" { finalMsg := providers.Message{Role: "assistant", Content: finalContent} ts.agent.Sessions.AddMessage(ts.sessionKey, finalMsg.Role, finalMsg.Content) ts.recordPersistedMessage(finalMsg) diff --git a/pkg/agent/loop_test.go b/pkg/agent/loop_test.go index 25d20c689..55a5aa2ed 100644 --- a/pkg/agent/loop_test.go +++ b/pkg/agent/loop_test.go @@ -3030,3 +3030,115 @@ func TestProcessMessage_ContextOverflow_AnthropicStyle(t *testing.T) { t.Fatalf("expected 2 calls for retry, got %d", provider.calls) } } + +// emptyProvider returns an empty LLM response (no text, no tool calls). +type emptyProvider struct{} + +func (e *emptyProvider) Chat( + ctx context.Context, + messages []providers.Message, + tools []providers.ToolDefinition, + model string, + opts map[string]any, +) (*providers.LLMResponse, error) { + return &providers.LLMResponse{Content: "", ToolCalls: []providers.ToolCall{}}, nil +} + +func (e *emptyProvider) GetDefaultModel() string { return "mock-model" } + +// TestSilentProcessing_EmptyResponseSuppressed verifies that when +// silent_processing is enabled, an empty LLM response does not produce the +// default error fallback — the agent returns "" so the channel sends nothing. +func TestSilentProcessing_EmptyResponseSuppressed(t *testing.T) { + tmpDir := t.TempDir() + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + SilentProcessing: true, + }, + }, + } + msgBus := bus.NewMessageBus() + al := NewAgentLoop(cfg, msgBus, &emptyProvider{}) + + response, err := al.processMessage(context.Background(), bus.InboundMessage{ + Channel: "telegram", + SenderID: "telegram:123", + ChatID: "group-1", + Content: "hello from the group", + }) + if err != nil { + t.Fatalf("processMessage() error = %v", err) + } + if response != "" { + t.Fatalf("processMessage() response = %q, want empty string in silent mode", response) + } +} + +// TestSilentProcessing_TextResponseStillSent verifies that when +// silent_processing is enabled, an LLM response that contains text is still +// returned normally — silent mode only suppresses the empty-response fallback. +func TestSilentProcessing_TextResponseStillSent(t *testing.T) { + tmpDir := t.TempDir() + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + SilentProcessing: true, + }, + }, + } + msgBus := bus.NewMessageBus() + al := NewAgentLoop(cfg, msgBus, &mockProvider{}) + + response, err := al.processMessage(context.Background(), bus.InboundMessage{ + Channel: "telegram", + SenderID: "telegram:123", + ChatID: "group-1", + Content: "hey @samanda what time is it?", + }) + if err != nil { + t.Fatalf("processMessage() error = %v", err) + } + if response != "Mock response" { + t.Fatalf("processMessage() response = %q, want %q", response, "Mock response") + } +} + +// TestDefaultMode_EmptyResponseGetsFallback verifies that without +// silent_processing the default error message is returned for empty LLM output. +func TestDefaultMode_EmptyResponseGetsFallback(t *testing.T) { + tmpDir := t.TempDir() + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + } + msgBus := bus.NewMessageBus() + al := NewAgentLoop(cfg, msgBus, &emptyProvider{}) + + response, err := al.processMessage(context.Background(), bus.InboundMessage{ + Channel: "telegram", + SenderID: "telegram:123", + ChatID: "chat-1", + Content: "hello", + }) + if err != nil { + t.Fatalf("processMessage() error = %v", err) + } + if response != defaultResponse { + t.Fatalf("processMessage() response = %q, want default fallback %q", response, defaultResponse) + } +} diff --git a/pkg/config/config.go b/pkg/config/config.go index 533f45a44..741afb82a 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -327,6 +327,7 @@ type AgentDefaults struct { SubTurn SubTurnConfig `json:"subturn" envPrefix:"PICOCLAW_AGENTS_DEFAULTS_SUBTURN_"` ToolFeedback ToolFeedbackConfig `json:"tool_feedback,omitempty"` SplitOnMarker bool `json:"split_on_marker" env:"PICOCLAW_AGENTS_DEFAULTS_SPLIT_ON_MARKER"` // split messages on <|[SPLIT]|> marker + SilentProcessing bool `json:"silent_processing,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_SILENT_PROCESSING"` } const DefaultMaxMediaSize = 20 * 1024 * 1024 // 20 MB