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.
This commit is contained in:
parent
026a1339c7
commit
f70f0fc7ee
4 changed files with 129 additions and 3 deletions
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue