diff --git a/src/agent/channel.rs b/src/agent/channel.rs index f29fc957d..aa5fcb440 100644 --- a/src/agent/channel.rs +++ b/src/agent/channel.rs @@ -568,7 +568,7 @@ impl Channel { } // Run agent turn with any image/audio attachments preserved - let (result, skip_flag, replied_flag) = self + let (result, skip_flag, replied_flag, edited_flag) = self .run_agent_turn( &combined_text, &system_prompt, @@ -577,7 +577,7 @@ impl Channel { ) .await?; - self.handle_agent_result(result, &skip_flag, &replied_flag, false) + self.handle_agent_result(result, &skip_flag, &replied_flag, &edited_flag, false) .await; // Check compaction if let Err(error) = self.compactor.check_and_compact().await { @@ -735,7 +735,7 @@ impl Channel { let is_retrigger = message.source == "system"; - let (result, skip_flag, replied_flag) = self + let (result, skip_flag, replied_flag, edited_flag) = self .run_agent_turn( &user_text, &system_prompt, @@ -744,7 +744,7 @@ impl Channel { ) .await?; - self.handle_agent_result(result, &skip_flag, &replied_flag, is_retrigger) + self.handle_agent_result(result, &skip_flag, &replied_flag, &edited_flag, is_retrigger) .await; // Check context size and trigger compaction if needed @@ -851,9 +851,11 @@ impl Channel { std::result::Result, crate::tools::SkipFlag, crate::tools::RepliedFlag, + crate::tools::EditedFlag, )> { let skip_flag = crate::tools::new_skip_flag(); let replied_flag = crate::tools::new_replied_flag(); + let edited_flag = crate::tools::new_edited_flag(); if let Err(error) = crate::tools::add_channel_tools( &self.tool_server, @@ -862,6 +864,7 @@ impl Channel { conversation_id, skip_flag.clone(), replied_flag.clone(), + edited_flag.clone(), self.deps.cron_tool.clone(), ) .await @@ -938,7 +941,7 @@ impl Channel { tracing::warn!(%error, "failed to remove channel tools"); } - Ok((result, skip_flag, replied_flag)) + Ok((result, skip_flag, replied_flag, edited_flag)) } /// Dispatch the LLM result: send fallback text, log errors, clean up typing. @@ -953,6 +956,7 @@ impl Channel { result: std::result::Result, skip_flag: &crate::tools::SkipFlag, replied_flag: &crate::tools::RepliedFlag, + edited_flag: &crate::tools::EditedFlag, is_retrigger: bool, ) { match result { diff --git a/src/lib.rs b/src/lib.rs index b06e676ce..5e38a7ce7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -356,6 +356,15 @@ pub enum OutboundResponse { StreamStart, StreamChunk(String), StreamEnd, + /// Edit a previously sent message by ID. + /// Telegram: edits the text of a message using editMessageText. + /// Other platforms: falls back to sending a new message (no-op). + EditMessage { + /// The platform-specific message ID to edit. + message_id: String, + /// The new text content. + text: String, + }, Status(StatusUpdate), } diff --git a/src/messaging/discord.rs b/src/messaging/discord.rs index ab433dd3a..532219e4a 100644 --- a/src/messaging/discord.rs +++ b/src/messaging/discord.rs @@ -330,6 +330,11 @@ impl Messaging for DiscordAdapter { } // Slack-specific variants — graceful fallbacks for Discord OutboundResponse::RemoveReaction(_) => {} // no-op + // Telegram-specific: edit a previously sent message + OutboundResponse::EditMessage { .. } => { + // Discord doesn't support editing arbitrary messages via API in the same way + tracing::debug!("EditMessage not supported on Discord, message unchanged"); + } OutboundResponse::Ephemeral { text, .. } => { // Discord has no ephemeral equivalent here; send as regular text if let Ok(channel_id) = self.extract_channel_id(message) { diff --git a/src/messaging/slack.rs b/src/messaging/slack.rs index 51e09375e..8ba228cf2 100644 --- a/src/messaging/slack.rs +++ b/src/messaging/slack.rs @@ -906,6 +906,12 @@ impl Messaging for SlackAdapter { .context("failed to remove slack reaction")?; } + // Telegram-specific: edit a previously sent message + OutboundResponse::EditMessage { .. } => { + // Slack does not support editing arbitrary messages in the same way + tracing::debug!("EditMessage not supported on Slack, message unchanged"); + } + OutboundResponse::Ephemeral { text, user_id } => { let thread_ts = extract_thread_ts(message); let req = SlackApiChatPostEphemeralRequest::new( @@ -1462,6 +1468,7 @@ fn variant_name(response: &OutboundResponse) -> &'static str { OutboundResponse::StreamStart => "StreamStart", OutboundResponse::StreamChunk(_) => "StreamChunk", OutboundResponse::StreamEnd => "StreamEnd", + OutboundResponse::EditMessage { .. } => "EditMessage", OutboundResponse::Status(_) => "Status", } } diff --git a/src/messaging/telegram.rs b/src/messaging/telegram.rs index eab7994c2..56de09c97 100644 --- a/src/messaging/telegram.rs +++ b/src/messaging/telegram.rs @@ -471,6 +471,27 @@ impl Messaging for TelegramAdapter { .await .remove(&message.conversation_id); } + OutboundResponse::EditMessage { message_id, text } => { + let msg_id = message_id + .parse::() + .context("invalid telegram message_id for edit")?; + let html = markdown_to_telegram_html(&text); + + if let Err(html_error) = self + .bot + .edit_message_text(chat_id, MessageId(msg_id), &html) + .parse_mode(ParseMode::Html) + .send() + .await + { + tracing::debug!(%html_error, "HTML edit failed, retrying as plain text"); + self.bot + .edit_message_text(chat_id, MessageId(msg_id), &text) + .send() + .await + .context("failed to edit telegram message")?; + } + } OutboundResponse::Status(status) => { self.send_status(message, status).await?; } diff --git a/src/messaging/twitch.rs b/src/messaging/twitch.rs index d716bf795..bf7648b84 100644 --- a/src/messaging/twitch.rs +++ b/src/messaging/twitch.rs @@ -387,6 +387,10 @@ impl Messaging for TwitchAdapter { OutboundResponse::Reaction(_) | OutboundResponse::RemoveReaction(_) | OutboundResponse::Status(_) => {} + OutboundResponse::EditMessage { .. } => { + // Telegram-specific: edit a previously sent message + tracing::debug!("EditMessage not supported on Twitch"); + } OutboundResponse::Ephemeral { text, .. } => { // No ephemeral concept in Twitch — send as regular chat message client diff --git a/src/messaging/webchat.rs b/src/messaging/webchat.rs index a016df1f4..6de6b780a 100644 --- a/src/messaging/webchat.rs +++ b/src/messaging/webchat.rs @@ -92,7 +92,8 @@ impl Messaging for WebChatAdapter { | OutboundResponse::Ephemeral { .. } | OutboundResponse::ScheduledMessage { .. } | OutboundResponse::RichMessage { .. } - | OutboundResponse::Status(_) => return Ok(()), + | OutboundResponse::Status(_) + | OutboundResponse::EditMessage { .. } => return Ok(()), }; let _ = tx.send(event).await; diff --git a/src/messaging/webhook.rs b/src/messaging/webhook.rs index e43f742ef..33f6f7bf2 100644 --- a/src/messaging/webhook.rs +++ b/src/messaging/webhook.rs @@ -195,7 +195,8 @@ impl Messaging for WebhookAdapter { // Reactions, status updates, and remove-reaction aren't meaningful over webhook OutboundResponse::Reaction(_) | OutboundResponse::RemoveReaction(_) - | OutboundResponse::Status(_) => return Ok(()), + | OutboundResponse::Status(_) + | OutboundResponse::EditMessage { .. } => return Ok(()), // Slack-specific rich variants — fall back to plain text OutboundResponse::Ephemeral { text, .. } => WebhookResponse { response_type: "text".into(), diff --git a/src/tools.rs b/src/tools.rs index 90fab2b1b..803d9344b 100644 --- a/src/tools.rs +++ b/src/tools.rs @@ -35,6 +35,7 @@ pub mod memory_save; pub mod react; pub mod read_skill; pub mod reply; +pub mod edit_message; pub mod route; pub mod send_file; pub mod send_message_to_another_channel; @@ -69,6 +70,7 @@ pub use memory_save::{ pub use react::{ReactArgs, ReactError, ReactOutput, ReactTool}; pub use read_skill::{ReadSkillArgs, ReadSkillError, ReadSkillOutput, ReadSkillTool}; pub use reply::{RepliedFlag, ReplyArgs, ReplyError, ReplyOutput, ReplyTool, new_replied_flag}; +pub use edit_message::{EditedFlag, EditMessageArgs, EditMessageError, EditMessageOutput, EditMessageTool, new_edited_flag}; pub use route::{RouteArgs, RouteError, RouteOutput, RouteTool}; pub use send_file::{SendFileArgs, SendFileError, SendFileOutput, SendFileTool}; pub use send_message_to_another_channel::{ @@ -176,20 +178,30 @@ pub async fn add_channel_tools( handle: &ToolServerHandle, state: ChannelState, response_tx: mpsc::Sender, - conversation_id: impl Into, + conversation_id: impl Into + Clone, skip_flag: SkipFlag, replied_flag: RepliedFlag, + edited_flag: EditedFlag, cron_tool: Option, ) -> Result<(), rig::tool::server::ToolServerError> { handle .add_tool(ReplyTool::new( response_tx.clone(), - conversation_id, + conversation_id.clone(), state.conversation_logger.clone(), state.channel_id.clone(), replied_flag.clone(), )) .await?; + handle + .add_tool(EditMessageTool::new( + response_tx.clone(), + conversation_id.clone(), + state.conversation_logger.clone(), + state.channel_id.clone(), + edited_flag.clone(), + )) + .await?; handle.add_tool(BranchTool::new(state.clone())).await?; handle.add_tool(SpawnWorkerTool::new(state.clone())).await?; handle.add_tool(RouteTool::new(state.clone())).await?; diff --git a/src/tools/edit_message.rs b/src/tools/edit_message.rs new file mode 100644 index 000000000..01877f725 --- /dev/null +++ b/src/tools/edit_message.rs @@ -0,0 +1,139 @@ +//! Tool for editing a previously sent Telegram message. + +use crate::conversation::ConversationLogger; +use crate::{ChannelId, OutboundResponse}; +use rig::completion::ToolDefinition; +use rig::tool::Tool; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use tokio::sync::mpsc; + +/// Shared flag between EditMessageTool and the channel event loop. +pub type EditedFlag = Arc; + +/// Create a new edited flag (defaults to false). +pub fn new_edited_flag() -> EditedFlag { + Arc::new(AtomicBool::new(false)) +} + +/// Tool for editing a previously sent Telegram message. +/// +/// This tool allows the agent to edit a message it previously sent by specifying +/// the message ID. Currently only supported on Telegram (uses editMessageText API). +/// On other platforms, calls are logged but have no effect. +#[derive(Debug, Clone)] +pub struct EditMessageTool { + response_tx: mpsc::Sender, + conversation_id: String, + conversation_logger: ConversationLogger, + channel_id: ChannelId, + edited_flag: EditedFlag, +} + +impl EditMessageTool { + pub fn new( + response_tx: mpsc::Sender, + conversation_id: impl Into, + conversation_logger: ConversationLogger, + channel_id: ChannelId, + edited_flag: EditedFlag, + ) -> Self { + Self { + response_tx, + conversation_id: conversation_id.into(), + conversation_logger, + channel_id, + edited_flag, + } + } +} + +#[derive(Debug, thiserror::Error)] +#[error("Edit message failed: {0}")] +pub struct EditMessageError(String); + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct EditMessageArgs { + /// The message ID to edit. This is the Telegram message_id that was returned + /// when the original message was sent. You can find this in conversation logs + /// or by referencing a previous message. + pub message_id: String, + /// The new text content to replace the original message with. + pub content: String, +} + +#[derive(Debug, Serialize)] +pub struct EditMessageOutput { + pub success: bool, + pub conversation_id: String, + pub message_id: String, + pub content: String, +} + +impl Tool for EditMessageTool { + const NAME: &'static str = "edit_message"; + + type Error = EditMessageError; + type Args = EditMessageArgs; + type Output = EditMessageOutput; + + async fn definition(&self, _prompt: String) -> ToolDefinition { + let parameters = serde_json::json!({ + "type": "object", + "properties": { + "message_id": { + "type": "string", + "description": "The Telegram message ID to edit. This is the numeric message_id from Telegram." + }, + "content": { + "type": "string", + "description": "The new text content to replace the original message with. Supports markdown formatting." + } + }, + "required": ["message_id", "content"] + }); + + ToolDefinition { + name: Self::NAME.to_string(), + description: "Edit a previously sent Telegram message by its message ID. Only works on Telegram; other platforms will log but not execute the edit.".to_string(), + parameters, + } + } + + async fn call(&self, args: Self::Args) -> Result { + tracing::info!( + conversation_id = %self.conversation_id, + message_id = %args.message_id, + content_len = args.content.len(), + "edit_message tool called" + ); + + let response = OutboundResponse::EditMessage { + message_id: args.message_id.clone(), + text: args.content.clone(), + }; + + self.response_tx + .send(response) + .await + .map_err(|e| EditMessageError(format!("failed to send edit: {e}")))?; + + // Mark the turn as handled so handle_agent_result skips any fallback + self.edited_flag.store(true, Ordering::Relaxed); + + tracing::debug!( + conversation_id = %self.conversation_id, + message_id = %args.message_id, + "edit sent to outbound channel" + ); + + Ok(EditMessageOutput { + success: true, + conversation_id: self.conversation_id.clone(), + message_id: args.message_id, + content: args.content, + }) + } +}