diff --git a/charts/openab/templates/configmap.yaml b/charts/openab/templates/configmap.yaml index ddb0e927..027c3238 100644 --- a/charts/openab/templates/configmap.yaml +++ b/charts/openab/templates/configmap.yaml @@ -103,6 +103,12 @@ data: [reactions] enabled = {{ ($cfg.reactions).enabled | default true }} remove_after_reply = {{ ($cfg.reactions).removeAfterReply | default false }} + {{- 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) }} + {{- end }} + tool_display = {{ ($cfg.reactions).toolDisplay | toJson }} + {{- end }} {{- if ($cfg.stt).enabled }} {{- if not ($cfg.stt).apiKey }} {{ fail (printf "agents.%s.stt.apiKey is required when stt.enabled=true" $name) }} diff --git a/charts/openab/tests/tool-display_test.yaml b/charts/openab/tests/tool-display_test.yaml new file mode 100644 index 00000000..8fa92de8 --- /dev/null +++ b/charts/openab/tests/tool-display_test.yaml @@ -0,0 +1,40 @@ +suite: toolDisplay +templates: + - templates/configmap.yaml +tests: + - it: renders tool_display = "compact" + set: + agents.kiro.reactions.toolDisplay: compact + asserts: + - matchRegex: + path: data["config.toml"] + pattern: 'tool_display = "compact"' + + - it: renders tool_display = "full" + set: + agents.kiro.reactions.toolDisplay: full + asserts: + - matchRegex: + path: data["config.toml"] + pattern: 'tool_display = "full"' + + - it: renders tool_display = "none" + set: + agents.kiro.reactions.toolDisplay: none + asserts: + - matchRegex: + path: data["config.toml"] + pattern: 'tool_display = "none"' + + - it: omits tool_display when not set + asserts: + - notMatchRegex: + path: data["config.toml"] + pattern: tool_display + + - it: rejects invalid toolDisplay value + set: + agents.kiro.reactions.toolDisplay: hidden + asserts: + - failedTemplate: + errorPattern: "must be one of: full, compact, none" diff --git a/charts/openab/values.yaml b/charts/openab/values.yaml index d5bfd0af..9f0abbae 100644 --- a/charts/openab/values.yaml +++ b/charts/openab/values.yaml @@ -48,6 +48,7 @@ agents: # reactions: # enabled: true # removeAfterReply: false + # # toolDisplay: "compact" (default) | "full" | "none" # persistence: # enabled: true # storageClass: "" @@ -80,6 +81,7 @@ agents: # reactions: # enabled: true # removeAfterReply: false + # # toolDisplay: "compact" (default) | "full" | "none" # persistence: # enabled: true # storageClass: "" @@ -109,6 +111,7 @@ agents: # reactions: # enabled: true # removeAfterReply: false + # # toolDisplay: "compact" (default) | "full" | "none" # persistence: # enabled: true # storageClass: "" @@ -164,6 +167,10 @@ agents: reactions: enabled: true removeAfterReply: false + # toolDisplay: "compact" (default) | "full" | "none" + # compact: show count summary (e.g. โœ… 3 ยท ๐Ÿ”ง 1 tool(s)) + # full: show complete tool titles (for debugging) + # none: hide tool lines entirely stt: enabled: false apiKey: "" diff --git a/docs/config-reference.md b/docs/config-reference.md index 32ae7143..1deb53e7 100644 --- a/docs/config-reference.md +++ b/docs/config-reference.md @@ -160,6 +160,7 @@ Emoji reaction feedback on messages to show agent processing status. |-----|------|---------|-------------| | `enabled` | bool | `true` | Enable/disable reaction feedback. | | `remove_after_reply` | bool | `false` | Remove the status reaction after the agent replies. | +| `tool_display` | string | `"compact"` | How tool calls are rendered: `"full"` (complete title), `"compact"` (count summary, e.g. `โœ… 3 ยท ๐Ÿ”ง 1 tool(s)`), or `"none"` (hidden). | ### `[reactions.emojis]` @@ -307,6 +308,7 @@ Key mapping (`values.yaml` โ†’ `config.toml`): | `agents..pool.maxSessions` | `[pool] max_sessions` | | `agents..pool.sessionTtlHours` | `[pool] session_ttl_hours` | | `agents..reactions.enabled` | `[reactions] enabled` | +| `agents..reactions.toolDisplay` | `[reactions] tool_display` | | `agents..stt.apiKey` | `[stt] api_key` | | `agents..cronjobs[].enabled` | `[[cron.jobs]] enabled` | | `agents..cronjobs[].schedule` | `[[cron.jobs]] schedule` | diff --git a/docs/tool-display.md b/docs/tool-display.md new file mode 100644 index 00000000..3716842c --- /dev/null +++ b/docs/tool-display.md @@ -0,0 +1,71 @@ +# Tool Display Configuration + +Control how tool calls are rendered in chat messages during agent responses. + +## Configuration + +```toml +[reactions] +tool_display = "compact" # full | compact | none +``` + +### Helm + +```yaml +agents: + kiro: + reactions: + toolDisplay: "compact" # full | compact | none +``` + +## Modes + +### `compact` (default) + +Shows a single-line count summary. No tool names, commands, or arguments are displayed. + +``` +โœ… 3 ยท ๐Ÿ”ง 1 tool(s) + +Agent response text here... +``` + +Best for: everyday use, public channels, mobile. + +### `full` + +Shows each tool call with its complete title. When more than 3 tools finish, they collapse into a count summary automatically. + +``` +โœ… `curl -s "https://ghcr.io/v2/openabdev/charts/openab/tags/list"` +โœ… `grep -r "pattern" src/` +๐Ÿ”ง `npm install`... + +Agent response text here... +``` + +Best for: debugging, understanding what the agent is doing step by step. + +### `none` + +Hides tool lines entirely. Only the final agent response is shown. Reaction emojis (๐Ÿ”งโ†’โœ…) still work, so you can tell the agent is busy. + +``` +Agent response text here... +``` + +Best for: clean output when you only care about the final answer. + +## Icons + +| Icon | Meaning | +|------|---------| +| ๐Ÿ”ง | Tool is running | +| โœ… | Tool completed successfully | +| โŒ | Tool failed | + +## Notes + +- **Default changed**: `compact` is the new default. Previously, tool calls were always shown in full. If you want the old behavior, set `tool_display = "full"`. +- **Reaction emojis are independent**: The emoji reactions on messages (๐Ÿ‘€โ†’๐Ÿค”โ†’๐Ÿ”งโ†’๐Ÿ†—) work regardless of `tool_display` setting. +- **Streaming behavior**: In `compact` mode, the count updates in real-time as tools start and finish. In `full` mode, individual tool lines appear and update during streaming. diff --git a/src/adapter.rs b/src/adapter.rs index a66b5aa9..f558fd07 100644 --- a/src/adapter.rs +++ b/src/adapter.rs @@ -5,7 +5,7 @@ use std::sync::Arc; use tracing::error; use crate::acp::{classify_notification, AcpEvent, ContentBlock, SessionPool}; -use crate::config::ReactionsConfig; +use crate::config::{ReactionsConfig, ToolDisplay}; use crate::error_display::{format_coded_error, format_user_error}; use crate::format; use crate::markdown::{self, TableMode}; @@ -143,7 +143,11 @@ pub struct AdapterRouter { } impl AdapterRouter { - pub fn new(pool: Arc, reactions_config: ReactionsConfig, table_mode: TableMode) -> Self { + pub fn new( + pool: Arc, + reactions_config: ReactionsConfig, + table_mode: TableMode, + ) -> Self { Self { pool, reactions_config, @@ -274,6 +278,7 @@ impl AdapterRouter { let message_limit = adapter.message_limit(); let streaming = adapter.use_streaming(other_bot_present); let table_mode = self.table_mode; + let tool_display = self.reactions_config.tool_display; self.pool .with_connection(thread_key, |conn| { @@ -313,15 +318,21 @@ impl AdapterRouter { let content = buf_rx.borrow_and_update().clone(); if content != last { let display = if content.chars().count() > limit - 100 { - format!("โ€ฆ{}", format::truncate_chars_tail(&content, limit - 100)) + format!( + "โ€ฆ{}", + format::truncate_chars_tail(&content, limit - 100) + ) } else { content.clone() }; - let _ = edit_adapter.edit_message(&edit_msg, &display).await; + let _ = + edit_adapter.edit_message(&edit_msg, &display).await; last = content; } } - if buf_rx.has_changed().is_err() { break; } + if buf_rx.has_changed().is_err() { + break; + } } }); (Some(tx), Some(msg)) @@ -333,7 +344,8 @@ impl AdapterRouter { let mut response_error: Option = None; let recv_timeout = std::time::Duration::from_secs(600); loop { - let notification = match tokio::time::timeout(recv_timeout, rx.recv()).await { + let notification = match tokio::time::timeout(recv_timeout, rx.recv()).await + { Ok(Some(n)) => n, Ok(None) => break, // channel closed Err(_) => { @@ -353,7 +365,12 @@ impl AdapterRouter { AcpEvent::Text(t) => { text_buf.push_str(&t); if let Some(tx) = &buf_tx { - let _ = tx.send(compose_display(&tool_lines, &text_buf, true)); + let _ = tx.send(compose_display( + &tool_lines, + &text_buf, + true, + tool_display, + )); } } AcpEvent::Thinking => { @@ -373,7 +390,12 @@ impl AdapterRouter { }); } if let Some(tx) = &buf_tx { - let _ = tx.send(compose_display(&tool_lines, &text_buf, true)); + let _ = tx.send(compose_display( + &tool_lines, + &text_buf, + true, + tool_display, + )); } } AcpEvent::ToolDone { id, title, status } => { @@ -396,7 +418,12 @@ impl AdapterRouter { }); } if let Some(tx) = &buf_tx { - let _ = tx.send(compose_display(&tool_lines, &text_buf, true)); + let _ = tx.send(compose_display( + &tool_lines, + &text_buf, + true, + tool_display, + )); } } AcpEvent::ConfigUpdate { options } => { @@ -412,7 +439,8 @@ impl AdapterRouter { drop(buf_tx); // Build final content - let final_content = compose_display(&tool_lines, &text_buf, false); + 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}") @@ -451,7 +479,10 @@ impl AdapterRouter { /// Flatten a tool-call title into a single line safe for inline-code spans. fn sanitize_title(title: &str) -> String { - title.replace('\r', "").replace('\n', " ; ").replace('`', "'") + title + .replace('\r', "") + .replace('\n', " ; ") + .replace('`', "'") } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -475,7 +506,11 @@ impl ToolEntry { ToolState::Completed => "โœ…", ToolState::Failed => "โŒ", }; - let suffix = if self.state == ToolState::Running { "..." } else { "" }; + let suffix = if self.state == ToolState::Running { + "..." + } else { + "" + }; format!("{icon} `{}`{}", self.title, suffix) } } @@ -484,47 +519,93 @@ impl ToolEntry { /// during streaming before collapsing into a summary line. const TOOL_COLLAPSE_THRESHOLD: usize = 3; -fn compose_display(tool_lines: &[ToolEntry], text: &str, streaming: bool) -> String { +fn compose_display( + tool_lines: &[ToolEntry], + text: &str, + streaming: bool, + tool_display: ToolDisplay, +) -> String { let mut out = String::new(); - if !tool_lines.is_empty() { - if streaming { - let done = tool_lines.iter().filter(|e| e.state == ToolState::Completed).count(); - let failed = tool_lines.iter().filter(|e| e.state == ToolState::Failed).count(); - let running: Vec<_> = tool_lines.iter().filter(|e| e.state == ToolState::Running).collect(); - let finished = done + failed; - - if finished <= TOOL_COLLAPSE_THRESHOLD { - for entry in tool_lines.iter().filter(|e| e.state != ToolState::Running) { - out.push_str(&entry.render()); - out.push('\n'); - } - } else { + if !tool_lines.is_empty() && tool_display != ToolDisplay::None { + let done = tool_lines + .iter() + .filter(|e| e.state == ToolState::Completed) + .count(); + let failed = tool_lines + .iter() + .filter(|e| e.state == ToolState::Failed) + .count(); + let running = tool_lines + .iter() + .filter(|e| e.state == ToolState::Running) + .count(); + let finished = done + failed; + + match tool_display { + ToolDisplay::Compact => { + // Always show count summary, never per-tool details let mut parts = Vec::new(); - if done > 0 { parts.push(format!("โœ… {done}")); } - if failed > 0 { parts.push(format!("โŒ {failed}")); } - out.push_str(&format!("{} tool(s) completed\n", parts.join(" ยท "))); - } - - if running.len() <= TOOL_COLLAPSE_THRESHOLD { - for entry in &running { - out.push_str(&entry.render()); - out.push('\n'); + if done > 0 { + parts.push(format!("โœ… {done}")); } - } else { - let hidden = running.len() - TOOL_COLLAPSE_THRESHOLD; - out.push_str(&format!("๐Ÿ”ง {hidden} more running\n")); - for entry in running.iter().skip(hidden) { - out.push_str(&entry.render()); - out.push('\n'); + if failed > 0 { + parts.push(format!("โŒ {failed}")); + } + if running > 0 { + parts.push(format!("๐Ÿ”ง {running}")); + } + if !parts.is_empty() { + out.push_str(&format!("{} tool(s)\n", parts.join(" ยท "))); } } - } else { - for entry in tool_lines { - out.push_str(&entry.render()); - out.push('\n'); + ToolDisplay::Full => { + if streaming { + let running_entries: Vec<_> = tool_lines + .iter() + .filter(|e| e.state == ToolState::Running) + .collect(); + + if finished <= TOOL_COLLAPSE_THRESHOLD { + for entry in tool_lines.iter().filter(|e| e.state != ToolState::Running) { + out.push_str(&entry.render()); + out.push('\n'); + } + } else { + let mut parts = Vec::new(); + if done > 0 { + parts.push(format!("โœ… {done}")); + } + if failed > 0 { + parts.push(format!("โŒ {failed}")); + } + out.push_str(&format!("{} tool(s) completed\n", parts.join(" ยท "))); + } + + if running_entries.len() <= TOOL_COLLAPSE_THRESHOLD { + for entry in &running_entries { + out.push_str(&entry.render()); + out.push('\n'); + } + } else { + let hidden = running_entries.len() - TOOL_COLLAPSE_THRESHOLD; + out.push_str(&format!("๐Ÿ”ง {hidden} more running\n")); + for entry in running_entries.iter().skip(hidden) { + out.push_str(&entry.render()); + out.push('\n'); + } + } + } else { + for entry in tool_lines { + out.push_str(&entry.render()); + out.push('\n'); + } + } } + ToolDisplay::None => {} // guarded above, but safe no-op + } + if !out.is_empty() { + out.push('\n'); } - if !out.is_empty() { out.push('\n'); } } out.push_str(text.trim_end()); out @@ -547,18 +628,33 @@ mod tests { #[async_trait] impl ChatAdapter for TestAdapter { - fn platform(&self) -> &'static str { "test" } - fn message_limit(&self) -> usize { 2000 } + fn platform(&self) -> &'static str { + "test" + } + fn message_limit(&self) -> usize { + 2000 + } async fn send_message(&self, _: &ChannelRef, _: &str) -> Result { unimplemented!() } - async fn create_thread(&self, _: &ChannelRef, _: &MessageRef, _: &str) -> Result { + async fn create_thread( + &self, + _: &ChannelRef, + _: &MessageRef, + _: &str, + ) -> Result { unimplemented!() } - async fn add_reaction(&self, _: &MessageRef, _: &str) -> Result<()> { Ok(()) } - async fn remove_reaction(&self, _: &MessageRef, _: &str) -> Result<()> { Ok(()) } + async fn add_reaction(&self, _: &MessageRef, _: &str) -> Result<()> { + Ok(()) + } + async fn remove_reaction(&self, _: &MessageRef, _: &str) -> Result<()> { + Ok(()) + } // use_streaming() MUST be declared โ€” removing this line should fail compilation - fn use_streaming(&self, _other_bot_present: bool) -> bool { false } + fn use_streaming(&self, _other_bot_present: bool) -> bool { + false + } } let adapter = TestAdapter; @@ -627,4 +723,61 @@ mod tests { }; assert_eq!(thread_ch.origin_event_id.as_deref(), Some("evt_abc")); } + + fn tool(id: &str, title: &str, state: ToolState) -> ToolEntry { + ToolEntry { + id: id.into(), + title: title.into(), + state, + } + } + + #[test] + fn compose_display_full_shows_complete_title() { + let tools = vec![tool( + "1", + "curl -s https://example.com", + ToolState::Completed, + )]; + let out = compose_display(&tools, "done", false, ToolDisplay::Full); + assert!(out.contains("`curl -s https://example.com`")); + } + + #[test] + fn compose_display_compact_shows_count_summary() { + let tools = vec![ + tool("1", "curl -s https://example.com", ToolState::Completed), + tool("2", "grep -r pattern src/", ToolState::Completed), + tool("3", "cat /etc/hosts", ToolState::Failed), + ]; + let out = compose_display(&tools, "done", false, ToolDisplay::Compact); + assert!(out.contains("โœ… 2"), "expected completed count: {out}"); + assert!(out.contains("โŒ 1"), "expected failed count: {out}"); + assert!(out.contains("tool(s)"), "expected tool(s) label: {out}"); + // Must NOT contain individual tool names + assert!(!out.contains("curl"), "should not show tool names: {out}"); + assert!(!out.contains("grep"), "should not show tool names: {out}"); + } + + #[test] + fn compose_display_compact_shows_running_count() { + let tools = vec![ + tool("1", "curl", ToolState::Completed), + tool("2", "npm install", ToolState::Running), + ]; + let out = compose_display(&tools, "", true, ToolDisplay::Compact); + assert!(out.contains("โœ… 1"), "expected completed count: {out}"); + assert!(out.contains("๐Ÿ”ง 1"), "expected running count: {out}"); + } + + #[test] + fn compose_display_none_hides_tools() { + let tools = vec![tool( + "1", + "curl -s https://example.com", + ToolState::Completed, + )]; + let out = compose_display(&tools, "response text", false, ToolDisplay::None); + assert_eq!(out, "response text"); + } } diff --git a/src/config.rs b/src/config.rs index ed2463dc..3e6fe3d7 100644 --- a/src/config.rs +++ b/src/config.rs @@ -251,6 +251,31 @@ fn default_cron_platform() -> String { "discord".into() } fn default_cron_sender() -> String { "openab-cron".into() } fn default_cron_timezone() -> String { "UTC".into() } +/// Controls how tool calls are rendered in chat messages. +/// +/// - `full`: show complete tool title including arguments (original behavior) +/// - `compact`: show only a count summary, e.g. `โœ… 3 ยท ๐Ÿ”ง 1 tool(s)` (default) +/// - `none`: hide tool lines entirely, only show final response +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum ToolDisplay { + Full, + #[default] + Compact, + None, +} + +impl<'de> Deserialize<'de> for ToolDisplay { + fn deserialize>(deserializer: D) -> Result { + let s = String::deserialize(deserializer)?; + match s.to_lowercase().as_str() { + "full" => Ok(Self::Full), + "compact" => Ok(Self::Compact), + "none" | "off" | "hidden" => Ok(Self::None), + other => Err(serde::de::Error::unknown_variant(other, &["full", "compact", "none"])), + } + } +} + #[derive(Debug, Deserialize)] pub struct ReactionsConfig { #[serde(default = "default_true")] @@ -258,6 +283,8 @@ pub struct ReactionsConfig { #[serde(default)] pub remove_after_reply: bool, #[serde(default)] + pub tool_display: ToolDisplay, + #[serde(default)] pub emojis: ReactionEmojis, #[serde(default)] pub timing: ReactionTiming, @@ -327,6 +354,7 @@ impl Default for ReactionsConfig { Self { enabled: true, remove_after_reply: false, + tool_display: ToolDisplay::default(), emojis: ReactionEmojis::default(), timing: ReactionTiming::default(), }