From f70f0fc7ee90facbdd67284fb2370ba97d9a35f3 Mon Sep 17 00:00:00 2001 From: Martin Barth Date: Sat, 28 Mar 2026 13:38:59 +0100 Subject: [PATCH] feat(agent): add silent_processing mode When silent_processing is true in agents.defaults, the agent runs fully (tool calls, memory writes) but suppresses the empty-response fallback when the LLM produces no text. The channel layer skips sending empty content, so nothing reaches the user. Explicit LLM text is sent as normal. Also skips writing empty assistant messages to session history, which would cause provider errors on the next turn. --- pkg/agent/instance.go | 5 ++ pkg/agent/loop.go | 14 ++++-- pkg/agent/loop_test.go | 112 +++++++++++++++++++++++++++++++++++++++++ pkg/config/config.go | 1 + 4 files changed, 129 insertions(+), 3 deletions(-) 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