feat(agent): add silent_processing mode

When silent_processing is true in agents.defaults, the agent runs fully
on every message (tool calls execute, session is flushed to disk) but
suppresses the empty-response fallback when the LLM produces no text.
The channel layer already skips sending empty content, so nothing reaches
the user. Explicit LLM text responses are sent as normal.

Empty assistant messages are not written to session history to avoid
provider errors on the next turn. The session itself is always saved so
the user message is not lost from history on silent turns.

Both the empty-response fallback and the tool-iteration-limit message are
suppressed in silent mode — a background observer agent must not surface
internal diagnostics to the channel.
This commit is contained in:
Martin Barth 2026-03-28 13:54:57 +01:00
parent f70f0fc7ee
commit a2aff99cbf
3 changed files with 78 additions and 4 deletions

View file

@ -144,6 +144,27 @@ You can also trigger by keyword prefixes (e.g. `!bot`):
}
```
**Optional: Silent observer mode**
To have the agent silently read all messages (e.g. for memory/context gathering) while only responding when explicitly addressed, use `agents.defaults.silent_processing` without `group_trigger.mention_only`. Setting `mention_only` would drop non-mention messages before the agent sees them, defeating the purpose:
```json
{
"agents": {
"defaults": {
"silent_processing": true
}
},
"channels": {
"discord": {
"allow_from": ["YOUR_USER_ID", "YOUR_SERVER_ID"]
}
}
}
```
The agent processes every message (tool calls, memory writes) but sends nothing when the LLM produces no text. Instruct the LLM via `AGENT.md` to only produce text when directly addressed.
**6. Run**
```bash

View file

@ -361,6 +361,57 @@ The `restrict_to_workspace` setting applies consistently across all execution pa
All paths share the same workspace restriction — there's no way to bypass the security boundary through subagents or scheduled tasks.
### Agent Response Behavior
#### `silent_processing`
When `silent_processing` is `true`, the agent runs fully on every inbound message — tool calls execute, session history is written, memory files are updated — but if the LLM produces no text output, nothing is sent to the channel. The automatic empty-response fallback message is suppressed.
If the LLM does produce text, it is sent as normal. `silent_processing` only suppresses the fallback, not intentional responses.
| Option | Default | Description |
|---|---|---|
| `silent_processing` | `false` | Suppress empty-response fallback; send nothing when LLM produces no text |
**Config:**
```json
{
"agents": {
"defaults": {
"silent_processing": true
}
}
}
```
**Environment variable:** `PICOCLAW_AGENTS_DEFAULTS_SILENT_PROCESSING=true`
**Use case — group observer agent:**
An agent added to a group chat that silently reads every message, uses `write_file` to maintain a memory log, and only responds when explicitly addressed. Do not set `group_trigger.mention_only` — that would drop non-mention messages before the agent sees them, defeating the purpose. Instead, instruct the LLM via `AGENT.md` to only produce text when addressed:
```json
{
"agents": {
"defaults": {
"silent_processing": true
}
},
"channels": {
"telegram": {
"allow_from": ["YOUR_USER_ID", "-YOUR_GROUP_ID"]
}
}
}
```
With this setup the agent processes every group message (for memory/analysis). When the LLM produces no text, `silent_processing` suppresses the fallback and nothing is sent. When the LLM does produce text (e.g. when @mentioned), it is sent as normal.
> **Note:** In silent mode, the tool-iteration-limit message is also suppressed. Background observer agents should not surface internal diagnostics to the channel.
---
### Heartbeat (Periodic Tasks)
PicoClaw can perform periodic tasks automatically. Create a `HEARTBEAT.md` file in your workspace:

View file

@ -2690,10 +2690,12 @@ turnLoop:
ts.setPhase(TurnPhaseFinalizing)
ts.setFinalContent(finalContent)
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)
if !ts.opts.NoHistory {
if finalContent != "" {
finalMsg := providers.Message{Role: "assistant", Content: finalContent}
ts.agent.Sessions.AddMessage(ts.sessionKey, finalMsg.Role, finalMsg.Content)
ts.recordPersistedMessage(finalMsg)
}
if err := ts.agent.Sessions.Save(ts.sessionKey); err != nil {
turnStatus = TurnEndStatusError
al.emitEvent(