Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions charts/openab/templates/configmap.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,7 @@ data:
[reactions]
enabled = {{ if hasKey ($cfg.reactions) "enabled" }}{{ ($cfg.reactions).enabled }}{{ else }}true{{ end }}
remove_after_reply = {{ if hasKey ($cfg.reactions) "removeAfterReply" }}{{ ($cfg.reactions).removeAfterReply }}{{ else }}false{{ end }}
empty_reply_placeholder = {{ if hasKey ($cfg.reactions) "emptyReplyPlaceholder" }}{{ ($cfg.reactions).emptyReplyPlaceholder }}{{ else }}true{{ end }}
{{- if ($cfg.reactions).toolDisplay }}
{{- if not (has ($cfg.reactions).toolDisplay (list "full" "compact" "none")) }}
{{- fail (printf "agents.%s.reactions.toolDisplay must be one of: full, compact, none — got: %s" $name ($cfg.reactions).toolDisplay) }}
Expand Down
2 changes: 2 additions & 0 deletions charts/openab/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,8 @@ agents:
# compact: show count summary (e.g. ✅ 3 · 🔧 1 tool(s))
# full: show complete tool titles (for debugging)
# none: hide tool lines entirely
# emptyReplyPlaceholder: true # set to false to silently suppress empty replies
# instead of posting "_(no response)_". Pairs with <silent /> sentinel steering.
stt:
enabled: false
apiKey: ""
Expand Down
31 changes: 31 additions & 0 deletions docs/steering/silence.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Silence Steering

## When to Stay Silent

You may receive messages addressed to other agents, or batched messages where some
entries are not intended for you. In those cases, staying silent is the correct
behaviour.

## How to Signal Silence

**Output exactly `<silent />` and nothing else.**

```
<silent />
```

The gateway detects this sentinel and suppresses the message before posting to the
channel. No user-visible output is produced and no placeholder is left behind.

**Do not** explain or justify staying silent. Do not write things like:
- "I'm staying silent because..."
- "This message wasn't addressed to me."
- "No response needed."

Any text other than the exact sentinel string will be posted to the channel.

## Sub-case: Multiple Bot Mentions

When a message mentions several bots, read the full mention list before deciding
whether your UID is present. Do not infer "I should stay silent" solely because
other bot UIDs appear — check whether your own UID is also in the list.
124 changes: 103 additions & 21 deletions src/adapter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ use anyhow::Result;
use async_trait::async_trait;
use serde::Serialize;
use std::sync::Arc;
use tracing::{error, warn};
use tracing::{debug, error, info, warn};

use crate::acp::{classify_notification, AcpEvent, ContentBlock, SessionPool};
use crate::config::{ReactionsConfig, ToolDisplay};
Expand Down Expand Up @@ -462,6 +462,7 @@ impl AdapterRouter {
let streaming = adapter.use_streaming(other_bot_present);
let table_mode = self.table_mode;
let tool_display = self.reactions_config.tool_display;
let empty_reply_placeholder = self.reactions_config.empty_reply_placeholder;
let prompt_hard_timeout = self.prompt_hard_timeout;
let liveness_check_interval = self.liveness_check_interval;

Expand All @@ -477,10 +478,14 @@ impl AdapterRouter {

let mut text_buf = String::new();
let mut tool_lines: Vec<ToolEntry> = Vec::new();

if reset {
text_buf.push_str("⚠️ _Session expired, starting fresh..._\n\n");
}
// Kept separate from text_buf so the sentinel check ("is the
// agent's actual output <silent />?") is not confused by the
// synthetic prelude. Prepended to final_content before send.
let reset_prelude = if reset {
"⚠️ _Session expired, starting fresh..._\n\n"
} else {
""
};

// Streaming edit: send placeholder, spawn edit loop
let (buf_tx, placeholder_msg) = if streaming {
Expand Down Expand Up @@ -540,6 +545,7 @@ impl AdapterRouter {
_ = tokio::time::sleep(liveness_check_interval) => {
if !conn.alive() {
response_error = Some("Agent process died".into());
warn!(platform = %adapter.platform(), "agent process died mid-prompt");
conn.abandon_request(request_id).await;
break;
}
Expand All @@ -548,6 +554,11 @@ impl AdapterRouter {
"Agent exceeded hard timeout ({}s)",
prompt_hard_timeout.as_secs(),
));
warn!(
platform = %adapter.platform(),
elapsed_s = prompt_start.elapsed().as_secs(),
"agent hard timeout exceeded"
);
conn.abandon_request(request_id).await;
break;
}
Expand All @@ -565,6 +576,12 @@ impl AdapterRouter {
}
if let Some(ref err) = notification.error {
response_error = Some(format_coded_error(err.code, &err.message));
warn!(
platform = %adapter.platform(),
code = err.code,
message = %err.message,
"agent JSON-RPC error"
);
}
break;
}
Expand All @@ -573,13 +590,22 @@ impl AdapterRouter {
match event {
AcpEvent::Text(t) => {
text_buf.push_str(&t);
// Don't stream potential-sentinel content to the edit
// loop — if the agent is outputting <silent /> the
// placeholder should stay as "…" until delete fires,
// not flash the literal sentinel text to users.
if let Some(tx) = &buf_tx {
let _ = tx.send(compose_display(
&tool_lines,
&text_buf,
true,
tool_display,
));
if text_buf.trim() != "<silent />" {
let _ = tx.send(format!(
"{reset_prelude}{}",
compose_display(
&tool_lines,
&text_buf,
true,
tool_display,
)
));
}
}
}
AcpEvent::Thinking => {
Expand All @@ -599,11 +625,14 @@ impl AdapterRouter {
});
}
if let Some(tx) = &buf_tx {
let _ = tx.send(compose_display(
&tool_lines,
&text_buf,
true,
tool_display,
let _ = tx.send(format!(
"{reset_prelude}{}",
compose_display(
&tool_lines,
&text_buf,
true,
tool_display,
)
));
}
}
Expand All @@ -627,11 +656,14 @@ impl AdapterRouter {
});
}
if let Some(tx) = &buf_tx {
let _ = tx.send(compose_display(
&tool_lines,
&text_buf,
true,
tool_display,
let _ = tx.send(format!(
"{reset_prelude}{}",
compose_display(
&tool_lines,
&text_buf,
true,
tool_display,
)
));
}
}
Expand All @@ -653,12 +685,39 @@ impl AdapterRouter {
let (directives, stripped_text) = parse_output_directives(&text_buf);
let text_buf = stripped_text;

// Sentinel: checked post-loop — chunks may transiently match mid-stream.
if text_buf.trim() == "<silent />" {
info!(platform = %adapter.platform(), "agent emitted <silent /> sentinel -- suppressing reply");
reactions.suppress().await;
if let Some(msg) = placeholder_msg {
let a = adapter.clone();
tokio::spawn(async move {
if let Err(e) = a.delete_message(&msg).await {
warn!(error = ?e, "delete placeholder failed after silent sentinel");
}
});
}
return Ok(());
}

// Build final content
let final_content =
compose_display(&tool_lines, &text_buf, false, tool_display);
let final_content = if final_content.is_empty() {
if let Some(err) = response_error {
format!("⚠️ {err}")
} else if !empty_reply_placeholder {
debug!(platform = %adapter.platform(), "empty reply suppressed; empty_reply_placeholder disabled");
reactions.suppress().await;
if let Some(msg) = placeholder_msg {
let a = adapter.clone();
tokio::spawn(async move {
if let Err(e) = a.delete_message(&msg).await {
warn!(error = ?e, "delete placeholder failed after empty reply suppression");
}
});
}
return Ok(());
} else {
"_(no response)_".to_string()
}
Expand All @@ -668,6 +727,7 @@ impl AdapterRouter {
final_content
};

let final_content = format!("{reset_prelude}{final_content}");
let final_content = markdown::convert_tables(&final_content, table_mode);
let chunks = format::split_message(&final_content, message_limit);
if let Some(msg) = placeholder_msg {
Expand Down Expand Up @@ -1195,4 +1255,26 @@ mod directive_tests {
assert_eq!(directives.reply_to, Some("456".to_string()));
assert_eq!(content, "看看 [[這個]] 怎麼樣");
}

#[test]
fn silent_sentinel_passes_through_directive_parser() {
// <silent /> is not a [[key:value]] directive — parse_output_directives must
// return it unchanged so the post-loop sentinel check can match it.
let input = "<silent />";
let (directives, content) = parse_output_directives(input);
assert_eq!(directives.reply_to, None);
assert_eq!(content, "<silent />");
}

#[test]
fn silent_sentinel_with_whitespace_passes_through() {
// Leading/trailing whitespace and newline variants must survive directive parsing.
for input in &[" <silent /> ", "<silent />\n", "\n<silent />"] {
let (_, content) = parse_output_directives(input);
assert!(
content.trim() == "<silent />",
"expected trimmed content to equal '<silent />' for input {input:?}, got {content:?}"
);
}
}
}
28 changes: 28 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -408,6 +408,10 @@ pub struct ReactionsConfig {
pub emojis: ReactionEmojis,
#[serde(default)]
pub timing: ReactionTiming,
/// When false, empty replies (no error) are silently suppressed instead of
/// posting "_(no response)_". Default true preserves existing behaviour.
#[serde(default = "default_true")]
pub empty_reply_placeholder: bool,
}

#[derive(Debug, Clone, Deserialize)]
Expand Down Expand Up @@ -520,6 +524,7 @@ impl Default for ReactionsConfig {
tool_display: ToolDisplay::default(),
emojis: ReactionEmojis::default(),
timing: ReactionTiming::default(),
empty_reply_placeholder: true,
}
}
}
Expand Down Expand Up @@ -922,4 +927,27 @@ echo_transcript = false
assert!(cfg.stt.enabled);
assert!(!cfg.stt.echo_transcript);
}

#[test]
fn empty_reply_placeholder_defaults_to_true() {
let toml = r#"
[agent]
command = "echo"
"#;
let cfg = parse_config(toml, "test").unwrap();
assert!(cfg.reactions.empty_reply_placeholder, "default must be true for backward compat");
}

#[test]
fn empty_reply_placeholder_can_be_set_to_false() {
let toml = r#"
[agent]
command = "echo"

[reactions]
empty_reply_placeholder = false
"#;
let cfg = parse_config(toml, "test").unwrap();
assert!(!cfg.reactions.empty_reply_placeholder);
}
}
Loading
Loading