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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions src/agent/channel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 {
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -851,9 +851,11 @@ impl Channel {
std::result::Result<String, rig::completion::PromptError>,
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,
Expand All @@ -862,6 +864,7 @@ impl Channel {
conversation_id,
skip_flag.clone(),
replied_flag.clone(),
edited_flag.clone(),
self.deps.cron_tool.clone(),
)
.await
Expand Down Expand Up @@ -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.
Expand All @@ -953,6 +956,7 @@ impl Channel {
result: std::result::Result<String, rig::completion::PromptError>,
skip_flag: &crate::tools::SkipFlag,
replied_flag: &crate::tools::RepliedFlag,
edited_flag: &crate::tools::EditedFlag,
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

edited_flag gets threaded through here, but handle_agent_result still only checks skip_flag/replied_flag. If the model calls edit_message without reply, you'll likely still hit the fallback text send and double-post. Consider loading edited_flag and treating it like replied_flag for fallback suppression.

is_retrigger: bool,
) {
match result {
Expand Down
9 changes: 9 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment says other platforms "falls back to sending a new message", but adapters currently treat EditMessage as a no-op. Suggest tightening this doc to match behavior.

Suggested change
/// Other platforms: falls back to sending a new message (no-op).
/// Other platforms: no-op (message unchanged).

EditMessage {
/// The platform-specific message ID to edit.
message_id: String,
/// The new text content.
text: String,
},
Status(StatusUpdate),
}

Expand Down
5 changes: 5 additions & 0 deletions src/messaging/discord.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
7 changes: 7 additions & 0 deletions src/messaging/slack.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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",
}
}
Expand Down
21 changes: 21 additions & 0 deletions src/messaging/telegram.rs
Original file line number Diff line number Diff line change
Expand Up @@ -471,6 +471,27 @@ impl Messaging for TelegramAdapter {
.await
.remove(&message.conversation_id);
}
OutboundResponse::EditMessage { message_id, text } => {
let msg_id = message_id
.parse::<i32>()
.context("invalid telegram message_id for edit")?;
let html = markdown_to_telegram_html(&text);

if let Err(html_error) = self
Comment on lines +478 to +480
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: there’s a whitespace-only line after markdown_to_telegram_html(&text); (shows up in the diff).

Suggested change
let html = markdown_to_telegram_html(&text);
if let Err(html_error) = self
let html = markdown_to_telegram_html(&text);
if let Err(html_error) = self

Also, this branch duplicates the StreamChunk edit logic above (markdown->HTML->plain fallback). Might be worth extracting a small helper to keep behavior consistent.

.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?;
}
Expand Down
4 changes: 4 additions & 0 deletions src/messaging/twitch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion src/messaging/webchat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
3 changes: 2 additions & 1 deletion src/messaging/webhook.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
16 changes: 14 additions & 2 deletions src/tools.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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::{
Expand Down Expand Up @@ -176,20 +178,30 @@ pub async fn add_channel_tools(
handle: &ToolServerHandle,
state: ChannelState,
response_tx: mpsc::Sender<OutboundResponse>,
conversation_id: impl Into<String>,
conversation_id: impl Into<String> + Clone,
skip_flag: SkipFlag,
replied_flag: RepliedFlag,
edited_flag: EditedFlag,
cron_tool: Option<CronTool>,
) -> 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?;
Expand Down
139 changes: 139 additions & 0 deletions src/tools/edit_message.rs
Original file line number Diff line number Diff line change
@@ -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<AtomicBool>;

/// 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<OutboundResponse>,
conversation_id: String,
conversation_logger: ConversationLogger,
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

EditMessageTool stores conversation_logger/channel_id, but call() doesn’t use them yet. If they’re intended for future (logging edits / mention conversion), maybe add a quick note or wire it up; otherwise consider dropping them from the struct/ctor to keep the tool minimal.

channel_id: ChannelId,
edited_flag: EditedFlag,
}

impl EditMessageTool {
pub fn new(
response_tx: mpsc::Sender<OutboundResponse>,
conversation_id: impl Into<String>,
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<Self::Output, Self::Error> {
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,
})
}
}
Loading