From 408748b12384d65fc0bfab0f299268cb3156edfb Mon Sep 17 00:00:00 2001 From: Deanfluence Bot Date: Mon, 2 Mar 2026 23:34:29 +0800 Subject: [PATCH 01/41] feat: add settings-backed generic listen-only mode and invoke gating --- interface/src/api/client.ts | 10 + interface/src/routes/AgentConfig.tsx | 25 +- src/agent/channel.rs | 515 ++++++++++++++++++++++++++- src/agent/channel_prompt.rs | 11 +- src/api/agents.rs | 1 + src/api/config.rs | 33 ++ src/config.rs | 47 +++ src/messaging/discord.rs | 23 +- src/messaging/slack.rs | 17 + src/messaging/telegram.rs | 11 + src/tools.rs | 1 + src/tools/reply.rs | 82 ++++- 12 files changed, 760 insertions(+), 16 deletions(-) diff --git a/interface/src/api/client.ts b/interface/src/api/client.ts index 482d056d1..ca4742645 100644 --- a/interface/src/api/client.ts +++ b/interface/src/api/client.ts @@ -566,6 +566,10 @@ export interface BrowserSection { evaluate_enabled: boolean; } +export interface ChannelSection { + listen_only_mode: boolean; +} + export interface SandboxSection { mode: "enabled" | "disabled"; writable_paths: string[]; @@ -584,6 +588,7 @@ export interface AgentConfigResponse { coalesce: CoalesceSection; memory_persistence: MemoryPersistenceSection; browser: BrowserSection; + channel: ChannelSection; discord: DiscordSection; sandbox: SandboxSection; } @@ -648,6 +653,10 @@ export interface BrowserUpdate { evaluate_enabled?: boolean; } +export interface ChannelUpdate { + listen_only_mode?: boolean; +} + export interface SandboxUpdate { mode?: "enabled" | "disabled"; writable_paths?: string[]; @@ -666,6 +675,7 @@ export interface AgentConfigUpdateRequest { coalesce?: CoalesceUpdate; memory_persistence?: MemoryPersistenceUpdate; browser?: BrowserUpdate; + channel?: ChannelUpdate; discord?: DiscordUpdate; sandbox?: SandboxUpdate; } diff --git a/interface/src/routes/AgentConfig.tsx b/interface/src/routes/AgentConfig.tsx index 5924be3d9..cd9c66547 100644 --- a/interface/src/routes/AgentConfig.tsx +++ b/interface/src/routes/AgentConfig.tsx @@ -15,7 +15,7 @@ function supportsAdaptiveThinking(modelId: string): boolean { || id.includes("sonnet-4-6") || id.includes("sonnet-4.6"); } -type SectionId = "soul" | "identity" | "user" | "routing" | "tuning" | "compaction" | "cortex" | "coalesce" | "memory" | "browser" | "sandbox"; +type SectionId = "soul" | "identity" | "user" | "routing" | "tuning" | "compaction" | "cortex" | "coalesce" | "memory" | "browser" | "channel" | "sandbox"; const SECTIONS: { id: SectionId; @@ -34,6 +34,7 @@ const SECTIONS: { { id: "coalesce", label: "Coalesce", group: "config", description: "Message batching", detail: "When multiple messages arrive in quick succession, coalescing batches them into a single LLM turn. This prevents the agent from responding to each message individually in fast-moving conversations." }, { id: "memory", label: "Memory Persistence", group: "config", description: "Auto-save interval", detail: "Spawns a silent background branch at regular intervals to recall existing memories and save new ones from the recent conversation. Runs without blocking the channel." }, { id: "browser", label: "Browser", group: "config", description: "Chrome automation", detail: "Controls browser automation tools available to workers. When enabled, workers can navigate web pages, take screenshots, and interact with sites. JavaScript evaluation is a separate permission." }, + { id: "channel", label: "Channel Behavior", group: "config", description: "Reply behavior", detail: "Listen-only mode suppresses unsolicited replies in busy channels. The agent still responds to slash commands, @mentions, and replies to its own messages." }, { id: "sandbox", label: "Sandbox", group: "config", description: "Process containment", detail: "OS-level filesystem containment for shell and exec tool subprocesses. When enabled, worker processes run inside a kernel-enforced sandbox (bubblewrap on Linux, sandbox-exec on macOS) with an allowlist-only filesystem — only system paths, the workspace, and explicitly configured extra paths are accessible." }, ]; @@ -64,7 +65,7 @@ export function AgentConfig({ agentId }: AgentConfigProps) { // Sync activeSection with URL search param useEffect(() => { if (search.tab) { - const validSections: SectionId[] = ["soul", "identity", "user", "routing", "tuning", "compaction", "cortex", "coalesce", "memory", "browser", "sandbox"]; + const validSections: SectionId[] = ["soul", "identity", "user", "routing", "tuning", "compaction", "cortex", "coalesce", "memory", "browser", "channel", "sandbox"]; if (validSections.includes(search.tab as SectionId)) { setActiveSection(search.tab as SectionId); } @@ -414,6 +415,7 @@ const SANDBOX_DEFAULTS = { mode: "enabled" as const, writable_paths: [] as strin function ConfigSectionEditor({ sectionId, label, description, detail, config, onDirtyChange, saveHandlerRef, onSave }: ConfigSectionEditorProps) { type ConfigValues = Record; const sandbox = config.sandbox ?? SANDBOX_DEFAULTS; + const channel = config.channel ?? { listen_only_mode: false }; const [localValues, setLocalValues] = useState(() => { // Initialize from config based on section switch (sectionId) { @@ -431,6 +433,8 @@ function ConfigSectionEditor({ sectionId, label, description, detail, config, on return { ...config.memory_persistence } as ConfigValues; case "browser": return { ...config.browser } as ConfigValues; + case "channel": + return { ...channel } as ConfigValues; case "sandbox": return { mode: sandbox.mode, writable_paths: sandbox.writable_paths } as ConfigValues; default: @@ -469,6 +473,9 @@ function ConfigSectionEditor({ sectionId, label, description, detail, config, on case "browser": setLocalValues({ ...config.browser }); break; + case "channel": + setLocalValues({ ...channel }); + break; case "sandbox": setLocalValues({ mode: sandbox.mode, writable_paths: sandbox.writable_paths }); break; @@ -509,6 +516,9 @@ function ConfigSectionEditor({ sectionId, label, description, detail, config, on case "browser": setLocalValues({ ...config.browser }); break; + case "channel": + setLocalValues({ ...channel }); + break; case "sandbox": setLocalValues({ mode: sandbox.mode, writable_paths: sandbox.writable_paths }); break; @@ -852,6 +862,17 @@ function ConfigSectionEditor({ sectionId, label, description, detail, config, on ); + case "channel": + return ( +
+ handleChange("listen_only_mode", v)} + /> +
+ ); default: return null; } diff --git a/src/agent/channel.rs b/src/agent/channel.rs index 7ced52b81..bcef933a9 100644 --- a/src/agent/channel.rs +++ b/src/agent/channel.rs @@ -173,6 +173,9 @@ pub struct Channel { pending_results: Vec, /// Optional send_agent_message tool (only when agent has active links). send_agent_message_tool: Option, + /// Channel-local reply mode toggle for telegram marketing intel. + /// true = listen-only unless explicit invoke, false = respond normally. + listen_only_mode: bool, } impl Channel { @@ -248,6 +251,7 @@ impl Channel { }; let self_tx = message_tx.clone(); + let default_listen_only_mode = deps.runtime_config.channel_config.load().listen_only_mode; let channel = Self { id: id.clone(), title: None, @@ -274,6 +278,7 @@ impl Channel { retrigger_deadline: None, pending_results: Vec::new(), send_agent_message_tool, + listen_only_mode: default_listen_only_mode, }; (channel, message_tx) @@ -299,10 +304,330 @@ impl Channel { .filter(|adapter| !adapter.is_empty()) } + fn sync_listen_only_mode_from_runtime(&mut self) { + self.listen_only_mode = self + .deps + .runtime_config + .channel_config + .load() + .listen_only_mode; + } + fn suppress_plaintext_fallback(&self) -> bool { matches!(self.current_adapter(), Some("email")) } + fn compute_listen_mode_invocation( + &self, + message: &InboundMessage, + raw_text: &str, + ) -> (bool, bool, bool) { + let text = raw_text.trim(); + let invoked_by_command = text.starts_with('/'); + let invoked_by_mention = match message.source.as_str() { + "telegram" => message + .metadata + .get("telegram_bot_username") + .and_then(|v| v.as_str()) + .map(|username| { + let mention = format!("@{username}"); + text.contains(&mention) + }) + .unwrap_or(false), + "discord" => message + .metadata + .get("discord_mentioned_bot") + .and_then(|v| v.as_bool()) + .unwrap_or(false), + "slack" => message + .metadata + .get("slack_mentions_or_replies_to_bot") + .and_then(|v| v.as_bool()) + .unwrap_or(false), + _ => false, + }; + let invoked_by_reply = match message.source.as_str() { + // Use bot-specific reply metadata; generic reply_to_is_bot can + // match unrelated bots and cause false invokes. + "discord" => message + .metadata + .get("discord_reply_to_bot") + .and_then(|v| v.as_bool()) + .unwrap_or(false), + _ => message + .metadata + .get("reply_to_is_bot") + .and_then(|v| v.as_bool()) + .unwrap_or(false), + }; + + (invoked_by_command, invoked_by_mention, invoked_by_reply) + } + + async fn send_builtin_text(&mut self, text: String, log_label: &str) { + self.state.conversation_logger.log_bot_message_with_name( + &self.state.channel_id, + &text, + Some(self.agent_display_name()), + ); + if let Err(error) = self.response_tx.send(OutboundResponse::Text(text)).await { + tracing::error!(%error, channel_id = %self.id, %log_label, "failed to send built-in reply"); + } + } + + async fn try_handle_builtin_ops_commands( + &mut self, + raw_text: &str, + message: &InboundMessage, + ) -> Result { + if message.source == "system" { + return Ok(false); + } + let supported_source = matches!( + message.source.as_str(), + "telegram" | "discord" | "slack" | "twitch" + ); + if !supported_source { + return Ok(false); + } + + let text = raw_text.trim(); + if !text.starts_with('/') { + return Ok(false); + } + + let temporal_context = TemporalContext::from_runtime(self.deps.runtime_config.as_ref()); + let now_line = temporal_context.current_time_line(); + + match text { + "/status" => { + let routing = self.deps.runtime_config.routing.load(); + let channel_model = routing.resolve(ProcessType::Channel, None).to_string(); + let branch_model = routing.resolve(ProcessType::Branch, None).to_string(); + let mode = if self.listen_only_mode { + "quiet" + } else { + "active" + }; + let adapter = self.current_adapter().unwrap_or("unknown"); + let body = format!( + "status\n\ + - agent: {}\n\ + - channel: {}\n\ + - adapter: {}\n\ + - mode: {} (quiet => only command/@mention/reply-to-bot)\n\ + - channel model: {}\n\ + - branch model: {}\n\ + - time: {}", + self.deps.agent_id, + self.id, + adapter, + mode, + channel_model, + branch_model, + now_line + ); + self.send_builtin_text(body, "status").await; + return Ok(true); + } + "/quiet" => { + self.listen_only_mode = true; + self.send_builtin_text( + "quiet mode enabled. i'll only reply to commands, @mentions, or replies to my message." + .to_string(), + "quiet", + ) + .await; + return Ok(true); + } + "/active" => { + self.listen_only_mode = false; + self.send_builtin_text( + "active mode enabled. i'll respond normally in this chat.".to_string(), + "active", + ) + .await; + return Ok(true); + } + "/tasks" => { + let ready = self + .deps + .task_store + .list_ready(self.deps.agent_id.as_ref(), 10) + .await?; + let body = if ready.is_empty() { + "tasks (ready): none".to_string() + } else { + let mut lines = vec!["tasks (ready):".to_string()]; + for task in ready { + lines.push(format!( + "- #{} [{}] {}", + task.task_number, task.priority, task.title + )); + } + lines.join("\n") + }; + self.send_builtin_text(body, "tasks").await; + return Ok(true); + } + "/today" => { + let in_progress = self + .deps + .task_store + .list( + self.deps.agent_id.as_ref(), + Some(crate::tasks::TaskStatus::InProgress), + None, + 5, + ) + .await?; + let ready = self + .deps + .task_store + .list_ready(self.deps.agent_id.as_ref(), 5) + .await?; + + let mut lines = vec!["today (local tasks snapshot):".to_string()]; + if in_progress.is_empty() { + lines.push("- in progress: none".to_string()); + } else { + lines.push("- in progress:".to_string()); + for task in in_progress { + lines.push(format!( + " #{} [{}] {}", + task.task_number, task.priority, task.title + )); + } + } + if ready.is_empty() { + lines.push("- up next (ready): none".to_string()); + } else { + lines.push("- up next (ready):".to_string()); + for task in ready { + lines.push(format!( + " #{} [{}] {}", + task.task_number, task.priority, task.title + )); + } + } + self.send_builtin_text(lines.join("\n"), "today").await; + return Ok(true); + } + "/help" => { + let body = "commands:\n\ + - /status: current mode, models, binding snapshot\n\ + - /today: in-progress + ready task snapshot\n\ + - /tasks: ready task list\n\ + - /quiet: listen-only mode\n\ + - /active: normal reply mode\n\ + - /digest: one-shot day digest (00:00 -> now)\n\ + - /agent-id: runtime agent id" + .to_string(); + self.send_builtin_text(body, "help").await; + return Ok(true); + } + _ => {} + } + + Ok(false) + } + + async fn try_handle_builtin_digest( + &mut self, + raw_text: &str, + message: &InboundMessage, + ) -> Result { + if message.source != "telegram" + || message.source == "system" + || raw_text.trim() != "/digest" + { + return Ok(false); + } + + let temporal_context = TemporalContext::from_runtime(self.deps.runtime_config.as_ref()); + let today_local = temporal_context.local_date(temporal_context.now_utc); + + let all_messages = self + .state + .conversation_logger + .load_recent(&self.state.channel_id, 400) + .await?; + + let mut transcript = String::new(); + for item in all_messages { + if item.role != "user" { + continue; + } + if temporal_context.local_date(item.created_at) != today_local { + continue; + } + let sender = item.sender_name.unwrap_or_else(|| "user".to_string()); + let content = item.content.trim(); + if content.is_empty() || content.eq_ignore_ascii_case("/digest") { + continue; + } + let ts = temporal_context.format_timestamp(item.created_at); + let line = format!("[{}] {}: {}\n", ts, sender, content); + if transcript.len() + line.len() > 12000 { + break; + } + transcript.push_str(&line); + } + + let reply_text = if transcript.trim().is_empty() { + "no material updates today.".to_string() + } else { + let routing = self.deps.runtime_config.routing.load(); + let model_name = routing.resolve(ProcessType::Channel, None).to_string(); + let model = SpacebotModel::make(&self.deps.llm_manager, &model_name) + .with_context(&*self.deps.agent_id, "channel") + .with_routing((**routing).clone()); + let agent = AgentBuilder::new(model) + .preamble("you write crisp internal marketing digests. output plain text only.") + .default_max_turns(1) + .build(); + let prompt = format!( + "summarize this channel's messages from local 00:00 to now.\n\ + output exactly in this order:\n\ + 1) top decisions\n\ + 2) key convo themes\n\ + 3) open loops\n\ + keep it concise and practical.\n\ + if there's no meaningful activity, reply exactly: no material updates today.\n\n\ + transcript:\n{transcript}" + ); + match agent.prompt(&prompt).await { + Ok(text) => { + let trimmed = text.trim(); + if trimmed.is_empty() { + "no material updates today.".to_string() + } else { + trimmed.to_string() + } + } + Err(error) => { + tracing::warn!(%error, channel_id = %self.id, "builtin /digest summarizer failed"); + "no material updates today.".to_string() + } + } + }; + + self.state.conversation_logger.log_bot_message_with_name( + &self.state.channel_id, + &reply_text, + Some(self.agent_display_name()), + ); + if let Err(error) = self + .response_tx + .send(OutboundResponse::Text(reply_text)) + .await + { + tracing::error!(%error, channel_id = %self.id, "failed to send builtin /digest reply"); + } + + Ok(true) + } + /// Run the channel event loop. pub async fn run(mut self) -> Result<()> { tracing::info!(channel_id = %self.id, "channel started"); @@ -481,6 +806,9 @@ impl Channel { /// with a coalesce hint telling the LLM this is a fast-moving conversation. #[tracing::instrument(skip(self, messages), fields(channel_id = %self.id, agent_id = %self.deps.agent_id, message_count = messages.len()))] async fn handle_message_batch(&mut self, messages: Vec) -> Result<()> { + // Apply runtime-config updates immediately without requiring a restart. + self.sync_listen_only_mode_from_runtime(); + let message_count = messages.len(); let batch_start_timestamp = messages .iter() @@ -545,6 +873,7 @@ impl Channel { let mut user_contents: Vec = Vec::new(); let mut conversation_id = String::new(); let temporal_context = TemporalContext::from_runtime(self.deps.runtime_config.as_ref()); + let mut batch_has_invoke = false; for message in &messages { if message.source != "system" { @@ -565,6 +894,13 @@ impl Channel { } }; + if self.listen_only_mode { + let (invoked_by_command, invoked_by_mention, invoked_by_reply) = + self.compute_listen_mode_invocation(message, &raw_text); + batch_has_invoke |= + invoked_by_command || invoked_by_mention || invoked_by_reply; + } + self.state.conversation_logger.log_user_message( &self.state.channel_id, sender_name, @@ -612,6 +948,19 @@ impl Channel { user_contents.push(UserContent::text(formatted_text)); } } + + if self.listen_only_mode && !batch_has_invoke { + tracing::debug!( + channel_id = %self.id, + message_count, + "listen-first mode: suppressing unsolicited coalesced batch" + ); + // Keep passive memory capture behavior aligned with single-message flow. + self.message_count += message_count; + self.check_memory_persistence().await; + return Ok(()); + } + // Separate text and non-text (image/audio) content let mut text_parts = Vec::new(); let mut attachment_parts = Vec::new(); @@ -733,6 +1082,9 @@ impl Channel { /// memory_save. The tools act on the channel's shared state directly. #[tracing::instrument(skip(self, message), fields(channel_id = %self.id, agent_id = %self.deps.agent_id, message_id = %message.id))] async fn handle_message(&mut self, message: InboundMessage) -> Result<()> { + // Apply runtime-config updates immediately without requiring a restart. + self.sync_listen_only_mode_from_runtime(); + tracing::info!( channel_id = %self.id, message_id = %message.id, @@ -757,9 +1109,104 @@ impl Channel { crate::MessageContent::Interaction { .. } => (message.content.to_string(), Vec::new()), }; + // Deterministic built-in command: bypass model output drift for agent identity checks. + if message.source != "system" && raw_text.trim() == "/agent-id" { + let agent_id = self.deps.agent_id.to_string(); + self.state.conversation_logger.log_bot_message_with_name( + &self.state.channel_id, + &agent_id, + Some(self.agent_display_name()), + ); + if let Err(error) = self + .response_tx + .send(OutboundResponse::Text(agent_id)) + .await + { + tracing::error!(%error, channel_id = %self.id, "failed to send built-in /agent-id reply"); + } + return Ok(()); + } + + // Deterministic liveness ping for Telegram mentions. + // This avoids model/provider flakiness for simple "you there?" style checks. + if message.source == "telegram" && message.source != "system" { + let text = raw_text.trim().to_lowercase(); + let mention = message + .metadata + .get("telegram_bot_username") + .and_then(|v| v.as_str()) + .map(|u| format!("@{u}")) + .unwrap_or_default(); + let has_mention = !mention.is_empty() && text.contains(&mention); + let looks_like_ping = text.contains("you here") + || text.contains("ping") + || text.ends_with(" yo") + || text == "yo" + || text.contains("alive") + || text.contains("there?"); + + if has_mention && looks_like_ping { + let ack = "yeah i'm here".to_string(); + self.state.conversation_logger.log_bot_message_with_name( + &self.state.channel_id, + &ack, + Some(self.agent_display_name()), + ); + if let Err(error) = self.response_tx.send(OutboundResponse::Text(ack)).await { + tracing::error!(%error, channel_id = %self.id, "failed to send built-in telegram ping reply"); + } + return Ok(()); + } + } + + // Deterministic ping ack for Discord quiet-mode mentions/replies to avoid + // flaky model behavior (e.g. skipping or over-formatting simple liveness checks). + if message.source == "discord" && self.listen_only_mode && message.source != "system" { + let text = raw_text.trim().to_lowercase(); + let directed = message + .metadata + .get("discord_mentions_or_replies_to_bot") + .and_then(|v| v.as_bool()) + .unwrap_or(false) + || message + .metadata + .get("reply_to_is_bot") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let looks_like_ping = text.contains("you here") + || text.contains("ping") + || text.ends_with(" yo") + || text == "yo" + || text.contains("alive") + || text.contains("there?"); + if directed && looks_like_ping { + let ack = "yeah i'm here".to_string(); + self.state.conversation_logger.log_bot_message_with_name( + &self.state.channel_id, + &ack, + Some(self.agent_display_name()), + ); + if let Err(error) = self.response_tx.send(OutboundResponse::Text(ack)).await { + tracing::error!(%error, channel_id = %self.id, "failed to send built-in discord ping reply"); + } + return Ok(()); + } + } + + let rewritten_text = if message.source == "telegram" { + match raw_text.trim() { + "/digest" => { + "generate a day digest for this channel for today (from local 00:00 to now), concise and useful. preferred format and order: 1) top decisions (what was decided), 2) key convo themes (high-level discussion summary), 3) open loops (pending calls/questions). keep it short, practical, and non-cringe. if there is no meaningful activity in that window, say exactly: no material updates today.".to_string() + } + _ => raw_text.clone(), + } + } else { + raw_text.clone() + }; + let temporal_context = TemporalContext::from_runtime(self.deps.runtime_config.as_ref()); let message_timestamp = temporal_context.format_timestamp(message.timestamp); - let user_text = format_user_message(&raw_text, &message, &message_timestamp); + let user_text = format_user_message(&rewritten_text, &message, &message_timestamp); let attachment_content = if !attachments.is_empty() { download_attachments(&self.deps, &attachments).await @@ -804,6 +1251,42 @@ impl Channel { )?); } + if self + .try_handle_builtin_ops_commands(&raw_text, &message) + .await? + { + return Ok(()); + } + + if self.try_handle_builtin_digest(&raw_text, &message).await? { + return Ok(()); + } + + let mut invoked_by_command = false; + let mut invoked_by_mention = false; + let mut invoked_by_reply = false; + + // Listen-first guardrail: + // ingest all messages, but only reply when explicitly invoked. + if self.listen_only_mode && message.source != "system" { + (invoked_by_command, invoked_by_mention, invoked_by_reply) = + self.compute_listen_mode_invocation(&message, &raw_text); + + if !invoked_by_command && !invoked_by_mention && !invoked_by_reply { + tracing::debug!( + channel_id = %self.id, + source = %message.source, + "listen-first mode: suppressing unsolicited reply" + ); + // In quiet/listen-first mode we still want passive memory capture. + // Count suppressed user messages so auto memory persistence branches + // continue to run on interval without requiring explicit invokes. + self.message_count += 1; + self.check_memory_persistence().await; + return Ok(()); + } + } + let system_prompt = self.build_system_prompt().await?; { @@ -826,6 +1309,33 @@ impl Channel { self.handle_agent_result(result, &skip_flag, &replied_flag, is_retrigger) .await; + // Safety-net: in quiet mode, explicit mention/reply should never be dropped silently. + if self.listen_only_mode + && !is_retrigger + && !invoked_by_command + && (invoked_by_mention || invoked_by_reply) + && skip_flag.load(std::sync::atomic::Ordering::Relaxed) + && !replied_flag.load(std::sync::atomic::Ordering::Relaxed) + && matches!( + message.source.as_str(), + "discord" | "telegram" | "slack" | "twitch" + ) + { + let ack = "yeah i'm here — tell me what you need.".to_string(); + self.state.conversation_logger.log_bot_message_with_name( + &self.state.channel_id, + &ack, + Some(self.agent_display_name()), + ); + if let Err(error) = self.response_tx.send(OutboundResponse::Text(ack)).await { + tracing::error!( + %error, + channel_id = %self.id, + "failed to send quiet-mode explicit invoke fallback" + ); + } + } + // After retrigger turns, persist a fallback summary only when we don't // already have the LLM's actual relay text in history. // @@ -1421,6 +1931,9 @@ impl Channel { /// Handle a process event (branch results, worker completions, status updates). async fn handle_event(&mut self, event: ProcessEvent) -> Result<()> { + // Keep mode aligned with live settings updates while this worker runs. + self.sync_listen_only_mode_from_runtime(); + // Only process events targeted at this channel if !event_is_for_channel(&event, &self.id) { return Ok(()); diff --git a/src/agent/channel_prompt.rs b/src/agent/channel_prompt.rs index 196a249c0..c292068c8 100644 --- a/src/agent/channel_prompt.rs +++ b/src/agent/channel_prompt.rs @@ -5,7 +5,7 @@ //! system prompt from identity, memory bulletin, skills, status, etc. use crate::error::Result; -use chrono::{DateTime, Local, Utc}; +use chrono::{DateTime, Local, NaiveDate, Utc}; use chrono_tz::Tz; /// Debounce window for retriggers: coalesce rapid branch/worker completions @@ -123,6 +123,15 @@ impl TemporalContext { ) } + pub(crate) fn local_date(&self, timestamp: DateTime) -> NaiveDate { + match &self.timezone { + TemporalTimezone::Named { timezone, .. } => { + timestamp.with_timezone(timezone).date_naive() + } + TemporalTimezone::SystemLocal => timestamp.with_timezone(&Local).date_naive(), + } + } + pub(crate) fn worker_task_preamble( &self, prompt_engine: &crate::prompts::PromptEngine, diff --git a/src/api/agents.rs b/src/api/agents.rs index 81999c552..3e5f60bb0 100644 --- a/src/api/agents.rs +++ b/src/api/agents.rs @@ -562,6 +562,7 @@ pub(super) async fn create_agent( cortex: None, warmup: None, browser: None, + channel: None, mcp: None, brave_search_key: None, cron_timezone: None, diff --git a/src/api/config.rs b/src/api/config.rs index 6653fe250..7916ca7a3 100644 --- a/src/api/config.rs +++ b/src/api/config.rs @@ -75,6 +75,11 @@ pub(super) struct BrowserSection { evaluate_enabled: bool, } +#[derive(Serialize, Debug)] +pub(super) struct ChannelSection { + listen_only_mode: bool, +} + #[derive(Serialize, Debug)] pub(super) struct SandboxSection { mode: String, @@ -98,6 +103,7 @@ pub(super) struct AgentConfigResponse { coalesce: CoalesceSection, memory_persistence: MemoryPersistenceSection, browser: BrowserSection, + channel: ChannelSection, sandbox: SandboxSection, discord: DiscordSection, } @@ -127,6 +133,8 @@ pub(super) struct AgentConfigUpdateRequest { #[serde(default)] browser: Option, #[serde(default)] + channel: Option, + #[serde(default)] sandbox: Option, #[serde(default)] discord: Option, @@ -201,6 +209,11 @@ pub(super) struct BrowserUpdate { evaluate_enabled: Option, } +#[derive(Deserialize, Debug)] +pub(super) struct ChannelUpdate { + listen_only_mode: Option, +} + #[derive(Deserialize, Debug)] pub(super) struct SandboxUpdate { mode: Option, @@ -231,6 +244,7 @@ pub(super) async fn get_agent_config( let coalesce = rc.coalesce.load(); let memory_persistence = rc.memory_persistence.load(); let browser = rc.browser_config.load(); + let channel = rc.channel_config.load(); let sandbox = rc.sandbox.load(); let response = AgentConfigResponse { @@ -287,6 +301,9 @@ pub(super) async fn get_agent_config( headless: browser.headless, evaluate_enabled: browser.evaluate_enabled, }, + channel: ChannelSection { + listen_only_mode: channel.listen_only_mode, + }, sandbox: SandboxSection { mode: match sandbox.mode { crate::sandbox::SandboxMode::Enabled => "enabled".to_string(), @@ -372,6 +389,9 @@ pub(super) async fn update_agent_config( if let Some(browser) = &request.browser { update_browser_table(&mut doc, agent_idx, browser)?; } + if let Some(channel) = &request.channel { + update_channel_table(&mut doc, agent_idx, channel)?; + } if let Some(sandbox) = &request.sandbox { update_sandbox_table(&mut doc, agent_idx, sandbox)?; } @@ -675,6 +695,19 @@ fn update_browser_table( Ok(()) } +fn update_channel_table( + doc: &mut toml_edit::DocumentMut, + agent_idx: usize, + channel: &ChannelUpdate, +) -> Result<(), StatusCode> { + let agent = get_agent_table_mut(doc, agent_idx)?; + let table = get_or_create_subtable(agent, "channel")?; + if let Some(v) = channel.listen_only_mode { + table["listen_only_mode"] = toml_edit::value(v); + } + Ok(()) +} + fn update_sandbox_table( doc: &mut toml_edit::DocumentMut, agent_idx: usize, diff --git a/src/config.rs b/src/config.rs index c3a9ec8ac..96af0ec91 100644 --- a/src/config.rs +++ b/src/config.rs @@ -715,6 +715,7 @@ pub struct DefaultsConfig { pub cortex: CortexConfig, pub warmup: WarmupConfig, pub browser: BrowserConfig, + pub channel: ChannelConfig, pub mcp: Vec, /// Brave Search API key for web search tool. Supports "env:VAR_NAME" references. pub brave_search_key: Option, @@ -745,6 +746,7 @@ impl std::fmt::Debug for DefaultsConfig { .field("cortex", &self.cortex) .field("warmup", &self.warmup) .field("browser", &self.browser) + .field("channel", &self.channel) .field("mcp", &self.mcp) .field( "brave_search_key", @@ -933,6 +935,21 @@ impl Default for BrowserConfig { } } +/// Channel behavior configuration. +#[derive(Debug, Clone, Copy)] +pub struct ChannelConfig { + /// When true, unsolicited chat messages are ignored unless command/mention/reply. + pub listen_only_mode: bool, +} + +impl Default for ChannelConfig { + fn default() -> Self { + Self { + listen_only_mode: false, + } + } +} + /// OpenCode subprocess worker configuration. #[derive(Debug, Clone)] pub struct OpenCodeConfig { @@ -1156,6 +1173,7 @@ pub struct AgentConfig { pub cortex: Option, pub warmup: Option, pub browser: Option, + pub channel: Option, pub mcp: Option>, /// Per-agent Brave Search API key override. None inherits from defaults. pub brave_search_key: Option, @@ -1211,6 +1229,7 @@ pub struct ResolvedAgentConfig { pub cortex: CortexConfig, pub warmup: WarmupConfig, pub browser: BrowserConfig, + pub channel: ChannelConfig, pub mcp: Vec, pub brave_search_key: Option, pub cron_timezone: Option, @@ -1238,6 +1257,7 @@ impl Default for DefaultsConfig { cortex: CortexConfig::default(), warmup: WarmupConfig::default(), browser: BrowserConfig::default(), + channel: ChannelConfig::default(), mcp: Vec::new(), brave_search_key: None, cron_timezone: None, @@ -1301,6 +1321,7 @@ impl AgentConfig { .browser .clone() .unwrap_or_else(|| defaults.browser.clone()), + channel: self.channel.unwrap_or(defaults.channel), mcp: resolve_mcp_configs(&defaults.mcp, self.mcp.as_deref()), brave_search_key: self .brave_search_key @@ -2902,6 +2923,7 @@ struct TomlDefaultsConfig { cortex: Option, warmup: Option, browser: Option, + channel: Option, #[serde(default)] mcp: Vec, brave_search_key: Option, @@ -3049,6 +3071,7 @@ struct TomlAgentConfig { cortex: Option, warmup: Option, browser: Option, + channel: Option, mcp: Option>, brave_search_key: Option, cron_timezone: Option, @@ -3074,6 +3097,11 @@ struct TomlCronDef { timeout_secs: Option, } +#[derive(Deserialize)] +struct TomlChannelConfig { + listen_only_mode: Option, +} + fn default_enabled() -> bool { true } @@ -4179,6 +4207,7 @@ impl Config { cortex: None, warmup: None, browser: None, + channel: None, mcp: None, brave_search_key: None, cron_timezone: None, @@ -4848,6 +4877,15 @@ impl Config { ..base_defaults.browser.clone() }) }, + channel: toml + .defaults + .channel + .map(|c| ChannelConfig { + listen_only_mode: c + .listen_only_mode + .unwrap_or(base_defaults.channel.listen_only_mode), + }) + .unwrap_or(base_defaults.channel), mcp: default_mcp, brave_search_key: toml .defaults @@ -5039,6 +5077,11 @@ impl Config { .or_else(|| defaults.browser.screenshot_dir.clone()), chrome_cache_dir: defaults.browser.chrome_cache_dir.clone(), }), + channel: a.channel.map(|c| ChannelConfig { + listen_only_mode: c + .listen_only_mode + .unwrap_or(defaults.channel.listen_only_mode), + }), mcp: match a.mcp { Some(mcp_servers) => Some( mcp_servers @@ -5077,6 +5120,7 @@ impl Config { cortex: None, warmup: None, browser: None, + channel: None, mcp: None, brave_search_key: None, cron_timezone: None, @@ -5623,6 +5667,7 @@ pub struct RuntimeConfig { pub max_concurrent_branches: ArcSwap, pub max_concurrent_workers: ArcSwap, pub browser_config: ArcSwap, + pub channel_config: ArcSwap, pub mcp: ArcSwap>, pub history_backfill_count: ArcSwap, pub brave_search_key: ArcSwap>, @@ -5689,6 +5734,7 @@ impl RuntimeConfig { max_concurrent_branches: ArcSwap::from_pointee(agent_config.max_concurrent_branches), max_concurrent_workers: ArcSwap::from_pointee(agent_config.max_concurrent_workers), browser_config: ArcSwap::from_pointee(agent_config.browser.clone()), + channel_config: ArcSwap::from_pointee(agent_config.channel), mcp: ArcSwap::from_pointee(agent_config.mcp.clone()), history_backfill_count: ArcSwap::from_pointee(agent_config.history_backfill_count), brave_search_key: ArcSwap::from_pointee(agent_config.brave_search_key.clone()), @@ -5781,6 +5827,7 @@ impl RuntimeConfig { self.max_concurrent_workers .store(Arc::new(resolved.max_concurrent_workers)); self.browser_config.store(Arc::new(resolved.browser)); + self.channel_config.store(Arc::new(resolved.channel)); self.mcp.store(Arc::new(new_mcp.clone())); self.history_backfill_count .store(Arc::new(resolved.history_backfill_count)); diff --git a/src/messaging/discord.rs b/src/messaging/discord.rs index 7f2363450..623ba962b 100644 --- a/src/messaging/discord.rs +++ b/src/messaging/discord.rs @@ -766,14 +766,21 @@ impl EventHandler for Handler { } fn is_mention_or_reply_to_bot(message: &Message, bot_user_id: Option) -> bool { + is_mention_to_bot(message, bot_user_id) || is_reply_to_bot(message, bot_user_id) +} + +fn is_mention_to_bot(message: &Message, bot_user_id: Option) -> bool { let Some(bot_id) = bot_user_id else { return false; }; - let directly_mentioned = message.mentions.iter().any(|user| user.id == bot_id); - if directly_mentioned { - return true; - } + message.mentions.iter().any(|user| user.id == bot_id) +} + +fn is_reply_to_bot(message: &Message, bot_user_id: Option) -> bool { + let Some(bot_id) = bot_user_id else { + return false; + }; message .referenced_message @@ -942,6 +949,14 @@ async fn build_metadata( "discord_mentions_or_replies_to_bot".into(), is_mention_or_reply_to_bot(message, bot_user_id).into(), ); + metadata.insert( + "discord_mentioned_bot".into(), + is_mention_to_bot(message, bot_user_id).into(), + ); + metadata.insert( + "discord_reply_to_bot".into(), + is_reply_to_bot(message, bot_user_id).into(), + ); (metadata, formatted_author) } diff --git a/src/messaging/slack.rs b/src/messaging/slack.rs index 49dc17287..7e813da3a 100644 --- a/src/messaging/slack.rs +++ b/src/messaging/slack.rs @@ -260,6 +260,18 @@ async fn handle_message_event( &adapter_state.channel_name_cache, ) .await; + let mut metadata = metadata; + let mentioned_bot = msg_event + .content + .as_ref() + .and_then(|content| content.text.as_ref()) + .as_ref() + .map(|text| text.contains(&format!("<@{}>", adapter_state.bot_user_id))) + .unwrap_or(false); + metadata.insert( + "slack_mentions_or_replies_to_bot".into(), + serde_json::Value::Bool(mentioned_bot), + ); send_inbound( &adapter_state.inbound_tx, @@ -350,6 +362,11 @@ async fn handle_app_mention_event( &adapter_state.channel_name_cache, ) .await; + let mut metadata = metadata; + metadata.insert( + "slack_mentions_or_replies_to_bot".into(), + serde_json::Value::Bool(true), + ); send_inbound( &adapter_state.inbound_tx, diff --git a/src/messaging/telegram.rs b/src/messaging/telegram.rs index c6060b03e..f806e7537 100644 --- a/src/messaging/telegram.rs +++ b/src/messaging/telegram.rs @@ -874,6 +874,17 @@ fn build_metadata( } if let Some(from) = &reply.from { metadata.insert("reply_to_author".into(), build_display_name(from).into()); + metadata.insert( + "reply_to_user_id".into(), + serde_json::Value::Number(from.id.0.into()), + ); + metadata.insert( + "reply_to_is_bot".into(), + serde_json::Value::Bool(from.is_bot), + ); + if let Some(username) = &from.username { + metadata.insert("reply_to_username".into(), username.clone().into()); + } } } diff --git a/src/tools.rs b/src/tools.rs index efc37294a..1edaf9bbe 100644 --- a/src/tools.rs +++ b/src/tools.rs @@ -260,6 +260,7 @@ pub async fn add_channel_tools( state.conversation_logger.clone(), state.channel_id.clone(), replied_flag.clone(), + state.deps.agent_id.to_string(), agent_display_name, )) .await?; diff --git a/src/tools/reply.rs b/src/tools/reply.rs index 1f698527c..695e6f594 100644 --- a/src/tools/reply.rs +++ b/src/tools/reply.rs @@ -46,6 +46,7 @@ pub struct ReplyTool { channel_id: ChannelId, replied_flag: RepliedFlag, agent_display_name: String, + agent_id: String, } impl ReplyTool { @@ -56,6 +57,7 @@ impl ReplyTool { conversation_logger: ConversationLogger, channel_id: ChannelId, replied_flag: RepliedFlag, + agent_id: impl Into, agent_display_name: impl Into, ) -> Self { Self { @@ -64,11 +66,49 @@ impl ReplyTool { conversation_logger, channel_id, replied_flag, + agent_id: agent_id.into(), agent_display_name: agent_display_name.into(), } } } +fn enforce_agent_style(_agent_id: &str, content: &str) -> String { + content.to_string() +} + +fn cards_to_text(cards: &[crate::Card]) -> String { + let mut sections = Vec::new(); + for card in cards { + let mut lines = Vec::new(); + if let Some(title) = &card.title + && !title.trim().is_empty() + { + lines.push(title.trim().to_string()); + } + if let Some(description) = &card.description + && !description.trim().is_empty() + { + lines.push(description.trim().to_string()); + } + for field in &card.fields { + let name = field.name.trim(); + let value = field.value.trim(); + if !name.is_empty() || !value.is_empty() { + lines.push(format!("{name}\n{value}").trim().to_string()); + } + } + if let Some(footer) = &card.footer + && !footer.trim().is_empty() + { + lines.push(footer.trim().to_string()); + } + if !lines.is_empty() { + sections.push(lines.join("\n\n")); + } + } + sections.join("\n\n") +} + /// Error type for reply tool. #[derive(Debug, thiserror::Error)] #[error("Reply failed: {0}")] @@ -361,6 +401,21 @@ impl Tool for ReplyTool { source, ) .await; + let mut converted_content = enforce_agent_style(&self.agent_id, &converted_content); + + // Some adapters/models emit card-only payloads with empty content. + // Derive a readable plaintext fallback from cards to avoid empty-message errors. + if converted_content.trim().is_empty() + && let Some(cards) = &args.cards + { + let from_cards = cards_to_text(cards); + if !from_cards.trim().is_empty() { + converted_content = enforce_agent_style(&self.agent_id, &from_cards); + } + } + if converted_content.trim().is_empty() { + converted_content = "noted.".to_string(); + } if crate::tools::should_block_user_visible_text(&converted_content) { tracing::warn!( @@ -378,12 +433,18 @@ impl Tool for ReplyTool { Some(&self.agent_display_name), ); - let response = if let Some(ref name) = args.thread_name { + let thread_name = args + .thread_name + .as_ref() + .map(|name| name.trim()) + .filter(|name| !name.is_empty()); + + let response = if let Some(name) = thread_name { // Cap thread names at 100 characters (Discord limit) let thread_name = if name.len() > 100 { name[..name.floor_char_boundary(100)].to_string() } else { - name.clone() + name.to_string() }; OutboundResponse::ThreadReply { thread_name, @@ -391,12 +452,17 @@ impl Tool for ReplyTool { } } else if args.cards.is_some() || args.interactive_elements.is_some() || args.poll.is_some() { - OutboundResponse::RichMessage { - text: converted_content.clone(), - blocks: vec![], // No block generation for now; Slack adapters will fall back to text - cards: args.cards.unwrap_or_default(), - interactive_elements: args.interactive_elements.unwrap_or_default(), - poll: args.poll, + if source == "telegram" || source == "discord" { + // Force plain text on adapters where rich payloads can fail silently. + OutboundResponse::Text(converted_content.clone()) + } else { + OutboundResponse::RichMessage { + text: converted_content.clone(), + blocks: vec![], // No block generation for now; Slack adapters will fall back to text + cards: args.cards.unwrap_or_default(), + interactive_elements: args.interactive_elements.unwrap_or_default(), + poll: args.poll, + } } } else { OutboundResponse::Text(converted_content.clone()) From 961dc0a8f7bd1475698649f3ce2b06830e9d1e79 Mon Sep 17 00:00:00 2001 From: Deanfluence Bot Date: Tue, 3 Mar 2026 00:06:03 +0800 Subject: [PATCH 02/41] fix: sync quiet/active commands with runtime listen-only config --- src/agent/channel.rs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/agent/channel.rs b/src/agent/channel.rs index bcef933a9..0d8686b9c 100644 --- a/src/agent/channel.rs +++ b/src/agent/channel.rs @@ -313,6 +313,16 @@ impl Channel { .listen_only_mode; } + fn set_listen_only_mode(&mut self, enabled: bool) { + self.listen_only_mode = enabled; + self.deps + .runtime_config + .channel_config + .store(Arc::new(crate::config::ChannelConfig { + listen_only_mode: enabled, + })); + } + fn suppress_plaintext_fallback(&self) -> bool { matches!(self.current_adapter(), Some("email")) } @@ -431,7 +441,7 @@ impl Channel { return Ok(true); } "/quiet" => { - self.listen_only_mode = true; + self.set_listen_only_mode(true); self.send_builtin_text( "quiet mode enabled. i'll only reply to commands, @mentions, or replies to my message." .to_string(), @@ -441,7 +451,7 @@ impl Channel { return Ok(true); } "/active" => { - self.listen_only_mode = false; + self.set_listen_only_mode(false); self.send_builtin_text( "active mode enabled. i'll respond normally in this chat.".to_string(), "active", From 05be0c124b4f56d2fb0f32ba159f99af45974560 Mon Sep 17 00:00:00 2001 From: Deanfluence Bot Date: Tue, 3 Mar 2026 00:14:18 +0800 Subject: [PATCH 03/41] fix: address listen-only invoke and rich reply review findings --- interface/src/routes/AgentConfig.tsx | 2 +- src/agent/channel.rs | 16 ++++++++++ src/messaging/slack.rs | 34 +++++++++++++++++++- src/messaging/twitch.rs | 12 +++++++ src/tools/reply.rs | 47 ++++++++++++++++++++-------- 5 files changed, 96 insertions(+), 15 deletions(-) diff --git a/interface/src/routes/AgentConfig.tsx b/interface/src/routes/AgentConfig.tsx index cd9c66547..1304a67dd 100644 --- a/interface/src/routes/AgentConfig.tsx +++ b/interface/src/routes/AgentConfig.tsx @@ -65,7 +65,7 @@ export function AgentConfig({ agentId }: AgentConfigProps) { // Sync activeSection with URL search param useEffect(() => { if (search.tab) { - const validSections: SectionId[] = ["soul", "identity", "user", "routing", "tuning", "compaction", "cortex", "coalesce", "memory", "browser", "channel", "sandbox"]; + const validSections = SECTIONS.map((section) => section.id); if (validSections.includes(search.tab as SectionId)) { setActiveSection(search.tab as SectionId); } diff --git a/src/agent/channel.rs b/src/agent/channel.rs index 0d8686b9c..b99940c32 100644 --- a/src/agent/channel.rs +++ b/src/agent/channel.rs @@ -354,6 +354,11 @@ impl Channel { .get("slack_mentions_or_replies_to_bot") .and_then(|v| v.as_bool()) .unwrap_or(false), + "twitch" => message + .metadata + .get("twitch_mentions_or_replies_to_bot") + .and_then(|v| v.as_bool()) + .unwrap_or(false), _ => false, }; let invoked_by_reply = match message.source.as_str() { @@ -732,6 +737,17 @@ impl Channel { if config.multi_user_only && self.is_dm() { return false; } + // Built-in slash commands should execute immediately and never be batched. + let looks_like_command = match &message.content { + crate::MessageContent::Text(text) => text.trim_start().starts_with('/'), + crate::MessageContent::Media { text, .. } => text + .as_deref() + .is_some_and(|value| value.trim_start().starts_with('/')), + crate::MessageContent::Interaction { .. } => false, + }; + if looks_like_command { + return false; + } true } diff --git a/src/messaging/slack.rs b/src/messaging/slack.rs index 7e813da3a..32125d938 100644 --- a/src/messaging/slack.rs +++ b/src/messaging/slack.rs @@ -268,9 +268,41 @@ async fn handle_message_event( .as_ref() .map(|text| text.contains(&format!("<@{}>", adapter_state.bot_user_id))) .unwrap_or(false); + let replied_to_bot = if let Some(thread_ts) = msg_event.origin.thread_ts.as_ref() { + // For threaded replies, treat as explicit invoke only when the thread + // root message belongs to this bot. + if thread_ts.0 != ts { + let req = SlackApiConversationsRepliesRequest::new( + SlackChannelId(channel_id.clone()), + thread_ts.clone(), + ) + .with_limit(1); + match client + .open_session(&SlackApiToken::new(SlackApiTokenValue( + adapter_state.bot_token.clone(), + ))) + .conversations_replies(&req) + .await + { + Ok(response) => response + .messages + .first() + .and_then(|message| message.sender.user.as_ref()) + .is_some_and(|user| user.0 == adapter_state.bot_user_id), + Err(error) => { + tracing::debug!(%error, "failed to resolve slack thread parent for reply invoke"); + false + } + } + } else { + false + } + } else { + false + }; metadata.insert( "slack_mentions_or_replies_to_bot".into(), - serde_json::Value::Bool(mentioned_bot), + serde_json::Value::Bool(mentioned_bot || replied_to_bot), ); send_inbound( diff --git a/src/messaging/twitch.rs b/src/messaging/twitch.rs index 2f824eac2..9604f0373 100644 --- a/src/messaging/twitch.rs +++ b/src/messaging/twitch.rs @@ -288,10 +288,22 @@ impl Messaging for TwitchAdapter { "twitch_user_login".into(), serde_json::Value::String(privmsg.sender.login.clone()), ); + metadata.insert( + "twitch_bot_login".into(), + serde_json::Value::String(bot_username.clone()), + ); metadata.insert( "sender_display_name".into(), serde_json::Value::String(privmsg.sender.name.clone()), ); + let mentions_bot = privmsg + .message_text + .to_lowercase() + .contains(&format!("@{bot_username}")); + metadata.insert( + "twitch_mentions_or_replies_to_bot".into(), + serde_json::Value::Bool(mentions_bot), + ); let formatted_author = format!( "{} ({})", diff --git a/src/tools/reply.rs b/src/tools/reply.rs index 695e6f594..ba76a97d6 100644 --- a/src/tools/reply.rs +++ b/src/tools/reply.rs @@ -439,6 +439,13 @@ impl Tool for ReplyTool { .map(|name| name.trim()) .filter(|name| !name.is_empty()); + let cards_requested = args.cards.as_ref().is_some_and(|cards| !cards.is_empty()); + let interactive_requested = args + .interactive_elements + .as_ref() + .is_some_and(|elements| !elements.is_empty()); + let poll_requested = args.poll.is_some(); + let response = if let Some(name) = thread_name { // Cap thread names at 100 characters (Discord limit) let thread_name = if name.len() > 100 { @@ -450,19 +457,33 @@ impl Tool for ReplyTool { thread_name, text: converted_content.clone(), } - } else if args.cards.is_some() || args.interactive_elements.is_some() || args.poll.is_some() - { - if source == "telegram" || source == "discord" { - // Force plain text on adapters where rich payloads can fail silently. - OutboundResponse::Text(converted_content.clone()) - } else { - OutboundResponse::RichMessage { - text: converted_content.clone(), - blocks: vec![], // No block generation for now; Slack adapters will fall back to text - cards: args.cards.unwrap_or_default(), - interactive_elements: args.interactive_elements.unwrap_or_default(), - poll: args.poll, - } + } else if cards_requested || interactive_requested || poll_requested { + let supports_cards = matches!(source, "discord" | "slack"); + let supports_interactive = matches!(source, "discord" | "slack"); + let supports_poll = matches!(source, "discord" | "telegram"); + let mut unsupported = Vec::new(); + if cards_requested && !supports_cards { + unsupported.push("cards"); + } + if interactive_requested && !supports_interactive { + unsupported.push("interactive_elements"); + } + if poll_requested && !supports_poll { + unsupported.push("poll"); + } + if !unsupported.is_empty() { + return Err(ReplyError(format!( + "unsupported rich payload for source '{source}': requested unsupported fields [{}]", + unsupported.join(", ") + ))); + } + + OutboundResponse::RichMessage { + text: converted_content.clone(), + blocks: vec![], // No block generation for now; Slack adapters will fall back to text + cards: args.cards.unwrap_or_default(), + interactive_elements: args.interactive_elements.unwrap_or_default(), + poll: args.poll, } } else { OutboundResponse::Text(converted_content.clone()) From 528791266ca876e8f357c54e0f234fea297b54a6 Mon Sep 17 00:00:00 2001 From: Deanfluence Bot Date: Tue, 3 Mar 2026 00:46:54 +0800 Subject: [PATCH 04/41] fix: tighten reply error semantics and invoke metadata handling --- src/agent/channel.rs | 21 ++++++++++++++++-- src/api/config.rs | 47 ++++++++++++++++++++++++++++++++++++++++ src/messaging/discord.rs | 5 +++++ src/tools/reply.rs | 18 ++++++++++----- 4 files changed, 83 insertions(+), 8 deletions(-) diff --git a/src/agent/channel.rs b/src/agent/channel.rs index b99940c32..0b2ab3e13 100644 --- a/src/agent/channel.rs +++ b/src/agent/channel.rs @@ -333,6 +333,7 @@ impl Channel { raw_text: &str, ) -> (bool, bool, bool) { let text = raw_text.trim(); + let text_lower = text.to_lowercase(); let invoked_by_command = text.starts_with('/'); let invoked_by_mention = match message.source.as_str() { "telegram" => message @@ -340,8 +341,8 @@ impl Channel { .get("telegram_bot_username") .and_then(|v| v.as_str()) .map(|username| { - let mention = format!("@{username}"); - text.contains(&mention) + let mention = format!("@{username}").to_lowercase(); + text_lower.contains(&mention) }) .unwrap_or(false), "discord" => message @@ -369,6 +370,22 @@ impl Channel { .get("discord_reply_to_bot") .and_then(|v| v.as_bool()) .unwrap_or(false), + "telegram" => { + let bot_username = message + .metadata + .get("telegram_bot_username") + .and_then(|v| v.as_str()) + .map(str::to_lowercase); + let reply_username = message + .metadata + .get("reply_to_username") + .and_then(|v| v.as_str()) + .map(str::to_lowercase); + match (bot_username, reply_username) { + (Some(bot), Some(reply)) => bot == reply, + _ => false, + } + } _ => message .metadata .get("reply_to_is_bot") diff --git a/src/api/config.rs b/src/api/config.rs index 7916ca7a3..63ede57e4 100644 --- a/src/api/config.rs +++ b/src/api/config.rs @@ -854,4 +854,51 @@ id = "main" let result = update_warmup_table(&mut doc, agent_idx, &update); assert_eq!(result, Err(StatusCode::BAD_REQUEST)); } + + #[test] + fn test_update_channel_table_writes_listen_only_mode() { + let mut doc: toml_edit::DocumentMut = r#" +[[agents]] +id = "main" +"# + .parse() + .expect("failed to parse test TOML"); + + let agent_idx = + find_or_create_agent_table(&mut doc, "main").expect("failed to find/create agent"); + + let enable_update = ChannelUpdate { + listen_only_mode: Some(true), + }; + update_channel_table(&mut doc, agent_idx, &enable_update) + .expect("failed to update channel table with true"); + + let agent = doc + .get("agents") + .and_then(|item| item.as_array_of_tables()) + .and_then(|agents| agents.get(agent_idx)) + .expect("missing agent table"); + let channel = agent + .get("channel") + .and_then(|item| item.as_table()) + .expect("missing channel table"); + assert_eq!(channel["listen_only_mode"].as_bool(), Some(true)); + + let disable_update = ChannelUpdate { + listen_only_mode: Some(false), + }; + update_channel_table(&mut doc, agent_idx, &disable_update) + .expect("failed to update channel table with false"); + + let agent = doc + .get("agents") + .and_then(|item| item.as_array_of_tables()) + .and_then(|agents| agents.get(agent_idx)) + .expect("missing agent table"); + let channel = agent + .get("channel") + .and_then(|item| item.as_table()) + .expect("missing channel table"); + assert_eq!(channel["listen_only_mode"].as_bool(), Some(false)); + } } diff --git a/src/messaging/discord.rs b/src/messaging/discord.rs index 623ba962b..a733f9a49 100644 --- a/src/messaging/discord.rs +++ b/src/messaging/discord.rs @@ -726,6 +726,11 @@ impl EventHandler for Handler { "discord_mentions_or_replies_to_bot".into(), serde_json::Value::Bool(true), ); + metadata.insert( + "discord_mentioned_bot".into(), + serde_json::Value::Bool(false), + ); + metadata.insert("discord_reply_to_bot".into(), serde_json::Value::Bool(true)); if let Some(guild_id) = component.guild_id { metadata.insert( "discord_guild_id".into(), diff --git a/src/tools/reply.rs b/src/tools/reply.rs index ba76a97d6..289f9826f 100644 --- a/src/tools/reply.rs +++ b/src/tools/reply.rs @@ -427,12 +427,6 @@ impl Tool for ReplyTool { )); } - self.conversation_logger.log_bot_message_with_name( - &self.channel_id, - &converted_content, - Some(&self.agent_display_name), - ); - let thread_name = args .thread_name .as_ref() @@ -446,6 +440,12 @@ impl Tool for ReplyTool { .is_some_and(|elements| !elements.is_empty()); let poll_requested = args.poll.is_some(); + if thread_name.is_some() && (cards_requested || interactive_requested || poll_requested) { + return Err(ReplyError( + "thread replies do not support cards, interactive_elements, or polls".into(), + )); + } + let response = if let Some(name) = thread_name { // Cap thread names at 100 characters (Discord limit) let thread_name = if name.len() > 100 { @@ -494,6 +494,12 @@ impl Tool for ReplyTool { .await .map_err(|e| ReplyError(format!("failed to send reply: {e}")))?; + self.conversation_logger.log_bot_message_with_name( + &self.channel_id, + &converted_content, + Some(&self.agent_display_name), + ); + // Mark the turn as handled so handle_agent_result skips the fallback send. self.replied_flag.store(true, Ordering::Relaxed); From 11b244582d6ab3f92106211ee545fbdb17d042a5 Mon Sep 17 00:00:00 2001 From: Deanfluence Bot Date: Tue, 3 Mar 2026 00:58:08 +0800 Subject: [PATCH 05/41] fix: polish quiet-mode invoke handling and slack mention check --- src/agent/channel.rs | 14 +++++++------- src/messaging/slack.rs | 4 ++-- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/agent/channel.rs b/src/agent/channel.rs index 0b2ab3e13..1a0066a10 100644 --- a/src/agent/channel.rs +++ b/src/agent/channel.rs @@ -1179,7 +1179,8 @@ impl Channel { .get("telegram_bot_username") .and_then(|v| v.as_str()) .map(|u| format!("@{u}")) - .unwrap_or_default(); + .unwrap_or_default() + .to_lowercase(); let has_mention = !mention.is_empty() && text.contains(&mention); let looks_like_ping = text.contains("you here") || text.contains("ping") @@ -1251,12 +1252,6 @@ impl Channel { let message_timestamp = temporal_context.format_timestamp(message.timestamp); let user_text = format_user_message(&rewritten_text, &message, &message_timestamp); - let attachment_content = if !attachments.is_empty() { - download_attachments(&self.deps, &attachments).await - } else { - Vec::new() - }; - // Persist user messages (skip system re-triggers) if message.source != "system" { let sender_name = message @@ -1338,6 +1333,11 @@ impl Channel { } let is_retrigger = message.source == "system"; + let attachment_content = if !attachments.is_empty() { + download_attachments(&self.deps, &attachments).await + } else { + Vec::new() + }; let (result, skip_flag, replied_flag, retrigger_reply_preserved) = self .run_agent_turn( diff --git a/src/messaging/slack.rs b/src/messaging/slack.rs index 32125d938..e7cbb1093 100644 --- a/src/messaging/slack.rs +++ b/src/messaging/slack.rs @@ -261,12 +261,12 @@ async fn handle_message_event( ) .await; let mut metadata = metadata; + let bot_mention = format!("<@{}>", adapter_state.bot_user_id); let mentioned_bot = msg_event .content .as_ref() .and_then(|content| content.text.as_ref()) - .as_ref() - .map(|text| text.contains(&format!("<@{}>", adapter_state.bot_user_id))) + .map(|text| text.contains(&bot_mention)) .unwrap_or(false); let replied_to_bot = if let Some(thread_ts) = msg_event.origin.thread_ts.as_ref() { // For threaded replies, treat as explicit invoke only when the thread From baa3264070d3a0bf7c9be882eeb600ba8a7377c9 Mon Sep 17 00:00:00 2001 From: Deanfluence Bot Date: Tue, 3 Mar 2026 01:11:13 +0800 Subject: [PATCH 06/41] fix: align builtin reply logging and discord invoke metadata --- src/agent/channel.rs | 85 +++++++++++++------------------------------- 1 file changed, 25 insertions(+), 60 deletions(-) diff --git a/src/agent/channel.rs b/src/agent/channel.rs index 1a0066a10..0015b30a0 100644 --- a/src/agent/channel.rs +++ b/src/agent/channel.rs @@ -397,14 +397,19 @@ impl Channel { } async fn send_builtin_text(&mut self, text: String, log_label: &str) { + if let Err(error) = self + .response_tx + .send(OutboundResponse::Text(text.clone())) + .await + { + tracing::error!(%error, channel_id = %self.id, %log_label, "failed to send built-in reply"); + return; + } self.state.conversation_logger.log_bot_message_with_name( &self.state.channel_id, &text, Some(self.agent_display_name()), ); - if let Err(error) = self.response_tx.send(OutboundResponse::Text(text)).await { - tracing::error!(%error, channel_id = %self.id, %log_label, "failed to send built-in reply"); - } } async fn try_handle_builtin_ops_commands( @@ -644,18 +649,19 @@ impl Channel { } }; - self.state.conversation_logger.log_bot_message_with_name( - &self.state.channel_id, - &reply_text, - Some(self.agent_display_name()), - ); if let Err(error) = self .response_tx - .send(OutboundResponse::Text(reply_text)) + .send(OutboundResponse::Text(reply_text.clone())) .await { tracing::error!(%error, channel_id = %self.id, "failed to send builtin /digest reply"); + return Ok(true); } + self.state.conversation_logger.log_bot_message_with_name( + &self.state.channel_id, + &reply_text, + Some(self.agent_display_name()), + ); Ok(true) } @@ -1154,19 +1160,8 @@ impl Channel { // Deterministic built-in command: bypass model output drift for agent identity checks. if message.source != "system" && raw_text.trim() == "/agent-id" { - let agent_id = self.deps.agent_id.to_string(); - self.state.conversation_logger.log_bot_message_with_name( - &self.state.channel_id, - &agent_id, - Some(self.agent_display_name()), - ); - if let Err(error) = self - .response_tx - .send(OutboundResponse::Text(agent_id)) - .await - { - tracing::error!(%error, channel_id = %self.id, "failed to send built-in /agent-id reply"); - } + self.send_builtin_text(self.deps.agent_id.to_string(), "agent-id") + .await; return Ok(()); } @@ -1190,15 +1185,8 @@ impl Channel { || text.contains("there?"); if has_mention && looks_like_ping { - let ack = "yeah i'm here".to_string(); - self.state.conversation_logger.log_bot_message_with_name( - &self.state.channel_id, - &ack, - Some(self.agent_display_name()), - ); - if let Err(error) = self.response_tx.send(OutboundResponse::Text(ack)).await { - tracing::error!(%error, channel_id = %self.id, "failed to send built-in telegram ping reply"); - } + self.send_builtin_text("yeah i'm here".to_string(), "telegram-ping") + .await; return Ok(()); } } @@ -1207,16 +1195,9 @@ impl Channel { // flaky model behavior (e.g. skipping or over-formatting simple liveness checks). if message.source == "discord" && self.listen_only_mode && message.source != "system" { let text = raw_text.trim().to_lowercase(); - let directed = message - .metadata - .get("discord_mentions_or_replies_to_bot") - .and_then(|v| v.as_bool()) - .unwrap_or(false) - || message - .metadata - .get("reply_to_is_bot") - .and_then(|v| v.as_bool()) - .unwrap_or(false); + let (_, invoked_by_mention, invoked_by_reply) = + self.compute_listen_mode_invocation(&message, &raw_text); + let directed = invoked_by_mention || invoked_by_reply; let looks_like_ping = text.contains("you here") || text.contains("ping") || text.ends_with(" yo") @@ -1224,29 +1205,13 @@ impl Channel { || text.contains("alive") || text.contains("there?"); if directed && looks_like_ping { - let ack = "yeah i'm here".to_string(); - self.state.conversation_logger.log_bot_message_with_name( - &self.state.channel_id, - &ack, - Some(self.agent_display_name()), - ); - if let Err(error) = self.response_tx.send(OutboundResponse::Text(ack)).await { - tracing::error!(%error, channel_id = %self.id, "failed to send built-in discord ping reply"); - } + self.send_builtin_text("yeah i'm here".to_string(), "discord-ping") + .await; return Ok(()); } } - let rewritten_text = if message.source == "telegram" { - match raw_text.trim() { - "/digest" => { - "generate a day digest for this channel for today (from local 00:00 to now), concise and useful. preferred format and order: 1) top decisions (what was decided), 2) key convo themes (high-level discussion summary), 3) open loops (pending calls/questions). keep it short, practical, and non-cringe. if there is no meaningful activity in that window, say exactly: no material updates today.".to_string() - } - _ => raw_text.clone(), - } - } else { - raw_text.clone() - }; + let rewritten_text = raw_text.clone(); let temporal_context = TemporalContext::from_runtime(self.deps.runtime_config.as_ref()); let message_timestamp = temporal_context.format_timestamp(message.timestamp); From 1a02036aa88e0894844531c27bbd98e9b82069ec Mon Sep 17 00:00:00 2001 From: Deanfluence Bot Date: Tue, 3 Mar 2026 01:17:13 +0800 Subject: [PATCH 07/41] fix: defer batch attachment downloads until invoke pass --- src/agent/channel.rs | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/src/agent/channel.rs b/src/agent/channel.rs index 0015b30a0..452a098f1 100644 --- a/src/agent/channel.rs +++ b/src/agent/channel.rs @@ -919,7 +919,7 @@ impl Channel { } // Persist each message to conversation log (individual audit trail) - let mut user_contents: Vec = Vec::new(); + let mut pending_batch_entries: Vec<(String, Vec<_>)> = Vec::new(); let mut conversation_id = String::new(); let temporal_context = TemporalContext::from_runtime(self.deps.runtime_config.as_ref()); let mut batch_has_invoke = false; @@ -986,15 +986,7 @@ impl Channel { &raw_text, ); - // Download attachments for this message - if !attachments.is_empty() { - let attachment_content = download_attachments(&self.deps, &attachments).await; - for content in attachment_content { - user_contents.push(content); - } - } - - user_contents.push(UserContent::text(formatted_text)); + pending_batch_entries.push((formatted_text, attachments)); } } @@ -1010,6 +1002,17 @@ impl Channel { return Ok(()); } + let mut user_contents: Vec = Vec::new(); + for (formatted_text, attachments) in pending_batch_entries { + if !attachments.is_empty() { + let attachment_content = download_attachments(&self.deps, &attachments).await; + for content in attachment_content { + user_contents.push(content); + } + } + user_contents.push(UserContent::text(formatted_text)); + } + // Separate text and non-text (image/audio) content let mut text_parts = Vec::new(); let mut attachment_parts = Vec::new(); From d311875d115224f215f14315bb0c3354546aa215 Mon Sep 17 00:00:00 2001 From: Deanfluence Bot Date: Tue, 3 Mar 2026 01:24:14 +0800 Subject: [PATCH 08/41] fix: finalize quiet-mode fallback send semantics cleanup --- src/agent/channel.rs | 26 ++++++++------------------ 1 file changed, 8 insertions(+), 18 deletions(-) diff --git a/src/agent/channel.rs b/src/agent/channel.rs index 452a098f1..2d4a8f8f7 100644 --- a/src/agent/channel.rs +++ b/src/agent/channel.rs @@ -1170,7 +1170,7 @@ impl Channel { // Deterministic liveness ping for Telegram mentions. // This avoids model/provider flakiness for simple "you there?" style checks. - if message.source == "telegram" && message.source != "system" { + if message.source == "telegram" { let text = raw_text.trim().to_lowercase(); let mention = message .metadata @@ -1196,7 +1196,7 @@ impl Channel { // Deterministic ping ack for Discord quiet-mode mentions/replies to avoid // flaky model behavior (e.g. skipping or over-formatting simple liveness checks). - if message.source == "discord" && self.listen_only_mode && message.source != "system" { + if message.source == "discord" && self.listen_only_mode { let text = raw_text.trim().to_lowercase(); let (_, invoked_by_mention, invoked_by_reply) = self.compute_listen_mode_invocation(&message, &raw_text); @@ -1214,11 +1214,9 @@ impl Channel { } } - let rewritten_text = raw_text.clone(); - let temporal_context = TemporalContext::from_runtime(self.deps.runtime_config.as_ref()); let message_timestamp = temporal_context.format_timestamp(message.timestamp); - let user_text = format_user_message(&rewritten_text, &message, &message_timestamp); + let user_text = format_user_message(&raw_text, &message, &message_timestamp); // Persist user messages (skip system re-triggers) if message.source != "system" { @@ -1332,19 +1330,11 @@ impl Channel { "discord" | "telegram" | "slack" | "twitch" ) { - let ack = "yeah i'm here — tell me what you need.".to_string(); - self.state.conversation_logger.log_bot_message_with_name( - &self.state.channel_id, - &ack, - Some(self.agent_display_name()), - ); - if let Err(error) = self.response_tx.send(OutboundResponse::Text(ack)).await { - tracing::error!( - %error, - channel_id = %self.id, - "failed to send quiet-mode explicit invoke fallback" - ); - } + self.send_builtin_text( + "yeah i'm here — tell me what you need.".to_string(), + "quiet-mode-fallback", + ) + .await; } // After retrigger turns, persist a fallback summary only when we don't From afc73d5177831ff981b9f0178cff804ec9071c73 Mon Sep 17 00:00:00 2001 From: Deanfluence Bot Date: Tue, 3 Mar 2026 01:34:40 +0800 Subject: [PATCH 09/41] fix: only advertise /digest in telegram help output --- src/agent/channel.rs | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/src/agent/channel.rs b/src/agent/channel.rs index 2d4a8f8f7..d29874180 100644 --- a/src/agent/channel.rs +++ b/src/agent/channel.rs @@ -551,15 +551,19 @@ impl Channel { return Ok(true); } "/help" => { - let body = "commands:\n\ - - /status: current mode, models, binding snapshot\n\ - - /today: in-progress + ready task snapshot\n\ - - /tasks: ready task list\n\ - - /quiet: listen-only mode\n\ - - /active: normal reply mode\n\ - - /digest: one-shot day digest (00:00 -> now)\n\ - - /agent-id: runtime agent id" - .to_string(); + let mut lines = vec![ + "commands:".to_string(), + "- /status: current mode, models, binding snapshot".to_string(), + "- /today: in-progress + ready task snapshot".to_string(), + "- /tasks: ready task list".to_string(), + "- /quiet: listen-only mode".to_string(), + "- /active: normal reply mode".to_string(), + "- /agent-id: runtime agent id".to_string(), + ]; + if message.source == "telegram" { + lines.push("- /digest: one-shot day digest (00:00 -> now)".to_string()); + } + let body = lines.join("\n"); self.send_builtin_text(body, "help").await; return Ok(true); } From 54dad94fc6ef6d73c18b000ad0a2a125324c2aa8 Mon Sep 17 00:00:00 2001 From: Deanfluence Bot Date: Tue, 3 Mar 2026 08:24:33 +0800 Subject: [PATCH 10/41] refactor: route task and digest slash commands through agent flow --- src/agent/channel.rs | 221 ++++++++---------------------------- src/agent/channel_prompt.rs | 11 +- 2 files changed, 47 insertions(+), 185 deletions(-) diff --git a/src/agent/channel.rs b/src/agent/channel.rs index d29874180..b3425b310 100644 --- a/src/agent/channel.rs +++ b/src/agent/channel.rs @@ -315,18 +315,49 @@ impl Channel { fn set_listen_only_mode(&mut self, enabled: bool) { self.listen_only_mode = enabled; + let mut next = (*self.deps.runtime_config.channel_config.load().as_ref()).clone(); + next.listen_only_mode = enabled; self.deps .runtime_config .channel_config - .store(Arc::new(crate::config::ChannelConfig { - listen_only_mode: enabled, - })); + .store(Arc::new(next)); } fn suppress_plaintext_fallback(&self) -> bool { matches!(self.current_adapter(), Some("email")) } + fn rewrite_tool_routed_command_prompt(&self, raw_text: &str) -> Option { + match raw_text.trim() { + "/tasks" => Some( + "use channel tools to fetch my ready tasks (limit 10) and reply exactly with:\n\ + - header: tasks (ready):\n\ + - each line: - # [] \n\ + if no tasks are ready, reply exactly: tasks (ready): none" + .to_string(), + ), + "/today" => Some( + "use channel tools to build a local tasks snapshot and reply exactly in this format:\n\ + - first line: today (local tasks snapshot):\n\ + - section 1: in-progress tasks (up to 5), each line: #<task_number> [<priority>] <title>\n\ + - section 2: up next ready tasks (up to 5), each line: #<task_number> [<priority>] <title>\n\ + if a section is empty use:\n\ + - in progress: none\n\ + - up next (ready): none" + .to_string(), + ), + "/digest" => Some( + "using available tools and channel context, generate a concise day digest from local 00:00 to now with exactly this order:\n\ + 1) top decisions\n\ + 2) key convo themes\n\ + 3) open loops\n\ + keep it practical and concise; if there are no meaningful updates, reply exactly: no material updates today." + .to_string(), + ), + _ => None, + } + } + fn compute_listen_mode_invocation( &self, message: &InboundMessage, @@ -486,83 +517,17 @@ impl Channel { .await; return Ok(true); } - "/tasks" => { - let ready = self - .deps - .task_store - .list_ready(self.deps.agent_id.as_ref(), 10) - .await?; - let body = if ready.is_empty() { - "tasks (ready): none".to_string() - } else { - let mut lines = vec!["tasks (ready):".to_string()]; - for task in ready { - lines.push(format!( - "- #{} [{}] {}", - task.task_number, task.priority, task.title - )); - } - lines.join("\n") - }; - self.send_builtin_text(body, "tasks").await; - return Ok(true); - } - "/today" => { - let in_progress = self - .deps - .task_store - .list( - self.deps.agent_id.as_ref(), - Some(crate::tasks::TaskStatus::InProgress), - None, - 5, - ) - .await?; - let ready = self - .deps - .task_store - .list_ready(self.deps.agent_id.as_ref(), 5) - .await?; - - let mut lines = vec!["today (local tasks snapshot):".to_string()]; - if in_progress.is_empty() { - lines.push("- in progress: none".to_string()); - } else { - lines.push("- in progress:".to_string()); - for task in in_progress { - lines.push(format!( - " #{} [{}] {}", - task.task_number, task.priority, task.title - )); - } - } - if ready.is_empty() { - lines.push("- up next (ready): none".to_string()); - } else { - lines.push("- up next (ready):".to_string()); - for task in ready { - lines.push(format!( - " #{} [{}] {}", - task.task_number, task.priority, task.title - )); - } - } - self.send_builtin_text(lines.join("\n"), "today").await; - return Ok(true); - } "/help" => { - let mut lines = vec![ + let lines = vec![ "commands:".to_string(), "- /status: current mode, models, binding snapshot".to_string(), "- /today: in-progress + ready task snapshot".to_string(), "- /tasks: ready task list".to_string(), + "- /digest: one-shot day digest (00:00 -> now)".to_string(), "- /quiet: listen-only mode".to_string(), "- /active: normal reply mode".to_string(), "- /agent-id: runtime agent id".to_string(), ]; - if message.source == "telegram" { - lines.push("- /digest: one-shot day digest (00:00 -> now)".to_string()); - } let body = lines.join("\n"); self.send_builtin_text(body, "help").await; return Ok(true); @@ -573,103 +538,6 @@ impl Channel { Ok(false) } - async fn try_handle_builtin_digest( - &mut self, - raw_text: &str, - message: &InboundMessage, - ) -> Result<bool> { - if message.source != "telegram" - || message.source == "system" - || raw_text.trim() != "/digest" - { - return Ok(false); - } - - let temporal_context = TemporalContext::from_runtime(self.deps.runtime_config.as_ref()); - let today_local = temporal_context.local_date(temporal_context.now_utc); - - let all_messages = self - .state - .conversation_logger - .load_recent(&self.state.channel_id, 400) - .await?; - - let mut transcript = String::new(); - for item in all_messages { - if item.role != "user" { - continue; - } - if temporal_context.local_date(item.created_at) != today_local { - continue; - } - let sender = item.sender_name.unwrap_or_else(|| "user".to_string()); - let content = item.content.trim(); - if content.is_empty() || content.eq_ignore_ascii_case("/digest") { - continue; - } - let ts = temporal_context.format_timestamp(item.created_at); - let line = format!("[{}] {}: {}\n", ts, sender, content); - if transcript.len() + line.len() > 12000 { - break; - } - transcript.push_str(&line); - } - - let reply_text = if transcript.trim().is_empty() { - "no material updates today.".to_string() - } else { - let routing = self.deps.runtime_config.routing.load(); - let model_name = routing.resolve(ProcessType::Channel, None).to_string(); - let model = SpacebotModel::make(&self.deps.llm_manager, &model_name) - .with_context(&*self.deps.agent_id, "channel") - .with_routing((**routing).clone()); - let agent = AgentBuilder::new(model) - .preamble("you write crisp internal marketing digests. output plain text only.") - .default_max_turns(1) - .build(); - let prompt = format!( - "summarize this channel's messages from local 00:00 to now.\n\ - output exactly in this order:\n\ - 1) top decisions\n\ - 2) key convo themes\n\ - 3) open loops\n\ - keep it concise and practical.\n\ - if there's no meaningful activity, reply exactly: no material updates today.\n\n\ - transcript:\n{transcript}" - ); - match agent.prompt(&prompt).await { - Ok(text) => { - let trimmed = text.trim(); - if trimmed.is_empty() { - "no material updates today.".to_string() - } else { - trimmed.to_string() - } - } - Err(error) => { - tracing::warn!(%error, channel_id = %self.id, "builtin /digest summarizer failed"); - "no material updates today.".to_string() - } - } - }; - - if let Err(error) = self - .response_tx - .send(OutboundResponse::Text(reply_text.clone())) - .await - { - tracing::error!(%error, channel_id = %self.id, "failed to send builtin /digest reply"); - return Ok(true); - } - self.state.conversation_logger.log_bot_message_with_name( - &self.state.channel_id, - &reply_text, - Some(self.agent_display_name()), - ); - - Ok(true) - } - /// Run the channel event loop. pub async fn run(mut self) -> Result<()> { tracing::info!(channel_id = %self.id, "channel started"); @@ -1218,10 +1086,6 @@ impl Channel { } } - let temporal_context = TemporalContext::from_runtime(self.deps.runtime_config.as_ref()); - let message_timestamp = temporal_context.format_timestamp(message.timestamp); - let user_text = format_user_message(&raw_text, &message, &message_timestamp); - // Persist user messages (skip system re-triggers) if message.source != "system" { let sender_name = message @@ -1266,9 +1130,16 @@ impl Channel { return Ok(()); } - if self.try_handle_builtin_digest(&raw_text, &message).await? { - return Ok(()); - } + let rewritten_text = if message.source == "system" { + raw_text.clone() + } else { + self.rewrite_tool_routed_command_prompt(&raw_text) + .unwrap_or_else(|| raw_text.clone()) + }; + + let temporal_context = TemporalContext::from_runtime(self.deps.runtime_config.as_ref()); + let message_timestamp = temporal_context.format_timestamp(message.timestamp); + let user_text = format_user_message(&rewritten_text, &message, &message_timestamp); let mut invoked_by_command = false; let mut invoked_by_mention = false; diff --git a/src/agent/channel_prompt.rs b/src/agent/channel_prompt.rs index c292068c8..196a249c0 100644 --- a/src/agent/channel_prompt.rs +++ b/src/agent/channel_prompt.rs @@ -5,7 +5,7 @@ //! system prompt from identity, memory bulletin, skills, status, etc. use crate::error::Result; -use chrono::{DateTime, Local, NaiveDate, Utc}; +use chrono::{DateTime, Local, Utc}; use chrono_tz::Tz; /// Debounce window for retriggers: coalesce rapid branch/worker completions @@ -123,15 +123,6 @@ impl TemporalContext { ) } - pub(crate) fn local_date(&self, timestamp: DateTime<Utc>) -> NaiveDate { - match &self.timezone { - TemporalTimezone::Named { timezone, .. } => { - timestamp.with_timezone(timezone).date_naive() - } - TemporalTimezone::SystemLocal => timestamp.with_timezone(&Local).date_naive(), - } - } - pub(crate) fn worker_task_preamble( &self, prompt_engine: &crate::prompts::PromptEngine, From f44d664aca5cbd89fd5442f8ea9205da8447c507 Mon Sep 17 00:00:00 2001 From: Deanfluence Bot <deanfluencebot@gmail.com> Date: Tue, 3 Mar 2026 08:35:14 +0800 Subject: [PATCH 11/41] fix: make listen-only config update atomic with rcu --- src/agent/channel.rs | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/agent/channel.rs b/src/agent/channel.rs index b3425b310..7b43a1348 100644 --- a/src/agent/channel.rs +++ b/src/agent/channel.rs @@ -315,12 +315,11 @@ impl Channel { fn set_listen_only_mode(&mut self, enabled: bool) { self.listen_only_mode = enabled; - let mut next = (*self.deps.runtime_config.channel_config.load().as_ref()).clone(); - next.listen_only_mode = enabled; - self.deps - .runtime_config - .channel_config - .store(Arc::new(next)); + self.deps.runtime_config.channel_config.rcu(|current| { + let mut next = (**current).clone(); + next.listen_only_mode = enabled; + Arc::new(next) + }); } fn suppress_plaintext_fallback(&self) -> bool { From 6533378e8abe485503508885e681ca773bbf0528 Mon Sep 17 00:00:00 2001 From: Deanfluence Bot <deanfluencebot@gmail.com> Date: Tue, 3 Mar 2026 14:32:54 +0800 Subject: [PATCH 12/41] chore: sync lockfile after merging main --- Cargo.lock | 1 + 1 file changed, 1 insertion(+) diff --git a/Cargo.lock b/Cargo.lock index 9be359324..b6a09a55d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8496,6 +8496,7 @@ dependencies = [ "opentelemetry-otlp", "opentelemetry-semantic-conventions", "opentelemetry_sdk", + "parking_lot", "pdf-extract", "pin-project", "prometheus", From 2253026d0ee4852385831cf2b5d92b851d5af700 Mon Sep 17 00:00:00 2001 From: Deanfluence Bot <deanfluencebot@gmail.com> Date: Tue, 3 Mar 2026 14:39:14 +0800 Subject: [PATCH 13/41] fix: preserve runtime listen-only mode on config reload --- src/config.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/config.rs b/src/config.rs index 3a98aea67..dcb3424ef 100644 --- a/src/config.rs +++ b/src/config.rs @@ -5827,7 +5827,12 @@ impl RuntimeConfig { self.max_concurrent_workers .store(Arc::new(resolved.max_concurrent_workers)); self.browser_config.store(Arc::new(resolved.browser)); - self.channel_config.store(Arc::new(resolved.channel)); + // Preserve runtime/API-updated listen_only_mode across file reloads. + // This maintains effective precedence: env/file defaults < runtime persisted value. + let persisted_listen_only_mode = self.channel_config.load().listen_only_mode; + self.channel_config.store(Arc::new(ChannelConfig { + listen_only_mode: persisted_listen_only_mode, + })); self.mcp.store(Arc::new(new_mcp.clone())); self.history_backfill_count .store(Arc::new(resolved.history_backfill_count)); From 3cd0822a89ba78b8360cf87436277ba0b5e955d5 Mon Sep 17 00:00:00 2001 From: Deanfluence Bot <deanfluencebot@gmail.com> Date: Tue, 3 Mar 2026 14:49:48 +0800 Subject: [PATCH 14/41] fix: make channel reload updates atomic and dedupe resolver --- src/config.rs | 45 +++++++++++++++++++++++++-------------------- 1 file changed, 25 insertions(+), 20 deletions(-) diff --git a/src/config.rs b/src/config.rs index dcb3424ef..cc30aae15 100644 --- a/src/config.rs +++ b/src/config.rs @@ -3102,6 +3102,19 @@ struct TomlChannelConfig { listen_only_mode: Option<bool>, } +fn resolve_channel_config( + configured: Option<TomlChannelConfig>, + default: ChannelConfig, +) -> ChannelConfig { + let mut resolved = default; + if let Some(configured) = configured + && let Some(listen_only_mode) = configured.listen_only_mode + { + resolved.listen_only_mode = listen_only_mode; + } + resolved +} + fn default_enabled() -> bool { true } @@ -4877,15 +4890,7 @@ impl Config { ..base_defaults.browser.clone() }) }, - channel: toml - .defaults - .channel - .map(|c| ChannelConfig { - listen_only_mode: c - .listen_only_mode - .unwrap_or(base_defaults.channel.listen_only_mode), - }) - .unwrap_or(base_defaults.channel), + channel: resolve_channel_config(toml.defaults.channel, base_defaults.channel), mcp: default_mcp, brave_search_key: toml .defaults @@ -5077,11 +5082,9 @@ impl Config { .or_else(|| defaults.browser.screenshot_dir.clone()), chrome_cache_dir: defaults.browser.chrome_cache_dir.clone(), }), - channel: a.channel.map(|c| ChannelConfig { - listen_only_mode: c - .listen_only_mode - .unwrap_or(defaults.channel.listen_only_mode), - }), + channel: a + .channel + .map(|channel| resolve_channel_config(Some(channel), defaults.channel)), mcp: match a.mcp { Some(mcp_servers) => Some( mcp_servers @@ -5827,12 +5830,14 @@ impl RuntimeConfig { self.max_concurrent_workers .store(Arc::new(resolved.max_concurrent_workers)); self.browser_config.store(Arc::new(resolved.browser)); - // Preserve runtime/API-updated listen_only_mode across file reloads. - // This maintains effective precedence: env/file defaults < runtime persisted value. - let persisted_listen_only_mode = self.channel_config.load().listen_only_mode; - self.channel_config.store(Arc::new(ChannelConfig { - listen_only_mode: persisted_listen_only_mode, - })); + // Preserve runtime/API-updated listen_only_mode across reloads without + // racing against concurrent channel_config updates. + let resolved_channel = resolved.channel; + self.channel_config.rcu(move |current| { + let mut next = resolved_channel; + next.listen_only_mode = current.listen_only_mode; + Arc::new(next) + }); self.mcp.store(Arc::new(new_mcp.clone())); self.history_backfill_count .store(Arc::new(resolved.history_backfill_count)); From 987ba1993fcd53229fcca39eb424fce2479c71fe Mon Sep 17 00:00:00 2001 From: Deanfluence Bot <deanfluencebot@gmail.com> Date: Tue, 3 Mar 2026 17:43:25 +0800 Subject: [PATCH 15/41] fix: apply resolved channel config atomically on reload --- src/config.rs | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/src/config.rs b/src/config.rs index cc30aae15..1024a0039 100644 --- a/src/config.rs +++ b/src/config.rs @@ -5830,14 +5830,9 @@ impl RuntimeConfig { self.max_concurrent_workers .store(Arc::new(resolved.max_concurrent_workers)); self.browser_config.store(Arc::new(resolved.browser)); - // Preserve runtime/API-updated listen_only_mode across reloads without - // racing against concurrent channel_config updates. + // Apply the resolved channel config atomically on reload. let resolved_channel = resolved.channel; - self.channel_config.rcu(move |current| { - let mut next = resolved_channel; - next.listen_only_mode = current.listen_only_mode; - Arc::new(next) - }); + self.channel_config.rcu(move |_| Arc::new(resolved_channel)); self.mcp.store(Arc::new(new_mcp.clone())); self.history_backfill_count .store(Arc::new(resolved.history_backfill_count)); From cc0a3d02fb1a1911ccfeed8ee12f20063c3ece2d Mon Sep 17 00:00:00 2001 From: Deanfluence Bot <deanfluencebot@gmail.com> Date: Tue, 3 Mar 2026 18:07:28 +0800 Subject: [PATCH 16/41] fix: enforce listen-only precedence resolver on reload --- src/config.rs | 45 +++++++++++++++++++++++++++++++++------------ 1 file changed, 33 insertions(+), 12 deletions(-) diff --git a/src/config.rs b/src/config.rs index 1024a0039..7f2f645b2 100644 --- a/src/config.rs +++ b/src/config.rs @@ -3104,15 +3104,24 @@ struct TomlChannelConfig { fn resolve_channel_config( configured: Option<TomlChannelConfig>, + persisted: Option<ChannelConfig>, default: ChannelConfig, ) -> ChannelConfig { - let mut resolved = default; - if let Some(configured) = configured - && let Some(listen_only_mode) = configured.listen_only_mode - { - resolved.listen_only_mode = listen_only_mode; + ChannelConfig { + listen_only_mode: resolve_listen_only_mode( + configured.and_then(|config| config.listen_only_mode), + persisted.map(|config| config.listen_only_mode), + default.listen_only_mode, + ), } - resolved +} + +fn resolve_listen_only_mode( + configured: Option<bool>, + persisted: Option<bool>, + default: bool, +) -> bool { + configured.or(persisted).unwrap_or(default) } fn default_enabled() -> bool { @@ -4890,7 +4899,7 @@ impl Config { ..base_defaults.browser.clone() }) }, - channel: resolve_channel_config(toml.defaults.channel, base_defaults.channel), + channel: resolve_channel_config(toml.defaults.channel, None, base_defaults.channel), mcp: default_mcp, brave_search_key: toml .defaults @@ -5082,9 +5091,9 @@ impl Config { .or_else(|| defaults.browser.screenshot_dir.clone()), chrome_cache_dir: defaults.browser.chrome_cache_dir.clone(), }), - channel: a - .channel - .map(|channel| resolve_channel_config(Some(channel), defaults.channel)), + channel: a.channel.map(|channel| { + resolve_channel_config(Some(channel), None, defaults.channel) + }), mcp: match a.mcp { Some(mcp_servers) => Some( mcp_servers @@ -5830,9 +5839,21 @@ impl RuntimeConfig { self.max_concurrent_workers .store(Arc::new(resolved.max_concurrent_workers)); self.browser_config.store(Arc::new(resolved.browser)); - // Apply the resolved channel config atomically on reload. + // Apply the resolved channel config atomically on reload while keeping + // explicit precedence: configured/env > persisted runtime(DB) > default. let resolved_channel = resolved.channel; - self.channel_config.rcu(move |_| Arc::new(resolved_channel)); + let persisted_listen_only_mode = self.channel_config.load().listen_only_mode; + let default_channel = config.defaults.channel; + let configured_listen_only = agent.channel.map(|channel| channel.listen_only_mode); + self.channel_config.rcu(move |_| { + let mut next = resolved_channel; + next.listen_only_mode = resolve_listen_only_mode( + configured_listen_only, + Some(persisted_listen_only_mode), + default_channel.listen_only_mode, + ); + Arc::new(next) + }); self.mcp.store(Arc::new(new_mcp.clone())); self.history_backfill_count .store(Arc::new(resolved.history_backfill_count)); From dd3432983c990ce7a621f653fbd6ec4168fde541 Mon Sep 17 00:00:00 2001 From: Deanfluence Bot <deanfluencebot@gmail.com> Date: Tue, 3 Mar 2026 19:23:27 +0800 Subject: [PATCH 17/41] fix: use rcu current snapshot for listen-only precedence --- src/config.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/config.rs b/src/config.rs index 7f2f645b2..0f0630313 100644 --- a/src/config.rs +++ b/src/config.rs @@ -5842,14 +5842,13 @@ impl RuntimeConfig { // Apply the resolved channel config atomically on reload while keeping // explicit precedence: configured/env > persisted runtime(DB) > default. let resolved_channel = resolved.channel; - let persisted_listen_only_mode = self.channel_config.load().listen_only_mode; let default_channel = config.defaults.channel; let configured_listen_only = agent.channel.map(|channel| channel.listen_only_mode); - self.channel_config.rcu(move |_| { + self.channel_config.rcu(move |current| { let mut next = resolved_channel; next.listen_only_mode = resolve_listen_only_mode( configured_listen_only, - Some(persisted_listen_only_mode), + Some(current.listen_only_mode), default_channel.listen_only_mode, ); Arc::new(next) From 32a5e0ede72f0d150be437648cfca670550e67cb Mon Sep 17 00:00:00 2001 From: Deanfluence Bot <deanfluencebot@gmail.com> Date: Tue, 3 Mar 2026 22:34:18 +0800 Subject: [PATCH 18/41] fix: avoid synthetic agent channel override when unset --- src/config.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/config.rs b/src/config.rs index 0f0630313..e8da4d829 100644 --- a/src/config.rs +++ b/src/config.rs @@ -5091,8 +5091,10 @@ impl Config { .or_else(|| defaults.browser.screenshot_dir.clone()), chrome_cache_dir: defaults.browser.chrome_cache_dir.clone(), }), - channel: a.channel.map(|channel| { - resolve_channel_config(Some(channel), None, defaults.channel) + channel: a.channel.and_then(|channel| { + channel + .listen_only_mode + .map(|listen_only_mode| ChannelConfig { listen_only_mode }) }), mcp: match a.mcp { Some(mcp_servers) => Some( From 3aefc427dd8145983765ad6a962cf016efd88403 Mon Sep 17 00:00:00 2001 From: Deanfluence Bot <deanfluencebot@gmail.com> Date: Tue, 3 Mar 2026 22:42:31 +0800 Subject: [PATCH 19/41] test: add listen-only precedence regression coverage --- src/config.rs | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/src/config.rs b/src/config.rs index e8da4d829..05e4f01b8 100644 --- a/src/config.rs +++ b/src/config.rs @@ -8363,6 +8363,42 @@ guild_id = "123456" assert_eq!(config.bindings[0].guild_id.as_deref(), Some("123456")); } + #[test] + fn resolve_listen_only_mode_configured_overrides_persisted() { + let resolved = resolve_listen_only_mode(Some(false), Some(true), true); + assert!(!resolved); + } + + #[test] + fn resolve_channel_config_persisted_wins_when_configured_absent() { + let resolved = resolve_channel_config( + None, + Some(ChannelConfig { + listen_only_mode: true, + }), + ChannelConfig { + listen_only_mode: false, + }, + ); + assert!(resolved.listen_only_mode); + } + + #[test] + fn resolve_channel_config_empty_table_preserves_persisted_runtime_state() { + let resolved = resolve_channel_config( + Some(TomlChannelConfig { + listen_only_mode: None, + }), + Some(ChannelConfig { + listen_only_mode: true, + }), + ChannelConfig { + listen_only_mode: false, + }, + ); + assert!(resolved.listen_only_mode); + } + #[test] fn normalize_adapter_trims_and_clears_empty() { assert_eq!(normalize_adapter(None), None); From bd41063cd44a3de95eb43f1ac09e51fbc1fbdaef Mon Sep 17 00:00:00 2001 From: Deanfluence Bot <deanfluencebot@gmail.com> Date: Tue, 3 Mar 2026 22:53:19 +0800 Subject: [PATCH 20/41] test: cover reload rcu merge precedence contract --- src/config.rs | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/src/config.rs b/src/config.rs index 05e4f01b8..4fdd2e0bd 100644 --- a/src/config.rs +++ b/src/config.rs @@ -8399,6 +8399,37 @@ guild_id = "123456" assert!(resolved.listen_only_mode); } + #[test] + fn reload_channel_rcu_merge_uses_current_runtime_snapshot() { + let channel_config = arc_swap::ArcSwap::from_pointee(ChannelConfig { + listen_only_mode: false, + }); + // Simulate a runtime/DB update landing before reload's rcu merge executes. + channel_config.store(Arc::new(ChannelConfig { + listen_only_mode: true, + })); + + let resolved_channel = ChannelConfig { + listen_only_mode: false, + }; + let configured_listen_only: Option<bool> = None; + let default_channel = ChannelConfig { + listen_only_mode: false, + }; + + channel_config.rcu(move |current| { + let mut next = resolved_channel; + next.listen_only_mode = resolve_listen_only_mode( + configured_listen_only, + Some(current.listen_only_mode), + default_channel.listen_only_mode, + ); + Arc::new(next) + }); + + assert!(channel_config.load().listen_only_mode); + } + #[test] fn normalize_adapter_trims_and_clears_empty() { assert_eq!(normalize_adapter(None), None); From 0f7b3b8e63334faf602e02b9c6aae10b65d4b9ad Mon Sep 17 00:00:00 2001 From: Deanfluence Bot <deanfluencebot@gmail.com> Date: Tue, 3 Mar 2026 23:06:43 +0800 Subject: [PATCH 21/41] refactor: share channel reload merge helper --- src/config.rs | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/src/config.rs b/src/config.rs index 4fdd2e0bd..807c226ef 100644 --- a/src/config.rs +++ b/src/config.rs @@ -3124,6 +3124,22 @@ fn resolve_listen_only_mode( configured.or(persisted).unwrap_or(default) } +fn resolve_channel_runtime_merge( + resolved: ChannelConfig, + configured_listen_only: Option<bool>, + persisted: ChannelConfig, + default: ChannelConfig, +) -> ChannelConfig { + let _ = resolved; + ChannelConfig { + listen_only_mode: resolve_listen_only_mode( + configured_listen_only, + Some(persisted.listen_only_mode), + default.listen_only_mode, + ), + } +} + fn default_enabled() -> bool { true } @@ -5847,13 +5863,12 @@ impl RuntimeConfig { let default_channel = config.defaults.channel; let configured_listen_only = agent.channel.map(|channel| channel.listen_only_mode); self.channel_config.rcu(move |current| { - let mut next = resolved_channel; - next.listen_only_mode = resolve_listen_only_mode( + Arc::new(resolve_channel_runtime_merge( + resolved_channel, configured_listen_only, - Some(current.listen_only_mode), - default_channel.listen_only_mode, - ); - Arc::new(next) + **current, + default_channel, + )) }); self.mcp.store(Arc::new(new_mcp.clone())); self.history_backfill_count From 97d551771236040c34e688b9345b17fbf56aecd4 Mon Sep 17 00:00:00 2001 From: Deanfluence Bot <deanfluencebot@gmail.com> Date: Tue, 3 Mar 2026 23:15:55 +0800 Subject: [PATCH 22/41] config: future-proof channel merge helper --- src/config.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/config.rs b/src/config.rs index 807c226ef..535b30fc4 100644 --- a/src/config.rs +++ b/src/config.rs @@ -3125,18 +3125,18 @@ fn resolve_listen_only_mode( } fn resolve_channel_runtime_merge( - resolved: ChannelConfig, + _resolved: ChannelConfig, configured_listen_only: Option<bool>, persisted: ChannelConfig, default: ChannelConfig, ) -> ChannelConfig { - let _ = resolved; ChannelConfig { listen_only_mode: resolve_listen_only_mode( configured_listen_only, Some(persisted.listen_only_mode), default.listen_only_mode, ), + .._resolved } } From 0366b007b2abfb7c8e92d24907730a71aaffb8a1 Mon Sep 17 00:00:00 2001 From: Deanfluence Bot <deanfluencebot@gmail.com> Date: Tue, 3 Mar 2026 23:23:22 +0800 Subject: [PATCH 23/41] test: use shared channel merge helper in rcu regression --- src/config.rs | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/config.rs b/src/config.rs index 535b30fc4..4843f5637 100644 --- a/src/config.rs +++ b/src/config.rs @@ -8433,13 +8433,12 @@ guild_id = "123456" }; channel_config.rcu(move |current| { - let mut next = resolved_channel; - next.listen_only_mode = resolve_listen_only_mode( + Arc::new(resolve_channel_runtime_merge( + resolved_channel, configured_listen_only, - Some(current.listen_only_mode), - default_channel.listen_only_mode, - ); - Arc::new(next) + **current, + default_channel, + )) }); assert!(channel_config.load().listen_only_mode); From d48d6f1d00ef22b5588de1cd551b377287a04b85 Mon Sep 17 00:00:00 2001 From: Deanfluence Bot <deanfluencebot@gmail.com> Date: Tue, 3 Mar 2026 23:29:05 +0800 Subject: [PATCH 24/41] config: polish resolver helper naming for review --- src/config.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/config.rs b/src/config.rs index 4843f5637..f30918d7b 100644 --- a/src/config.rs +++ b/src/config.rs @@ -3124,8 +3124,9 @@ fn resolve_listen_only_mode( configured.or(persisted).unwrap_or(default) } +#[allow(unused_variables)] fn resolve_channel_runtime_merge( - _resolved: ChannelConfig, + resolved_channel: ChannelConfig, configured_listen_only: Option<bool>, persisted: ChannelConfig, default: ChannelConfig, @@ -3136,7 +3137,7 @@ fn resolve_channel_runtime_merge( Some(persisted.listen_only_mode), default.listen_only_mode, ), - .._resolved + ..resolved_channel } } From c4904849131319ac4e30d43a8ba5e4f068bfa7ef Mon Sep 17 00:00:00 2001 From: Deanfluence Bot <deanfluencebot@gmail.com> Date: Wed, 4 Mar 2026 09:05:19 +0800 Subject: [PATCH 25/41] chore: satisfy clippy and simplify channel config merge --- src/agent/channel.rs | 4 ++-- src/config.rs | 26 ++++++++------------------ 2 files changed, 10 insertions(+), 20 deletions(-) diff --git a/src/agent/channel.rs b/src/agent/channel.rs index 7b43a1348..135a38cb0 100644 --- a/src/agent/channel.rs +++ b/src/agent/channel.rs @@ -316,7 +316,7 @@ impl Channel { fn set_listen_only_mode(&mut self, enabled: bool) { self.listen_only_mode = enabled; self.deps.runtime_config.channel_config.rcu(|current| { - let mut next = (**current).clone(); + let mut next = **current; next.listen_only_mode = enabled; Arc::new(next) }); @@ -517,7 +517,7 @@ impl Channel { return Ok(true); } "/help" => { - let lines = vec![ + let lines = [ "commands:".to_string(), "- /status: current mode, models, binding snapshot".to_string(), "- /today: in-progress + ready task snapshot".to_string(), diff --git a/src/config.rs b/src/config.rs index f30918d7b..17c1b9597 100644 --- a/src/config.rs +++ b/src/config.rs @@ -936,20 +936,12 @@ impl Default for BrowserConfig { } /// Channel behavior configuration. -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone, Copy, Default)] pub struct ChannelConfig { /// When true, unsolicited chat messages are ignored unless command/mention/reply. pub listen_only_mode: bool, } -impl Default for ChannelConfig { - fn default() -> Self { - Self { - listen_only_mode: false, - } - } -} - /// OpenCode subprocess worker configuration. #[derive(Debug, Clone)] pub struct OpenCodeConfig { @@ -3124,21 +3116,19 @@ fn resolve_listen_only_mode( configured.or(persisted).unwrap_or(default) } -#[allow(unused_variables)] fn resolve_channel_runtime_merge( resolved_channel: ChannelConfig, configured_listen_only: Option<bool>, persisted: ChannelConfig, default: ChannelConfig, ) -> ChannelConfig { - ChannelConfig { - listen_only_mode: resolve_listen_only_mode( - configured_listen_only, - Some(persisted.listen_only_mode), - default.listen_only_mode, - ), - ..resolved_channel - } + let mut merged = resolved_channel; + merged.listen_only_mode = resolve_listen_only_mode( + configured_listen_only, + Some(persisted.listen_only_mode), + default.listen_only_mode, + ); + merged } fn default_enabled() -> bool { From cb78cb5f8bfeaaae5cac701681b767e00fe7d43e Mon Sep 17 00:00:00 2001 From: Deanfluence Bot <deanfluencebot@gmail.com> Date: Wed, 4 Mar 2026 09:18:57 +0800 Subject: [PATCH 26/41] config: restore channel listen-only support after main refactor --- src/config/load.rs | 31 ++++++++++++++++++++++++------- src/config/runtime.rs | 15 ++++++++++++--- src/config/toml_schema.rs | 7 +++++++ src/config/types.rs | 13 +++++++++++++ 4 files changed, 56 insertions(+), 10 deletions(-) diff --git a/src/config/load.rs b/src/config/load.rs index 4abd1c0b0..012ea75b9 100644 --- a/src/config/load.rs +++ b/src/config/load.rs @@ -10,13 +10,14 @@ use super::providers::{ }; use super::toml_schema::*; use super::{ - AgentConfig, ApiConfig, ApiType, Binding, BrowserConfig, CoalesceConfig, CompactionConfig, - Config, CortexConfig, CronDef, DefaultsConfig, DiscordConfig, DiscordInstanceConfig, - EmailConfig, EmailInstanceConfig, GroupDef, HumanDef, IngestionConfig, LinkDef, LlmConfig, - McpServerConfig, McpTransport, MemoryPersistenceConfig, MessagingConfig, MetricsConfig, - OpenCodeConfig, ProviderConfig, SlackCommandConfig, SlackConfig, SlackInstanceConfig, - TelegramConfig, TelegramInstanceConfig, TelemetryConfig, TwitchConfig, TwitchInstanceConfig, - WarmupConfig, WebhookConfig, normalize_adapter, validate_named_messaging_adapters, + AgentConfig, ApiConfig, ApiType, Binding, BrowserConfig, ChannelConfig, CoalesceConfig, + CompactionConfig, Config, CortexConfig, CronDef, DefaultsConfig, DiscordConfig, + DiscordInstanceConfig, EmailConfig, EmailInstanceConfig, GroupDef, HumanDef, IngestionConfig, + LinkDef, LlmConfig, McpServerConfig, McpTransport, MemoryPersistenceConfig, MessagingConfig, + MetricsConfig, OpenCodeConfig, ProviderConfig, SlackCommandConfig, SlackConfig, + SlackInstanceConfig, TelegramConfig, TelegramInstanceConfig, TelemetryConfig, TwitchConfig, + TwitchInstanceConfig, WarmupConfig, WebhookConfig, normalize_adapter, + validate_named_messaging_adapters, }; use crate::error::{ConfigError, Result}; @@ -730,6 +731,7 @@ impl Config { cortex: None, warmup: None, browser: None, + channel: None, mcp: None, brave_search_key: None, cron_timezone: None, @@ -1401,6 +1403,15 @@ impl Config { ..base_defaults.browser.clone() }) }, + channel: toml + .defaults + .channel + .map(|c| ChannelConfig { + listen_only_mode: c + .listen_only_mode + .unwrap_or(base_defaults.channel.listen_only_mode), + }) + .unwrap_or(base_defaults.channel), mcp: default_mcp, brave_search_key: toml .defaults @@ -1592,6 +1603,11 @@ impl Config { .or_else(|| defaults.browser.screenshot_dir.clone()), chrome_cache_dir: defaults.browser.chrome_cache_dir.clone(), }), + channel: a.channel.map(|c| ChannelConfig { + listen_only_mode: c + .listen_only_mode + .unwrap_or(defaults.channel.listen_only_mode), + }), mcp: match a.mcp { Some(mcp_servers) => Some( mcp_servers @@ -1630,6 +1646,7 @@ impl Config { cortex: None, warmup: None, browser: None, + channel: None, mcp: None, brave_search_key: None, cron_timezone: None, diff --git a/src/config/runtime.rs b/src/config/runtime.rs index e645d1a53..0e72e95a2 100644 --- a/src/config/runtime.rs +++ b/src/config/runtime.rs @@ -4,9 +4,9 @@ use std::sync::Arc; use arc_swap::ArcSwap; use super::{ - BrowserConfig, CoalesceConfig, CompactionConfig, Config, CortexConfig, DefaultsConfig, - IngestionConfig, McpServerConfig, MemoryPersistenceConfig, OpenCodeConfig, ResolvedAgentConfig, - WarmupConfig, WarmupStatus, WorkReadiness, evaluate_work_readiness, + BrowserConfig, ChannelConfig, CoalesceConfig, CompactionConfig, Config, CortexConfig, + DefaultsConfig, IngestionConfig, McpServerConfig, MemoryPersistenceConfig, OpenCodeConfig, + ResolvedAgentConfig, WarmupConfig, WarmupStatus, WorkReadiness, evaluate_work_readiness, }; use crate::llm::routing::RoutingConfig; @@ -25,6 +25,7 @@ pub struct RuntimeConfig { pub memory_persistence: ArcSwap<MemoryPersistenceConfig>, pub coalesce: ArcSwap<CoalesceConfig>, pub ingestion: ArcSwap<IngestionConfig>, + pub channel_config: ArcSwap<ChannelConfig>, pub max_turns: ArcSwap<usize>, pub branch_max_turns: ArcSwap<usize>, pub context_window: ArcSwap<usize>, @@ -91,6 +92,7 @@ impl RuntimeConfig { memory_persistence: ArcSwap::from_pointee(agent_config.memory_persistence), coalesce: ArcSwap::from_pointee(agent_config.coalesce), ingestion: ArcSwap::from_pointee(agent_config.ingestion), + channel_config: ArcSwap::from_pointee(agent_config.channel), max_turns: ArcSwap::from_pointee(agent_config.max_turns), branch_max_turns: ArcSwap::from_pointee(agent_config.branch_max_turns), context_window: ArcSwap::from_pointee(agent_config.context_window), @@ -180,6 +182,13 @@ impl RuntimeConfig { .store(Arc::new(resolved.memory_persistence)); self.coalesce.store(Arc::new(resolved.coalesce)); self.ingestion.store(Arc::new(resolved.ingestion)); + let resolved_channel = resolved.channel; + let configured_listen_only = agent.channel.map(|c| c.listen_only_mode); + self.channel_config.rcu(move |current| { + let mut next = resolved_channel; + next.listen_only_mode = configured_listen_only.unwrap_or(current.listen_only_mode); + Arc::new(next) + }); self.max_turns.store(Arc::new(resolved.max_turns)); self.branch_max_turns .store(Arc::new(resolved.branch_max_turns)); diff --git a/src/config/toml_schema.rs b/src/config/toml_schema.rs index 55e73b347..fdca63ccd 100644 --- a/src/config/toml_schema.rs +++ b/src/config/toml_schema.rs @@ -277,6 +277,7 @@ pub(super) struct TomlDefaultsConfig { pub(super) cortex: Option<TomlCortexConfig>, pub(super) warmup: Option<TomlWarmupConfig>, pub(super) browser: Option<TomlBrowserConfig>, + pub(super) channel: Option<TomlChannelConfig>, #[serde(default)] pub(super) mcp: Vec<TomlMcpServerConfig>, pub(super) brave_search_key: Option<String>, @@ -366,6 +367,11 @@ pub(super) struct TomlBrowserConfig { pub(super) screenshot_dir: Option<String>, } +#[derive(Deserialize)] +pub(super) struct TomlChannelConfig { + pub(super) listen_only_mode: Option<bool>, +} + #[derive(Deserialize)] pub(super) struct TomlOpenCodeConfig { pub(super) enabled: Option<bool>, @@ -424,6 +430,7 @@ pub(super) struct TomlAgentConfig { pub(super) cortex: Option<TomlCortexConfig>, pub(super) warmup: Option<TomlWarmupConfig>, pub(super) browser: Option<TomlBrowserConfig>, + pub(super) channel: Option<TomlChannelConfig>, pub(super) mcp: Option<Vec<TomlMcpServerConfig>>, pub(super) brave_search_key: Option<String>, pub(super) cron_timezone: Option<String>, diff --git a/src/config/types.rs b/src/config/types.rs index ddc15310a..a272be0f4 100644 --- a/src/config/types.rs +++ b/src/config/types.rs @@ -511,6 +511,7 @@ pub struct DefaultsConfig { pub cortex: CortexConfig, pub warmup: WarmupConfig, pub browser: BrowserConfig, + pub channel: ChannelConfig, pub mcp: Vec<McpServerConfig>, /// Brave Search API key for web search tool. Supports "env:VAR_NAME" references. pub brave_search_key: Option<String>, @@ -541,6 +542,7 @@ impl std::fmt::Debug for DefaultsConfig { .field("cortex", &self.cortex) .field("warmup", &self.warmup) .field("browser", &self.browser) + .field("channel", &self.channel) .field("mcp", &self.mcp) .field( "brave_search_key", @@ -729,6 +731,13 @@ impl Default for BrowserConfig { } } +/// Channel behavior configuration. +#[derive(Debug, Clone, Copy, Default)] +pub struct ChannelConfig { + /// When true, unsolicited chat messages are ignored unless command/mention/reply. + pub listen_only_mode: bool, +} + /// OpenCode subprocess worker configuration. #[derive(Debug, Clone, PartialEq, Eq)] pub struct OpenCodeConfig { @@ -952,6 +961,7 @@ pub struct AgentConfig { pub cortex: Option<CortexConfig>, pub warmup: Option<WarmupConfig>, pub browser: Option<BrowserConfig>, + pub channel: Option<ChannelConfig>, pub mcp: Option<Vec<McpServerConfig>>, /// Per-agent Brave Search API key override. None inherits from defaults. pub brave_search_key: Option<String>, @@ -1007,6 +1017,7 @@ pub struct ResolvedAgentConfig { pub cortex: CortexConfig, pub warmup: WarmupConfig, pub browser: BrowserConfig, + pub channel: ChannelConfig, pub mcp: Vec<McpServerConfig>, pub brave_search_key: Option<String>, pub cron_timezone: Option<String>, @@ -1034,6 +1045,7 @@ impl Default for DefaultsConfig { cortex: CortexConfig::default(), warmup: WarmupConfig::default(), browser: BrowserConfig::default(), + channel: ChannelConfig::default(), mcp: Vec::new(), brave_search_key: None, cron_timezone: None, @@ -1097,6 +1109,7 @@ impl AgentConfig { .browser .clone() .unwrap_or_else(|| defaults.browser.clone()), + channel: self.channel.unwrap_or(defaults.channel), mcp: resolve_mcp_configs(&defaults.mcp, self.mcp.as_deref()), brave_search_key: self .brave_search_key From fad08fa38602f1b2a9faf73ed65120330c0355e1 Mon Sep 17 00:00:00 2001 From: Deanfluence Bot <deanfluencebot@gmail.com> Date: Wed, 4 Mar 2026 09:22:00 +0800 Subject: [PATCH 27/41] quiet-mode: persist listen setting and keep fast-path inbound logs --- src/agent/channel.rs | 59 +++++++++++++++++++++++++++++-------------- src/config/runtime.rs | 20 +++++++++++++-- src/settings.rs | 2 +- src/settings/store.rs | 18 +++++++++++++ 4 files changed, 77 insertions(+), 22 deletions(-) diff --git a/src/agent/channel.rs b/src/agent/channel.rs index 799da8fca..a138bc9f5 100644 --- a/src/agent/channel.rs +++ b/src/agent/channel.rs @@ -321,6 +321,44 @@ impl Channel { next.listen_only_mode = enabled; Arc::new(next) }); + if let Some(settings_store) = self + .deps + .runtime_config + .settings + .load() + .as_ref() + .as_ref() + .cloned() + && let Err(error) = settings_store.set_channel_listen_only_mode(enabled) + { + tracing::warn!( + %error, + channel_id = %self.id, + listen_only_mode = enabled, + "failed to persist listen_only_mode setting" + ); + } + } + + fn persist_inbound_user_message(&self, message: &InboundMessage, raw_text: &str) { + if message.source == "system" { + return; + } + let sender_name = message + .metadata + .get("sender_display_name") + .and_then(|v| v.as_str()) + .unwrap_or(&message.sender_id); + self.state.conversation_logger.log_user_message( + &self.state.channel_id, + sender_name, + &message.sender_id, + raw_text, + &message.metadata, + ); + self.state + .channel_store + .upsert(&message.conversation_id, &message.metadata); } fn suppress_plaintext_fallback(&self) -> bool { @@ -1033,6 +1071,8 @@ impl Channel { crate::MessageContent::Interaction { .. } => (message.content.to_string(), Vec::new()), }; + self.persist_inbound_user_message(&message, &raw_text); + // Deterministic built-in command: bypass model output drift for agent identity checks. if message.source != "system" && raw_text.trim() == "/agent-id" { self.send_builtin_text(self.deps.agent_id.to_string(), "agent-id") @@ -1086,25 +1126,6 @@ impl Channel { } } - // Persist user messages (skip system re-triggers) - if message.source != "system" { - let sender_name = message - .metadata - .get("sender_display_name") - .and_then(|v| v.as_str()) - .unwrap_or(&message.sender_id); - self.state.conversation_logger.log_user_message( - &self.state.channel_id, - sender_name, - &message.sender_id, - &raw_text, - &message.metadata, - ); - self.state - .channel_store - .upsert(&message.conversation_id, &message.metadata); - } - // Capture conversation context from the first message (platform, channel, server) if self.conversation_context.is_none() { let prompt_engine = self.deps.runtime_config.prompts.load(); diff --git a/src/config/runtime.rs b/src/config/runtime.rs index 0e72e95a2..64617cda2 100644 --- a/src/config/runtime.rs +++ b/src/config/runtime.rs @@ -134,7 +134,15 @@ impl RuntimeConfig { /// Set the settings store after initialization. pub fn set_settings(&self, settings: Arc<crate::settings::SettingsStore>) { + let persisted_listen_only = settings.channel_listen_only_mode(); self.settings.store(Arc::new(Some(settings))); + if let Some(enabled) = persisted_listen_only { + self.channel_config.rcu(move |current| { + let mut next = **current; + next.listen_only_mode = enabled; + Arc::new(next) + }); + } } /// Set the secrets store after initialization. @@ -184,9 +192,17 @@ impl RuntimeConfig { self.ingestion.store(Arc::new(resolved.ingestion)); let resolved_channel = resolved.channel; let configured_listen_only = agent.channel.map(|c| c.listen_only_mode); - self.channel_config.rcu(move |current| { + let persisted_listen_only = self + .settings + .load() + .as_ref() + .as_ref() + .and_then(|settings| settings.channel_listen_only_mode()); + self.channel_config.rcu(move |_current| { let mut next = resolved_channel; - next.listen_only_mode = configured_listen_only.unwrap_or(current.listen_only_mode); + next.listen_only_mode = configured_listen_only + .or(persisted_listen_only) + .unwrap_or(next.listen_only_mode); Arc::new(next) }); self.max_turns.store(Arc::new(resolved.max_turns)); diff --git a/src/settings.rs b/src/settings.rs index 8c6d73490..29acb2be2 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -2,4 +2,4 @@ pub mod store; -pub use store::{SettingsStore, WORKER_LOG_MODE_KEY, WorkerLogMode}; +pub use store::{CHANNEL_LISTEN_ONLY_MODE_KEY, SettingsStore, WORKER_LOG_MODE_KEY, WorkerLogMode}; diff --git a/src/settings/store.rs b/src/settings/store.rs index 387ed6b69..b2d8660f2 100644 --- a/src/settings/store.rs +++ b/src/settings/store.rs @@ -11,6 +11,8 @@ const SETTINGS_TABLE: TableDefinition<&str, &str> = TableDefinition::new("settin /// Default key for worker log mode setting. pub const WORKER_LOG_MODE_KEY: &str = "worker_log_mode"; +/// Key for channel listen-only mode setting. +pub const CHANNEL_LISTEN_ONLY_MODE_KEY: &str = "channel_listen_only_mode"; /// How worker execution logs are stored. #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] @@ -160,6 +162,22 @@ impl SettingsStore { pub fn set_worker_log_mode(&self, mode: WorkerLogMode) -> Result<()> { self.set_raw(WORKER_LOG_MODE_KEY, &mode.to_string()) } + + /// Get the channel listen-only mode, if explicitly persisted. + pub fn channel_listen_only_mode(&self) -> Option<bool> { + match self.get_raw(CHANNEL_LISTEN_ONLY_MODE_KEY) { + Ok(raw) => raw.parse::<bool>().ok(), + Err(_) => None, + } + } + + /// Persist channel listen-only mode. + pub fn set_channel_listen_only_mode(&self, enabled: bool) -> Result<()> { + self.set_raw( + CHANNEL_LISTEN_ONLY_MODE_KEY, + if enabled { "true" } else { "false" }, + ) + } } impl std::fmt::Debug for SettingsStore { From 7feba63d6251d21dae59407f69091f38ed07c98c Mon Sep 17 00:00:00 2001 From: Deanfluence Bot <deanfluencebot@gmail.com> Date: Wed, 4 Mar 2026 09:28:49 +0800 Subject: [PATCH 28/41] review: apply Tembo suggestions for mentions and rich payload gating --- src/agent/channel.rs | 26 ++++++++++++++------------ src/messaging/twitch.rs | 7 +++---- src/tools/reply.rs | 4 ++-- 3 files changed, 19 insertions(+), 18 deletions(-) diff --git a/src/agent/channel.rs b/src/agent/channel.rs index a138bc9f5..1d67ef76e 100644 --- a/src/agent/channel.rs +++ b/src/agent/channel.rs @@ -174,8 +174,8 @@ pub struct Channel { pending_results: Vec<PendingResult>, /// Optional send_agent_message tool (only when agent has active links). send_agent_message_tool: Option<crate::tools::SendAgentMessageTool>, - /// Channel-local reply mode toggle for telegram marketing intel. - /// true = listen-only unless explicit invoke, false = respond normally. + /// Channel-local reply mode toggle. + /// When true, suppress unsolicited replies unless explicitly invoked. listen_only_mode: bool, } @@ -402,18 +402,20 @@ impl Channel { raw_text: &str, ) -> (bool, bool, bool) { let text = raw_text.trim(); - let text_lower = text.to_lowercase(); let invoked_by_command = text.starts_with('/'); let invoked_by_mention = match message.source.as_str() { - "telegram" => message - .metadata - .get("telegram_bot_username") - .and_then(|v| v.as_str()) - .map(|username| { - let mention = format!("@{username}").to_lowercase(); - text_lower.contains(&mention) - }) - .unwrap_or(false), + "telegram" => { + let text_lower = text.to_lowercase(); + message + .metadata + .get("telegram_bot_username") + .and_then(|v| v.as_str()) + .map(|username| { + let mention = format!("@{username}").to_lowercase(); + text_lower.contains(&mention) + }) + .unwrap_or(false) + } "discord" => message .metadata .get("discord_mentioned_bot") diff --git a/src/messaging/twitch.rs b/src/messaging/twitch.rs index 9604f0373..fdff8dbcf 100644 --- a/src/messaging/twitch.rs +++ b/src/messaging/twitch.rs @@ -296,10 +296,9 @@ impl Messaging for TwitchAdapter { "sender_display_name".into(), serde_json::Value::String(privmsg.sender.name.clone()), ); - let mentions_bot = privmsg - .message_text - .to_lowercase() - .contains(&format!("@{bot_username}")); + let message_lower = privmsg.message_text.to_lowercase(); + let bot_login = bot_username.to_lowercase(); + let mentions_bot = message_lower.contains(&format!("@{bot_login}")); metadata.insert( "twitch_mentions_or_replies_to_bot".into(), serde_json::Value::Bool(mentions_bot), diff --git a/src/tools/reply.rs b/src/tools/reply.rs index 2d590833d..2e2e74b7b 100644 --- a/src/tools/reply.rs +++ b/src/tools/reply.rs @@ -469,8 +469,8 @@ impl Tool for ReplyTool { text: converted_content.clone(), } } else if cards_requested || interactive_requested || poll_requested { - let supports_cards = matches!(source, "discord" | "slack"); - let supports_interactive = matches!(source, "discord" | "slack"); + let supports_cards = matches!(source, "discord"); + let supports_interactive = matches!(source, "discord"); let supports_poll = matches!(source, "discord" | "telegram"); let mut unsupported = Vec::new(); if cards_requested && !supports_cards { From 9f3ccca50d1d1307109042c54c86fca741489c19 Mon Sep 17 00:00:00 2001 From: Deanfluence Bot <deanfluencebot@gmail.com> Date: Wed, 4 Mar 2026 09:30:20 +0800 Subject: [PATCH 29/41] slack: reuse session handle for thread-root lookup --- src/messaging/slack.rs | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/src/messaging/slack.rs b/src/messaging/slack.rs index e7cbb1093..d9a901038 100644 --- a/src/messaging/slack.rs +++ b/src/messaging/slack.rs @@ -268,6 +268,8 @@ async fn handle_message_event( .and_then(|content| content.text.as_ref()) .map(|text| text.contains(&bot_mention)) .unwrap_or(false); + let token = SlackApiToken::new(SlackApiTokenValue(adapter_state.bot_token.clone())); + let session = client.open_session(&token); let replied_to_bot = if let Some(thread_ts) = msg_event.origin.thread_ts.as_ref() { // For threaded replies, treat as explicit invoke only when the thread // root message belongs to this bot. @@ -277,13 +279,7 @@ async fn handle_message_event( thread_ts.clone(), ) .with_limit(1); - match client - .open_session(&SlackApiToken::new(SlackApiTokenValue( - adapter_state.bot_token.clone(), - ))) - .conversations_replies(&req) - .await - { + match session.conversations_replies(&req).await { Ok(response) => response .messages .first() From a1c58509c22fca1201f7d5e2eae8115253e00f04 Mon Sep 17 00:00:00 2001 From: Deanfluence Bot <deanfluencebot@gmail.com> Date: Wed, 4 Mar 2026 09:36:04 +0800 Subject: [PATCH 30/41] config: honor explicit-vs-persisted listen-only precedence --- src/api/agents.rs | 3 ++- src/config/load.rs | 10 +++++----- src/config/runtime.rs | 46 ++++++++++++++++++++++++++++++------------- src/main.rs | 7 ++++++- src/settings/store.rs | 16 ++++++++++++--- 5 files changed, 58 insertions(+), 24 deletions(-) diff --git a/src/api/agents.rs b/src/api/agents.rs index 3e5f60bb0..644dd11d7 100644 --- a/src/api/agents.rs +++ b/src/api/agents.rs @@ -677,7 +677,8 @@ pub(super) async fn create_agent( identity, skills, )); - runtime_config.set_settings(settings_store.clone()); + let explicit_listen_only = raw_config.channel.map(|channel| channel.listen_only_mode); + runtime_config.set_settings(settings_store.clone(), explicit_listen_only); let llm_manager = { let guard = state.llm_manager.read().await; diff --git a/src/config/load.rs b/src/config/load.rs index 012ea75b9..fa0e86b88 100644 --- a/src/config/load.rs +++ b/src/config/load.rs @@ -1406,8 +1406,8 @@ impl Config { channel: toml .defaults .channel - .map(|c| ChannelConfig { - listen_only_mode: c + .map(|channel_config| ChannelConfig { + listen_only_mode: channel_config .listen_only_mode .unwrap_or(base_defaults.channel.listen_only_mode), }) @@ -1603,10 +1603,10 @@ impl Config { .or_else(|| defaults.browser.screenshot_dir.clone()), chrome_cache_dir: defaults.browser.chrome_cache_dir.clone(), }), - channel: a.channel.map(|c| ChannelConfig { - listen_only_mode: c + channel: a.channel.and_then(|channel_config| { + channel_config .listen_only_mode - .unwrap_or(defaults.channel.listen_only_mode), + .map(|listen_only_mode| ChannelConfig { listen_only_mode }) }), mcp: match a.mcp { Some(mcp_servers) => Some( diff --git a/src/config/runtime.rs b/src/config/runtime.rs index 64617cda2..06be36a91 100644 --- a/src/config/runtime.rs +++ b/src/config/runtime.rs @@ -133,15 +133,27 @@ impl RuntimeConfig { } /// Set the settings store after initialization. - pub fn set_settings(&self, settings: Arc<crate::settings::SettingsStore>) { + pub fn set_settings( + &self, + settings: Arc<crate::settings::SettingsStore>, + explicit_listen_only: Option<bool>, + ) { let persisted_listen_only = settings.channel_listen_only_mode(); self.settings.store(Arc::new(Some(settings))); - if let Some(enabled) = persisted_listen_only { - self.channel_config.rcu(move |current| { - let mut next = **current; - next.listen_only_mode = enabled; - Arc::new(next) - }); + if explicit_listen_only.is_none() { + match persisted_listen_only { + Ok(Some(enabled)) => { + self.channel_config.rcu(move |current| { + let mut next = **current; + next.listen_only_mode = enabled; + Arc::new(next) + }); + } + Ok(None) => {} + Err(error) => { + tracing::warn!(%error, "failed to load persisted channel listen_only_mode"); + } + } } } @@ -191,13 +203,19 @@ impl RuntimeConfig { self.coalesce.store(Arc::new(resolved.coalesce)); self.ingestion.store(Arc::new(resolved.ingestion)); let resolved_channel = resolved.channel; - let configured_listen_only = agent.channel.map(|c| c.listen_only_mode); - let persisted_listen_only = self - .settings - .load() - .as_ref() - .as_ref() - .and_then(|settings| settings.channel_listen_only_mode()); + let configured_listen_only = agent.channel.map(|channel| channel.listen_only_mode); + let persisted_listen_only = self.settings.load().as_ref().as_ref().and_then(|settings| { + match settings.channel_listen_only_mode() { + Ok(value) => value, + Err(error) => { + tracing::warn!( + %error, + "failed to load persisted channel listen_only_mode during reload" + ); + None + } + } + }); self.channel_config.rcu(move |_current| { let mut next = resolved_channel; next.listen_only_mode = configured_listen_only diff --git a/src/main.rs b/src/main.rs index 6c5a68948..10fc1d3f0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2162,7 +2162,12 @@ async fn initialize_agents( )); // Set the settings store in RuntimeConfig and apply config-driven defaults - runtime_config.set_settings(settings_store.clone()); + let explicit_listen_only = config + .agents + .iter() + .find(|agent| agent.id == agent_config.id) + .and_then(|agent| agent.channel.map(|channel| channel.listen_only_mode)); + runtime_config.set_settings(settings_store.clone(), explicit_listen_only); if let Err(error) = settings_store.set_worker_log_mode(config.defaults.worker_log_mode) { tracing::warn!(%error, agent = %agent_config.id, "failed to set worker_log_mode from config"); } diff --git a/src/settings/store.rs b/src/settings/store.rs index b2d8660f2..419df91bf 100644 --- a/src/settings/store.rs +++ b/src/settings/store.rs @@ -164,10 +164,20 @@ impl SettingsStore { } /// Get the channel listen-only mode, if explicitly persisted. - pub fn channel_listen_only_mode(&self) -> Option<bool> { + pub fn channel_listen_only_mode(&self) -> Result<Option<bool>> { match self.get_raw(CHANNEL_LISTEN_ONLY_MODE_KEY) { - Ok(raw) => raw.parse::<bool>().ok(), - Err(_) => None, + Ok(raw) => raw.parse::<bool>().map(Some).map_err(|error| { + SettingsError::ReadFailed { + key: CHANNEL_LISTEN_ONLY_MODE_KEY.to_string(), + details: format!("invalid boolean value '{raw}': {error}"), + } + .into() + }), + Err(crate::error::Error::Settings(settings_error)) => match *settings_error { + SettingsError::NotFound { .. } => Ok(None), + other => Err(other.into()), + }, + Err(other) => Err(other), } } From aa246d2dacdbe7f40e15d88bc54f175a78983bbe Mon Sep 17 00:00:00 2001 From: Deanfluence Bot <deanfluencebot@gmail.com> Date: Wed, 4 Mar 2026 09:48:34 +0800 Subject: [PATCH 31/41] review: address latest coderabbit listen-mode and twitch checks --- src/agent/channel.rs | 44 +++++++++++++++++++++++------------------ src/config/runtime.rs | 4 ++-- src/messaging/twitch.rs | 15 ++++++++++++-- 3 files changed, 40 insertions(+), 23 deletions(-) diff --git a/src/agent/channel.rs b/src/agent/channel.rs index 1d67ef76e..87b6c39c3 100644 --- a/src/agent/channel.rs +++ b/src/agent/channel.rs @@ -314,13 +314,8 @@ impl Channel { .listen_only_mode; } - fn set_listen_only_mode(&mut self, enabled: bool) { - self.listen_only_mode = enabled; - self.deps.runtime_config.channel_config.rcu(|current| { - let mut next = **current; - next.listen_only_mode = enabled; - Arc::new(next) - }); + fn set_listen_only_mode(&mut self, enabled: bool) -> bool { + let mut persisted = true; if let Some(settings_store) = self .deps .runtime_config @@ -331,6 +326,7 @@ impl Channel { .cloned() && let Err(error) = settings_store.set_channel_listen_only_mode(enabled) { + persisted = false; tracing::warn!( %error, channel_id = %self.id, @@ -338,6 +334,14 @@ impl Channel { "failed to persist listen_only_mode setting" ); } + + self.listen_only_mode = enabled; + self.deps.runtime_config.channel_config.rcu(|current| { + let mut next = **current; + next.listen_only_mode = enabled; + Arc::new(next) + }); + persisted } fn persist_inbound_user_message(&self, message: &InboundMessage, raw_text: &str) { @@ -539,22 +543,24 @@ impl Channel { return Ok(true); } "/quiet" => { - self.set_listen_only_mode(true); - self.send_builtin_text( + let persisted = self.set_listen_only_mode(true); + let body = if persisted { "quiet mode enabled. i'll only reply to commands, @mentions, or replies to my message." - .to_string(), - "quiet", - ) - .await; + .to_string() + } else { + "quiet mode enabled for this session, but persistence failed; it may revert after restart.".to_string() + }; + self.send_builtin_text(body, "quiet").await; return Ok(true); } "/active" => { - self.set_listen_only_mode(false); - self.send_builtin_text( - "active mode enabled. i'll respond normally in this chat.".to_string(), - "active", - ) - .await; + let persisted = self.set_listen_only_mode(false); + let body = if persisted { + "active mode enabled. i'll respond normally in this chat.".to_string() + } else { + "active mode enabled for this session, but persistence failed; it may revert after restart.".to_string() + }; + self.send_builtin_text(body, "active").await; return Ok(true); } "/help" => { diff --git a/src/config/runtime.rs b/src/config/runtime.rs index 06be36a91..bcde6df6e 100644 --- a/src/config/runtime.rs +++ b/src/config/runtime.rs @@ -216,11 +216,11 @@ impl RuntimeConfig { } } }); - self.channel_config.rcu(move |_current| { + self.channel_config.rcu(move |current| { let mut next = resolved_channel; next.listen_only_mode = configured_listen_only .or(persisted_listen_only) - .unwrap_or(next.listen_only_mode); + .unwrap_or(current.as_ref().listen_only_mode); Arc::new(next) }); self.max_turns.store(Arc::new(resolved.max_turns)); diff --git a/src/messaging/twitch.rs b/src/messaging/twitch.rs index fdff8dbcf..0d86b32e4 100644 --- a/src/messaging/twitch.rs +++ b/src/messaging/twitch.rs @@ -297,8 +297,19 @@ impl Messaging for TwitchAdapter { serde_json::Value::String(privmsg.sender.name.clone()), ); let message_lower = privmsg.message_text.to_lowercase(); - let bot_login = bot_username.to_lowercase(); - let mentions_bot = message_lower.contains(&format!("@{bot_login}")); + let mention = format!("@{}", bot_username.to_lowercase()); + let is_login_char = |character: char| { + character.is_ascii_lowercase() + || character.is_ascii_digit() + || character == '_' + }; + let mentions_bot = message_lower.match_indices(&mention).any(|(start, _)| { + let before = message_lower[..start].chars().next_back(); + let after = message_lower[start + mention.len()..].chars().next(); + let before_ok = before.map(|character| !is_login_char(character)).unwrap_or(true); + let after_ok = after.map(|character| !is_login_char(character)).unwrap_or(true); + before_ok && after_ok + }); metadata.insert( "twitch_mentions_or_replies_to_bot".into(), serde_json::Value::Bool(mentions_bot), From e8e200b17ce4784485825ba1aad4ce2b1a080097 Mon Sep 17 00:00:00 2001 From: Deanfluence Bot <deanfluencebot@gmail.com> Date: Wed, 4 Mar 2026 10:13:06 +0800 Subject: [PATCH 32/41] review: fix persistence signal and add slack parent-lookup timeout --- src/agent/channel.rs | 25 +++++++++++++++++-------- src/messaging/slack.rs | 21 +++++++++++++++++---- 2 files changed, 34 insertions(+), 12 deletions(-) diff --git a/src/agent/channel.rs b/src/agent/channel.rs index 87b6c39c3..cd7f8f6a4 100644 --- a/src/agent/channel.rs +++ b/src/agent/channel.rs @@ -315,23 +315,32 @@ impl Channel { } fn set_listen_only_mode(&mut self, enabled: bool) -> bool { - let mut persisted = true; - if let Some(settings_store) = self + let mut persisted = false; + let settings_store = self .deps .runtime_config .settings .load() .as_ref() .as_ref() - .cloned() - && let Err(error) = settings_store.set_channel_listen_only_mode(enabled) - { - persisted = false; + .cloned(); + if let Some(settings_store) = settings_store { + match settings_store.set_channel_listen_only_mode(enabled) { + Ok(()) => persisted = true, + Err(error) => { + tracing::warn!( + %error, + channel_id = %self.id, + listen_only_mode = enabled, + "failed to persist listen_only_mode setting" + ); + } + } + } else { tracing::warn!( - %error, channel_id = %self.id, listen_only_mode = enabled, - "failed to persist listen_only_mode setting" + "settings store unavailable; listen_only_mode is session-scoped" ); } diff --git a/src/messaging/slack.rs b/src/messaging/slack.rs index d9a901038..44e67997a 100644 --- a/src/messaging/slack.rs +++ b/src/messaging/slack.rs @@ -32,6 +32,7 @@ use slack_morphism::prelude::*; use std::collections::HashMap; use std::sync::Arc; use tokio::sync::{RwLock, mpsc}; +use tokio::time::{Duration, timeout}; /// State shared with socket mode callbacks via `SlackClientEventsUserState`. struct SlackAdapterState { @@ -274,21 +275,33 @@ async fn handle_message_event( // For threaded replies, treat as explicit invoke only when the thread // root message belongs to this bot. if thread_ts.0 != ts { - let req = SlackApiConversationsRepliesRequest::new( + let thread_replies_request = SlackApiConversationsRepliesRequest::new( SlackChannelId(channel_id.clone()), thread_ts.clone(), ) .with_limit(1); - match session.conversations_replies(&req).await { - Ok(response) => response + match timeout( + Duration::from_secs(2), + session.conversations_replies(&thread_replies_request), + ) + .await + { + Ok(Ok(response)) => response .messages .first() .and_then(|message| message.sender.user.as_ref()) .is_some_and(|user| user.0 == adapter_state.bot_user_id), - Err(error) => { + Ok(Err(error)) => { tracing::debug!(%error, "failed to resolve slack thread parent for reply invoke"); false } + Err(error) => { + tracing::debug!( + %error, + "timed out resolving slack thread parent for reply invoke" + ); + false + } } } else { false From 4fa37badb269dbde8eeb04d9ade54899dac77d24 Mon Sep 17 00:00:00 2001 From: Deanfluence Bot <deanfluencebot@gmail.com> Date: Wed, 4 Mar 2026 10:46:44 +0800 Subject: [PATCH 33/41] review: apply latest coderabbit fixes and nits --- src/agent/channel.rs | 6 +++--- src/config/runtime.rs | 5 ++--- src/messaging/discord.rs | 11 +++++------ src/messaging/twitch.rs | 2 +- src/tools/reply.rs | 3 ++- 5 files changed, 13 insertions(+), 14 deletions(-) diff --git a/src/agent/channel.rs b/src/agent/channel.rs index cd7f8f6a4..c3a54a03d 100644 --- a/src/agent/channel.rs +++ b/src/agent/channel.rs @@ -465,9 +465,9 @@ impl Channel { .get("reply_to_username") .and_then(|v| v.as_str()) .map(str::to_lowercase); - match (bot_username, reply_username) { - (Some(bot), Some(reply)) => bot == reply, - _ => false, + match reply_username { + Some(reply) => bot_username.is_none_or(|bot| bot == reply), + None => false, } } _ => message diff --git a/src/config/runtime.rs b/src/config/runtime.rs index bcde6df6e..7a9412219 100644 --- a/src/config/runtime.rs +++ b/src/config/runtime.rs @@ -138,10 +138,9 @@ impl RuntimeConfig { settings: Arc<crate::settings::SettingsStore>, explicit_listen_only: Option<bool>, ) { - let persisted_listen_only = settings.channel_listen_only_mode(); - self.settings.store(Arc::new(Some(settings))); + self.settings.store(Arc::new(Some(settings.clone()))); if explicit_listen_only.is_none() { - match persisted_listen_only { + match settings.channel_listen_only_mode() { Ok(Some(enabled)) => { self.channel_config.rcu(move |current| { let mut next = **current; diff --git a/src/messaging/discord.rs b/src/messaging/discord.rs index 1d7855ee6..cbd84803d 100644 --- a/src/messaging/discord.rs +++ b/src/messaging/discord.rs @@ -722,15 +722,14 @@ impl EventHandler for Handler { "discord_message_id".into(), serde_json::Value::Number(component.message.id.get().into()), ); + let discord_mentioned_bot = false; + let discord_reply_to_bot = true; + metadata.insert("discord_mentioned_bot".into(), discord_mentioned_bot.into()); + metadata.insert("discord_reply_to_bot".into(), discord_reply_to_bot.into()); metadata.insert( "discord_mentions_or_replies_to_bot".into(), - serde_json::Value::Bool(true), + (discord_mentioned_bot || discord_reply_to_bot).into(), ); - metadata.insert( - "discord_mentioned_bot".into(), - serde_json::Value::Bool(false), - ); - metadata.insert("discord_reply_to_bot".into(), serde_json::Value::Bool(true)); if let Some(guild_id) = component.guild_id { metadata.insert( "discord_guild_id".into(), diff --git a/src/messaging/twitch.rs b/src/messaging/twitch.rs index 0d86b32e4..aaa9accad 100644 --- a/src/messaging/twitch.rs +++ b/src/messaging/twitch.rs @@ -297,7 +297,7 @@ impl Messaging for TwitchAdapter { serde_json::Value::String(privmsg.sender.name.clone()), ); let message_lower = privmsg.message_text.to_lowercase(); - let mention = format!("@{}", bot_username.to_lowercase()); + let mention = format!("@{bot_username}"); let is_login_char = |character: char| { character.is_ascii_lowercase() || character.is_ascii_digit() diff --git a/src/tools/reply.rs b/src/tools/reply.rs index 2e2e74b7b..2a9ddea23 100644 --- a/src/tools/reply.rs +++ b/src/tools/reply.rs @@ -73,6 +73,7 @@ impl ReplyTool { } fn enforce_agent_style(_agent_id: &str, content: &str) -> String { + // TODO: apply per-agent style/voice transforms when rules are defined. content.to_string() } @@ -471,7 +472,7 @@ impl Tool for ReplyTool { } else if cards_requested || interactive_requested || poll_requested { let supports_cards = matches!(source, "discord"); let supports_interactive = matches!(source, "discord"); - let supports_poll = matches!(source, "discord" | "telegram"); + let supports_poll = matches!(source, "discord"); let mut unsupported = Vec::new(); if cards_requested && !supports_cards { unsupported.push("cards"); From 3c72735259b9539d3336bde4000950b8c6bdb5d4 Mon Sep 17 00:00:00 2001 From: Deanfluence Bot <deanfluencebot@gmail.com> Date: Wed, 4 Mar 2026 10:53:37 +0800 Subject: [PATCH 34/41] review: harden telegram mentions and include twitch reply invoke --- src/agent/channel.rs | 17 +++++++++++++++-- src/messaging/twitch.rs | 9 ++++++++- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/src/agent/channel.rs b/src/agent/channel.rs index c3a54a03d..3054b6d92 100644 --- a/src/agent/channel.rs +++ b/src/agent/channel.rs @@ -424,8 +424,21 @@ impl Channel { .get("telegram_bot_username") .and_then(|v| v.as_str()) .map(|username| { - let mention = format!("@{username}").to_lowercase(); - text_lower.contains(&mention) + let mention = format!("@{}", username.to_lowercase()); + text_lower.match_indices(&mention).any(|(start, _)| { + let end = start + mention.len(); + let before_ok = start == 0 + || text_lower[..start].chars().next_back().is_none_or( + |character| { + !(character.is_ascii_alphanumeric() || character == '_') + }, + ); + let after_ok = end == text_lower.len() + || text_lower[end..].chars().next().is_none_or(|character| { + !(character.is_ascii_alphanumeric() || character == '_') + }); + before_ok && after_ok + }) }) .unwrap_or(false) } diff --git a/src/messaging/twitch.rs b/src/messaging/twitch.rs index aaa9accad..5f6d6210c 100644 --- a/src/messaging/twitch.rs +++ b/src/messaging/twitch.rs @@ -310,9 +310,16 @@ impl Messaging for TwitchAdapter { let after_ok = after.map(|character| !is_login_char(character)).unwrap_or(true); before_ok && after_ok }); + let replies_to_bot = privmsg + .source + .tags + .0 + .get("reply-parent-msg-id") + .and_then(|value| value.as_ref()) + .is_some_and(|value| !value.is_empty()); metadata.insert( "twitch_mentions_or_replies_to_bot".into(), - serde_json::Value::Bool(mentions_bot), + serde_json::Value::Bool(mentions_bot || replies_to_bot), ); let formatted_author = format!( From 7ee08405d0fd8e8eef5d346ed801d23c7d89bf59 Mon Sep 17 00:00:00 2001 From: Deanfluence Bot <deanfluencebot@gmail.com> Date: Wed, 4 Mar 2026 11:17:40 +0800 Subject: [PATCH 35/41] review: tighten telegram/twitch invoke semantics --- src/agent/channel.rs | 21 ++++++++++----------- src/messaging/twitch.rs | 4 ++-- 2 files changed, 12 insertions(+), 13 deletions(-) diff --git a/src/agent/channel.rs b/src/agent/channel.rs index 3054b6d92..651a6c733 100644 --- a/src/agent/channel.rs +++ b/src/agent/channel.rs @@ -468,6 +468,11 @@ impl Channel { .and_then(|v| v.as_bool()) .unwrap_or(false), "telegram" => { + let reply_to_is_bot = message + .metadata + .get("reply_to_is_bot") + .and_then(|v| v.as_bool()) + .unwrap_or(false); let bot_username = message .metadata .get("telegram_bot_username") @@ -478,9 +483,10 @@ impl Channel { .get("reply_to_username") .and_then(|v| v.as_str()) .map(str::to_lowercase); - match reply_username { - Some(reply) => bot_username.is_none_or(|bot| bot == reply), - None => false, + match (reply_username, bot_username) { + (Some(reply), Some(bot)) => reply_to_is_bot && bot == reply, + (Some(_reply), None) => reply_to_is_bot, + _ => false, } } _ => message @@ -1114,14 +1120,7 @@ impl Channel { // This avoids model/provider flakiness for simple "you there?" style checks. if message.source == "telegram" { let text = raw_text.trim().to_lowercase(); - let mention = message - .metadata - .get("telegram_bot_username") - .and_then(|v| v.as_str()) - .map(|u| format!("@{u}")) - .unwrap_or_default() - .to_lowercase(); - let has_mention = !mention.is_empty() && text.contains(&mention); + let (_, has_mention, _) = self.compute_listen_mode_invocation(&message, &raw_text); let looks_like_ping = text.contains("you here") || text.contains("ping") || text.ends_with(" yo") diff --git a/src/messaging/twitch.rs b/src/messaging/twitch.rs index 5f6d6210c..0974a1326 100644 --- a/src/messaging/twitch.rs +++ b/src/messaging/twitch.rs @@ -314,9 +314,9 @@ impl Messaging for TwitchAdapter { .source .tags .0 - .get("reply-parent-msg-id") + .get("reply-parent-user-login") .and_then(|value| value.as_ref()) - .is_some_and(|value| !value.is_empty()); + .is_some_and(|login| login.eq_ignore_ascii_case(&bot_username)); metadata.insert( "twitch_mentions_or_replies_to_bot".into(), serde_json::Value::Bool(mentions_bot || replies_to_bot), From 63bbc3ac724fe161ce0f5703f9ee5ea71ec84826 Mon Sep 17 00:00:00 2001 From: Deanfluence Bot <deanfluencebot@gmail.com> Date: Wed, 4 Mar 2026 11:19:57 +0800 Subject: [PATCH 36/41] review: tighten telegram reply matching and rich payload limits --- src/agent/channel.rs | 9 ++++----- src/tools/reply.rs | 16 ++++++++++++++++ 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/src/agent/channel.rs b/src/agent/channel.rs index 651a6c733..1e52acb30 100644 --- a/src/agent/channel.rs +++ b/src/agent/channel.rs @@ -483,11 +483,10 @@ impl Channel { .get("reply_to_username") .and_then(|v| v.as_str()) .map(str::to_lowercase); - match (reply_username, bot_username) { - (Some(reply), Some(bot)) => reply_to_is_bot && bot == reply, - (Some(_reply), None) => reply_to_is_bot, - _ => false, - } + reply_to_is_bot + && reply_username + .zip(bot_username) + .is_some_and(|(reply, bot)| bot == reply) } _ => message .metadata diff --git a/src/tools/reply.rs b/src/tools/reply.rs index 2a9ddea23..6e84265b1 100644 --- a/src/tools/reply.rs +++ b/src/tools/reply.rs @@ -473,6 +473,8 @@ impl Tool for ReplyTool { let supports_cards = matches!(source, "discord"); let supports_interactive = matches!(source, "discord"); let supports_poll = matches!(source, "discord"); + const DISCORD_MAX_CARDS: usize = 10; + const DISCORD_MAX_INTERACTIVE_ELEMENTS: usize = 5; let mut unsupported = Vec::new(); if cards_requested && !supports_cards { unsupported.push("cards"); @@ -489,6 +491,20 @@ impl Tool for ReplyTool { unsupported.join(", ") ))); } + if source == "discord" { + let card_count = args.cards.as_ref().map_or(0, Vec::len); + if card_count > DISCORD_MAX_CARDS { + return Err(ReplyError(format!( + "discord rich payload limit exceeded: cards={card_count} (max {DISCORD_MAX_CARDS})" + ))); + } + let interactive_count = args.interactive_elements.as_ref().map_or(0, Vec::len); + if interactive_count > DISCORD_MAX_INTERACTIVE_ELEMENTS { + return Err(ReplyError(format!( + "discord rich payload limit exceeded: interactive_elements={interactive_count} (max {DISCORD_MAX_INTERACTIVE_ELEMENTS})" + ))); + } + } OutboundResponse::RichMessage { text: converted_content.clone(), From 26aea572dc493071de3d9b6c1b274c5c75fd4fd8 Mon Sep 17 00:00:00 2001 From: Deanfluence Bot <deanfluencebot@gmail.com> Date: Wed, 4 Mar 2026 11:37:05 +0800 Subject: [PATCH 37/41] fix: scope listen-only persistence per channel --- src/agent/channel.rs | 54 ++++++++++++++++++++++++++++++++++++------- src/config/runtime.rs | 4 ++-- src/settings/store.rs | 29 +++++++++++++++++++++++ 3 files changed, 77 insertions(+), 10 deletions(-) diff --git a/src/agent/channel.rs b/src/agent/channel.rs index 1e52acb30..f112576c3 100644 --- a/src/agent/channel.rs +++ b/src/agent/channel.rs @@ -253,6 +253,25 @@ impl Channel { let self_tx = message_tx.clone(); let default_listen_only_mode = deps.runtime_config.channel_config.load().listen_only_mode; + let persisted_listen_only_mode = deps + .runtime_config + .settings + .load() + .as_ref() + .as_ref() + .and_then( + |settings| match settings.channel_listen_only_mode_for(id.as_ref()) { + Ok(value) => value, + Err(error) => { + tracing::warn!( + %error, + channel_id = %id, + "failed to load channel-scoped listen_only_mode setting" + ); + None + } + }, + ); let channel = Self { id: id.clone(), title: None, @@ -279,7 +298,7 @@ impl Channel { retrigger_deadline: None, pending_results: Vec::new(), send_agent_message_tool, - listen_only_mode: default_listen_only_mode, + listen_only_mode: persisted_listen_only_mode.unwrap_or(default_listen_only_mode), }; (channel, message_tx) @@ -306,12 +325,36 @@ impl Channel { } fn sync_listen_only_mode_from_runtime(&mut self) { - self.listen_only_mode = self + let runtime_default = self .deps .runtime_config .channel_config .load() .listen_only_mode; + let settings_store = self + .deps + .runtime_config + .settings + .load() + .as_ref() + .as_ref() + .cloned(); + self.listen_only_mode = if let Some(settings_store) = settings_store { + match settings_store.channel_listen_only_mode_for(self.id.as_ref()) { + Ok(Some(enabled)) => enabled, + Ok(None) => runtime_default, + Err(error) => { + tracing::warn!( + %error, + channel_id = %self.id, + "failed to sync channel-scoped listen_only_mode setting" + ); + runtime_default + } + } + } else { + runtime_default + }; } fn set_listen_only_mode(&mut self, enabled: bool) -> bool { @@ -325,7 +368,7 @@ impl Channel { .as_ref() .cloned(); if let Some(settings_store) = settings_store { - match settings_store.set_channel_listen_only_mode(enabled) { + match settings_store.set_channel_listen_only_mode_for(self.id.as_ref(), enabled) { Ok(()) => persisted = true, Err(error) => { tracing::warn!( @@ -345,11 +388,6 @@ impl Channel { } self.listen_only_mode = enabled; - self.deps.runtime_config.channel_config.rcu(|current| { - let mut next = **current; - next.listen_only_mode = enabled; - Arc::new(next) - }); persisted } diff --git a/src/config/runtime.rs b/src/config/runtime.rs index 7a9412219..04bfd65f1 100644 --- a/src/config/runtime.rs +++ b/src/config/runtime.rs @@ -215,11 +215,11 @@ impl RuntimeConfig { } } }); - self.channel_config.rcu(move |current| { + self.channel_config.rcu(move |_current| { let mut next = resolved_channel; next.listen_only_mode = configured_listen_only .or(persisted_listen_only) - .unwrap_or(current.as_ref().listen_only_mode); + .unwrap_or(next.listen_only_mode); Arc::new(next) }); self.max_turns.store(Arc::new(resolved.max_turns)); diff --git a/src/settings/store.rs b/src/settings/store.rs index 419df91bf..668236c6f 100644 --- a/src/settings/store.rs +++ b/src/settings/store.rs @@ -13,6 +13,7 @@ const SETTINGS_TABLE: TableDefinition<&str, &str> = TableDefinition::new("settin pub const WORKER_LOG_MODE_KEY: &str = "worker_log_mode"; /// Key for channel listen-only mode setting. pub const CHANNEL_LISTEN_ONLY_MODE_KEY: &str = "channel_listen_only_mode"; +const CHANNEL_LISTEN_ONLY_MODE_PREFIX: &str = "channel_listen_only_mode:"; /// How worker execution logs are stored. #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] @@ -56,6 +57,9 @@ pub struct SettingsStore { } impl SettingsStore { + fn channel_listen_only_mode_key(channel_id: &str) -> String { + format!("{CHANNEL_LISTEN_ONLY_MODE_PREFIX}{channel_id}") + } /// Create a new settings store at the given path. /// The database will be created if it doesn't exist. pub fn new(path: &Path) -> Result<Self> { @@ -188,6 +192,31 @@ impl SettingsStore { if enabled { "true" } else { "false" }, ) } + + /// Get the listen-only mode for a specific channel, if explicitly persisted. + pub fn channel_listen_only_mode_for(&self, channel_id: &str) -> Result<Option<bool>> { + let key = Self::channel_listen_only_mode_key(channel_id); + match self.get_raw(&key) { + Ok(raw) => raw.parse::<bool>().map(Some).map_err(|error| { + SettingsError::ReadFailed { + key: key.clone(), + details: format!("invalid boolean value '{raw}': {error}"), + } + .into() + }), + Err(crate::error::Error::Settings(settings_error)) => match *settings_error { + SettingsError::NotFound { .. } => Ok(None), + other => Err(other.into()), + }, + Err(other) => Err(other), + } + } + + /// Persist listen-only mode for a specific channel. + pub fn set_channel_listen_only_mode_for(&self, channel_id: &str, enabled: bool) -> Result<()> { + let key = Self::channel_listen_only_mode_key(channel_id); + self.set_raw(&key, if enabled { "true" } else { "false" }) + } } impl std::fmt::Debug for SettingsStore { From 686f269d17e888a9d5f3155ec6b477db2240f938 Mon Sep 17 00:00:00 2001 From: Deanfluence Bot <deanfluencebot@gmail.com> Date: Wed, 4 Mar 2026 11:49:48 +0800 Subject: [PATCH 38/41] fix: preserve quiet-mode precedence and session override --- src/agent/channel.rs | 36 ++++++++++++++---------------------- src/config/runtime.rs | 12 ++++++++++-- src/tools/reply.rs | 3 ++- 3 files changed, 26 insertions(+), 25 deletions(-) diff --git a/src/agent/channel.rs b/src/agent/channel.rs index f112576c3..bed9c0b86 100644 --- a/src/agent/channel.rs +++ b/src/agent/channel.rs @@ -177,6 +177,8 @@ pub struct Channel { /// Channel-local reply mode toggle. /// When true, suppress unsolicited replies unless explicitly invoked. listen_only_mode: bool, + /// Session-scoped override used when persistence is unavailable/failed. + listen_only_session_override: Option<bool>, } impl Channel { @@ -252,26 +254,7 @@ impl Channel { }; let self_tx = message_tx.clone(); - let default_listen_only_mode = deps.runtime_config.channel_config.load().listen_only_mode; - let persisted_listen_only_mode = deps - .runtime_config - .settings - .load() - .as_ref() - .as_ref() - .and_then( - |settings| match settings.channel_listen_only_mode_for(id.as_ref()) { - Ok(value) => value, - Err(error) => { - tracing::warn!( - %error, - channel_id = %id, - "failed to load channel-scoped listen_only_mode setting" - ); - None - } - }, - ); + let resolved_listen_only_mode = deps.runtime_config.channel_config.load().listen_only_mode; let channel = Self { id: id.clone(), title: None, @@ -298,7 +281,8 @@ impl Channel { retrigger_deadline: None, pending_results: Vec::new(), send_agent_message_tool, - listen_only_mode: persisted_listen_only_mode.unwrap_or(default_listen_only_mode), + listen_only_mode: resolved_listen_only_mode, + listen_only_session_override: None, }; (channel, message_tx) @@ -325,12 +309,17 @@ impl Channel { } fn sync_listen_only_mode_from_runtime(&mut self) { + if let Some(override_mode) = self.listen_only_session_override { + self.listen_only_mode = override_mode; + return; + } let runtime_default = self .deps .runtime_config .channel_config .load() .listen_only_mode; + let explicit_listen_only = **self.deps.runtime_config.channel_listen_only_explicit.load(); let settings_store = self .deps .runtime_config @@ -339,7 +328,9 @@ impl Channel { .as_ref() .as_ref() .cloned(); - self.listen_only_mode = if let Some(settings_store) = settings_store { + self.listen_only_mode = if explicit_listen_only.is_some() { + runtime_default + } else if let Some(settings_store) = settings_store { match settings_store.channel_listen_only_mode_for(self.id.as_ref()) { Ok(Some(enabled)) => enabled, Ok(None) => runtime_default, @@ -388,6 +379,7 @@ impl Channel { } self.listen_only_mode = enabled; + self.listen_only_session_override = if persisted { None } else { Some(enabled) }; persisted } diff --git a/src/config/runtime.rs b/src/config/runtime.rs index 04bfd65f1..636327878 100644 --- a/src/config/runtime.rs +++ b/src/config/runtime.rs @@ -58,6 +58,9 @@ pub struct RuntimeConfig { pub cron_scheduler: ArcSwap<Option<Arc<crate::cron::Scheduler>>>, /// Settings store for agent-specific configuration. pub settings: ArcSwap<Option<Arc<crate::settings::SettingsStore>>>, + /// Tracks whether listen_only_mode is explicitly configured via agent/env. + /// When set, channel-local persisted values must not override it. + pub channel_listen_only_explicit: ArcSwap<Option<bool>>, /// Secrets store for encrypted credential storage. pub secrets: ArcSwap<Option<Arc<crate::secrets::store::SecretsStore>>>, /// Sandbox configuration for process containment. @@ -117,6 +120,7 @@ impl RuntimeConfig { cron_store: ArcSwap::from_pointee(None), cron_scheduler: ArcSwap::from_pointee(None), settings: ArcSwap::from_pointee(None), + channel_listen_only_explicit: ArcSwap::from_pointee(None), secrets: ArcSwap::from_pointee(None), sandbox: Arc::new(ArcSwap::from_pointee(agent_config.sandbox.clone())), } @@ -139,6 +143,8 @@ impl RuntimeConfig { explicit_listen_only: Option<bool>, ) { self.settings.store(Arc::new(Some(settings.clone()))); + self.channel_listen_only_explicit + .store(Arc::new(explicit_listen_only)); if explicit_listen_only.is_none() { match settings.channel_listen_only_mode() { Ok(Some(enabled)) => { @@ -203,6 +209,8 @@ impl RuntimeConfig { self.ingestion.store(Arc::new(resolved.ingestion)); let resolved_channel = resolved.channel; let configured_listen_only = agent.channel.map(|channel| channel.listen_only_mode); + self.channel_listen_only_explicit + .store(Arc::new(configured_listen_only)); let persisted_listen_only = self.settings.load().as_ref().as_ref().and_then(|settings| { match settings.channel_listen_only_mode() { Ok(value) => value, @@ -215,11 +223,11 @@ impl RuntimeConfig { } } }); - self.channel_config.rcu(move |_current| { + self.channel_config.rcu(move |current| { let mut next = resolved_channel; next.listen_only_mode = configured_listen_only .or(persisted_listen_only) - .unwrap_or(next.listen_only_mode); + .unwrap_or(current.as_ref().listen_only_mode); Arc::new(next) }); self.max_turns.store(Arc::new(resolved.max_turns)); diff --git a/src/tools/reply.rs b/src/tools/reply.rs index 6e84265b1..e5ad871f1 100644 --- a/src/tools/reply.rs +++ b/src/tools/reply.rs @@ -46,6 +46,7 @@ pub struct ReplyTool { channel_id: ChannelId, replied_flag: RepliedFlag, agent_display_name: String, + // Captured for planned per-agent style transforms in enforce_agent_style. agent_id: String, } @@ -73,7 +74,7 @@ impl ReplyTool { } fn enforce_agent_style(_agent_id: &str, content: &str) -> String { - // TODO: apply per-agent style/voice transforms when rules are defined. + // TODO: enforce_agent_style should use _agent_id for per-agent voice rules. content.to_string() } From f963e73016b839b5c18e3ea176d749f2c633ea80 Mon Sep 17 00:00:00 2001 From: James Pine <ijamespine@me.com> Date: Wed, 4 Mar 2026 16:21:27 -0800 Subject: [PATCH 39/41] refactor: move Discord-specific validation from reply tool to adapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reply tool was accumulating adapter-specific concerns: platform support gating, Discord embed/action-row limits, thread+rich payload restrictions, and a cards-to-text fallback. These belong in the adapter layer — the reply tool should construct the OutboundResponse and pass it through. - Remove enforce_agent_style stub, cards_to_text, platform gating, Discord limit checks, and thread+rich restriction from reply tool - Add OutboundResponse::ensure_text_fallback() and text_from_cards() on the shared type so any adapter can derive plaintext from cards - Move embed/action-row limit enforcement into the Discord adapter's respond() and broadcast() with tracing::warn on truncation - Add cards-to-text fallback in the Discord adapter for empty-text rich messages --- src/lib.rs | 56 ++++++++++++++++++++ src/messaging/discord.rs | 79 +++++++++++++++++++++++----- src/tools.rs | 1 - src/tools/reply.rs | 111 ++------------------------------------- 4 files changed, 124 insertions(+), 123 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 330bebcea..8b87b2047 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -475,6 +475,62 @@ pub enum OutboundResponse { Status(StatusUpdate), } +impl OutboundResponse { + /// Ensure `RichMessage` variants have a non-empty `text` fallback. + /// + /// Some LLMs emit card-only payloads with empty content. This derives a + /// readable plaintext fallback from cards so adapters that don't support + /// rich formatting (or use `text` for notifications) always have content. + pub fn ensure_text_fallback(&mut self) { + if let OutboundResponse::RichMessage { text, cards, .. } = self { + if text.trim().is_empty() { + let derived = Self::text_from_cards(cards); + if !derived.trim().is_empty() { + *text = derived; + } + } + } + } + + /// Derive a plaintext representation from a slice of [`Card`]s. + /// + /// Used as a fallback when the LLM provides cards but no text content. + /// Adapters can call this directly when they destructure `RichMessage` + /// and need a text fallback without reconstructing the enum. + pub fn text_from_cards(cards: &[Card]) -> String { + let mut sections = Vec::new(); + for card in cards { + let mut lines = Vec::new(); + if let Some(title) = &card.title + && !title.trim().is_empty() + { + lines.push(title.trim().to_string()); + } + if let Some(description) = &card.description + && !description.trim().is_empty() + { + lines.push(description.trim().to_string()); + } + for field in &card.fields { + let name = field.name.trim(); + let value = field.value.trim(); + if !name.is_empty() || !value.is_empty() { + lines.push(format!("{name}\n{value}").trim().to_string()); + } + } + if let Some(footer) = &card.footer + && !footer.trim().is_empty() + { + lines.push(footer.trim().to_string()); + } + if !lines.is_empty() { + sections.push(lines.join("\n\n")); + } + } + sections.join("\n\n") + } +} + /// A generic rich-formatted card (maps to Embeds in Discord). #[derive(Debug, Clone, Serialize, Deserialize, Default, schemars::JsonSchema)] pub struct Card { diff --git a/src/messaging/discord.rs b/src/messaging/discord.rs index cbd84803d..afd8c1c87 100644 --- a/src/messaging/discord.rs +++ b/src/messaging/discord.rs @@ -164,7 +164,7 @@ impl Messaging for DiscordAdapter { } } OutboundResponse::RichMessage { - text, + mut text, cards, interactive_elements, poll, @@ -173,6 +173,35 @@ impl Messaging for DiscordAdapter { self.stop_typing(message).await; let reply_to = Self::extract_reply_message_id(message); + // Derive a plaintext fallback from cards when text is empty so + // the message is never blank (notifications, logging, etc.). + if text.trim().is_empty() { + let derived = crate::OutboundResponse::text_from_cards(&cards); + if !derived.trim().is_empty() { + text = derived; + } + } + + // Enforce Discord API limits: max 10 embeds, 5 action rows. + let cards = if cards.len() > 10 { + tracing::warn!( + count = cards.len(), + "truncating cards to Discord embed limit (10)" + ); + &cards[..10] + } else { + &cards + }; + let interactive_elements = if interactive_elements.len() > 5 { + tracing::warn!( + count = interactive_elements.len(), + "truncating interactive elements to Discord action row limit (5)" + ); + &interactive_elements[..5] + } else { + &interactive_elements + }; + let chunks = split_message(&text, 2000); for (i, chunk) in chunks.iter().enumerate() { let is_last = i == chunks.len() - 1; @@ -183,16 +212,13 @@ impl Messaging for DiscordAdapter { // Attach rich content only to the final chunk if is_last { - let embeds: Vec<_> = cards.iter().take(10).map(build_embed).collect(); + let embeds: Vec<_> = cards.iter().map(build_embed).collect(); if !embeds.is_empty() { msg = msg.embeds(embeds); } - let components: Vec<_> = interactive_elements - .iter() - .take(5) - .map(build_action_row) - .collect(); + let components: Vec<_> = + interactive_elements.iter().map(build_action_row).collect(); if !components.is_empty() { msg = msg.components(components); } @@ -422,13 +448,41 @@ impl Messaging for DiscordAdapter { .context("failed to broadcast discord message")?; } } else if let OutboundResponse::RichMessage { - text, + mut text, cards, interactive_elements, poll, .. } = response { + // Derive a plaintext fallback from cards when text is empty. + if text.trim().is_empty() { + let derived = crate::OutboundResponse::text_from_cards(&cards); + if !derived.trim().is_empty() { + text = derived; + } + } + + // Enforce Discord API limits: max 10 embeds, 5 action rows. + let cards = if cards.len() > 10 { + tracing::warn!( + count = cards.len(), + "truncating cards to Discord embed limit (10)" + ); + &cards[..10] + } else { + &cards + }; + let interactive_elements = if interactive_elements.len() > 5 { + tracing::warn!( + count = interactive_elements.len(), + "truncating interactive elements to Discord action row limit (5)" + ); + &interactive_elements[..5] + } else { + &interactive_elements + }; + let chunks = split_message(&text, 2000); for (i, chunk) in chunks.iter().enumerate() { let is_last = i == chunks.len() - 1; @@ -439,16 +493,13 @@ impl Messaging for DiscordAdapter { // Attach rich content only to the final chunk if is_last { - let embeds: Vec<_> = cards.iter().take(10).map(build_embed).collect(); + let embeds: Vec<_> = cards.iter().map(build_embed).collect(); if !embeds.is_empty() { msg = msg.embeds(embeds); } - let components: Vec<_> = interactive_elements - .iter() - .take(5) - .map(build_action_row) - .collect(); + let components: Vec<_> = + interactive_elements.iter().map(build_action_row).collect(); if !components.is_empty() { msg = msg.components(components); } diff --git a/src/tools.rs b/src/tools.rs index c1359b2e8..ea5bdce24 100644 --- a/src/tools.rs +++ b/src/tools.rs @@ -307,7 +307,6 @@ pub async fn add_channel_tools( state.conversation_logger.clone(), state.channel_id.clone(), replied_flag.clone(), - state.deps.agent_id.to_string(), agent_display_name, )) .await?; diff --git a/src/tools/reply.rs b/src/tools/reply.rs index e5ad871f1..91c68b2e9 100644 --- a/src/tools/reply.rs +++ b/src/tools/reply.rs @@ -46,8 +46,6 @@ pub struct ReplyTool { channel_id: ChannelId, replied_flag: RepliedFlag, agent_display_name: String, - // Captured for planned per-agent style transforms in enforce_agent_style. - agent_id: String, } impl ReplyTool { @@ -58,7 +56,6 @@ impl ReplyTool { conversation_logger: ConversationLogger, channel_id: ChannelId, replied_flag: RepliedFlag, - agent_id: impl Into<String>, agent_display_name: impl Into<String>, ) -> Self { Self { @@ -67,50 +64,11 @@ impl ReplyTool { conversation_logger, channel_id, replied_flag, - agent_id: agent_id.into(), agent_display_name: agent_display_name.into(), } } } -fn enforce_agent_style(_agent_id: &str, content: &str) -> String { - // TODO: enforce_agent_style should use _agent_id for per-agent voice rules. - content.to_string() -} - -fn cards_to_text(cards: &[crate::Card]) -> String { - let mut sections = Vec::new(); - for card in cards { - let mut lines = Vec::new(); - if let Some(title) = &card.title - && !title.trim().is_empty() - { - lines.push(title.trim().to_string()); - } - if let Some(description) = &card.description - && !description.trim().is_empty() - { - lines.push(description.trim().to_string()); - } - for field in &card.fields { - let name = field.name.trim(); - let value = field.value.trim(); - if !name.is_empty() || !value.is_empty() { - lines.push(format!("{name}\n{value}").trim().to_string()); - } - } - if let Some(footer) = &card.footer - && !footer.trim().is_empty() - { - lines.push(footer.trim().to_string()); - } - if !lines.is_empty() { - sections.push(lines.join("\n\n")); - } - } - sections.join("\n\n") -} - /// Error type for reply tool. #[derive(Debug, thiserror::Error)] #[error("Reply failed: {0}")] @@ -403,21 +361,6 @@ impl Tool for ReplyTool { source, ) .await; - let mut converted_content = enforce_agent_style(&self.agent_id, &converted_content); - - // Some adapters/models emit card-only payloads with empty content. - // Derive a readable plaintext fallback from cards to avoid empty-message errors. - if converted_content.trim().is_empty() - && let Some(cards) = &args.cards - { - let from_cards = cards_to_text(cards); - if !from_cards.trim().is_empty() { - converted_content = enforce_agent_style(&self.agent_id, &from_cards); - } - } - if converted_content.trim().is_empty() { - converted_content = "noted.".to_string(); - } if crate::tools::should_block_user_visible_text(&converted_content) { tracing::warn!( @@ -446,19 +389,6 @@ impl Tool for ReplyTool { )); } - let cards_requested = args.cards.as_ref().is_some_and(|cards| !cards.is_empty()); - let interactive_requested = args - .interactive_elements - .as_ref() - .is_some_and(|elements| !elements.is_empty()); - let poll_requested = args.poll.is_some(); - - if thread_name.is_some() && (cards_requested || interactive_requested || poll_requested) { - return Err(ReplyError( - "thread replies do not support cards, interactive_elements, or polls".into(), - )); - } - let response = if let Some(name) = thread_name { // Cap thread names at 100 characters (Discord limit) let thread_name = if name.len() > 100 { @@ -470,46 +400,11 @@ impl Tool for ReplyTool { thread_name, text: converted_content.clone(), } - } else if cards_requested || interactive_requested || poll_requested { - let supports_cards = matches!(source, "discord"); - let supports_interactive = matches!(source, "discord"); - let supports_poll = matches!(source, "discord"); - const DISCORD_MAX_CARDS: usize = 10; - const DISCORD_MAX_INTERACTIVE_ELEMENTS: usize = 5; - let mut unsupported = Vec::new(); - if cards_requested && !supports_cards { - unsupported.push("cards"); - } - if interactive_requested && !supports_interactive { - unsupported.push("interactive_elements"); - } - if poll_requested && !supports_poll { - unsupported.push("poll"); - } - if !unsupported.is_empty() { - return Err(ReplyError(format!( - "unsupported rich payload for source '{source}': requested unsupported fields [{}]", - unsupported.join(", ") - ))); - } - if source == "discord" { - let card_count = args.cards.as_ref().map_or(0, Vec::len); - if card_count > DISCORD_MAX_CARDS { - return Err(ReplyError(format!( - "discord rich payload limit exceeded: cards={card_count} (max {DISCORD_MAX_CARDS})" - ))); - } - let interactive_count = args.interactive_elements.as_ref().map_or(0, Vec::len); - if interactive_count > DISCORD_MAX_INTERACTIVE_ELEMENTS { - return Err(ReplyError(format!( - "discord rich payload limit exceeded: interactive_elements={interactive_count} (max {DISCORD_MAX_INTERACTIVE_ELEMENTS})" - ))); - } - } - + } else if args.cards.is_some() || args.interactive_elements.is_some() || args.poll.is_some() + { OutboundResponse::RichMessage { text: converted_content.clone(), - blocks: vec![], // No block generation for now; Slack adapters will fall back to text + blocks: vec![], cards: args.cards.unwrap_or_default(), interactive_elements: args.interactive_elements.unwrap_or_default(), poll: args.poll, From 502c7dcdd5c005e890dde490fea80deb427e5398 Mon Sep 17 00:00:00 2001 From: James Pine <ijamespine@me.com> Date: Wed, 4 Mar 2026 16:34:48 -0800 Subject: [PATCH 40/41] fix: add missing ChannelConfig import in config/load.rs --- src/config/load.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/config/load.rs b/src/config/load.rs index 2fc0c7336..85a62c22e 100644 --- a/src/config/load.rs +++ b/src/config/load.rs @@ -10,8 +10,8 @@ use super::providers::{ }; use super::toml_schema::*; use super::{ - AgentConfig, ApiConfig, ApiType, Binding, BrowserConfig, ClosePolicy, CoalesceConfig, - CompactionConfig, Config, CortexConfig, CronDef, DefaultsConfig, DiscordConfig, + AgentConfig, ApiConfig, ApiType, Binding, BrowserConfig, ChannelConfig, ClosePolicy, + CoalesceConfig, CompactionConfig, Config, CortexConfig, CronDef, DefaultsConfig, DiscordConfig, DiscordInstanceConfig, EmailConfig, EmailInstanceConfig, GroupDef, HumanDef, IngestionConfig, LinkDef, LlmConfig, McpServerConfig, McpTransport, MemoryPersistenceConfig, MessagingConfig, MetricsConfig, OpenCodeConfig, ProviderConfig, SlackCommandConfig, SlackConfig, From a8169be1979a10aefea5e32eb931dd71ad6741ae Mon Sep 17 00:00:00 2001 From: James Pine <ijamespine@me.com> Date: Wed, 4 Mar 2026 17:20:04 -0800 Subject: [PATCH 41/41] fix: collapse nested if per clippy collapsible_if lint --- src/lib.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 8b87b2047..53e130036 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -482,12 +482,12 @@ impl OutboundResponse { /// readable plaintext fallback from cards so adapters that don't support /// rich formatting (or use `text` for notifications) always have content. pub fn ensure_text_fallback(&mut self) { - if let OutboundResponse::RichMessage { text, cards, .. } = self { - if text.trim().is_empty() { - let derived = Self::text_from_cards(cards); - if !derived.trim().is_empty() { - *text = derived; - } + if let OutboundResponse::RichMessage { text, cards, .. } = self + && text.trim().is_empty() + { + let derived = Self::text_from_cards(cards); + if !derived.trim().is_empty() { + *text = derived; } } }