use std::sync::Arc;
use async_trait::async_trait;
use everruns_contracts::{tool_types::ToolHints, typed_id::MessageId};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use crate::tools::{Tool, ToolExecutionResult};
use crate::{RuntimeAgent, channel::ChannelReplyMode, tool_context::ToolContext};
pub const CHANNEL_POST_MESSAGE_TOOL_NAME: &str = "channel_post_message";
pub const MAX_CHANNEL_MESSAGE_CHARS: usize = 12_000;
pub const CHANNEL_REPLY_MODE_TAG_PREFIX: &str = "channel:reply_mode:";
pub const CHANNEL_TOOL_ONLY_TAG: &str = "channel:reply_mode:tool_only";
pub const SLACK_REPLY_MODE_TAG_PREFIX: &str = "slack:reply_mode:";
pub const SLACK_TOOL_ONLY_TAG: &str = "slack:reply_mode:tool_only";
const PROMPT_MARKER: &str = "# Channel Communication";
const SYSTEM_PROMPT: &str = r#"# Channel Communication
This session is attached to an external conversation. Normal assistant messages stay in Everruns and are not sent to that conversation.
Use `channel_post_message` to send user-facing updates, questions, and final answers. You choose the wording and when to communicate; the runtime selects the conversation that triggered this turn.
- Post concise, meaningful updates during longer work. Avoid low-level tool chatter or repeated acknowledgements.
- Send questions and final answers through `channel_post_message` before ending the turn.
- A successful result confirms the channel accepted the message and includes its message reference. Do not post the same content again as an assistant reply.
- If delivery fails, use the error to decide what to do. An uncertain result may already have been posted; do not blindly resend it.
- Platform reactions, edits, uploads, and approval tools remain available when enabled."#;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ChannelMessageReceipt {
pub platform: String,
pub channel: String,
pub message_ref: String,
}
#[async_trait]
pub trait ChannelMessageSender: Send + Sync {
async fn post_message(
&self,
text: &str,
input_message_id: MessageId,
tool_call_id: &str,
) -> Result<ChannelMessageReceipt, ToolExecutionResult>;
}
#[derive(Clone)]
pub struct ChannelMessageSenderExt(pub Arc<dyn ChannelMessageSender>);
pub fn session_uses_channel_tools(tags: &[String]) -> bool {
tags.iter().any(|tag| {
matches!(
tag.as_str(),
CHANNEL_TOOL_ONLY_TAG
| SLACK_TOOL_ONLY_TAG
| "channel:reply_mode:report_progress_only"
| "slack:reply_mode:report_progress_only"
)
})
}
pub fn sync_channel_reply_mode_tags(tags: &mut Vec<String>, mode: ChannelReplyMode) {
tags.retain(|tag| !tag.starts_with(CHANNEL_REPLY_MODE_TAG_PREFIX));
if mode == ChannelReplyMode::ToolOnly {
tags.push(CHANNEL_TOOL_ONLY_TAG.into());
}
}
pub fn sync_slack_reply_mode_tags(tags: &mut Vec<String>, mode: ChannelReplyMode) {
tags.retain(|tag| !tag.starts_with(SLACK_REPLY_MODE_TAG_PREFIX));
if mode == ChannelReplyMode::ToolOnly {
tags.push(SLACK_TOOL_ONLY_TAG.into());
}
sync_channel_reply_mode_tags(tags, mode);
}
pub fn apply_channel_message_mode(mut agent: RuntimeAgent) -> RuntimeAgent {
if !agent
.tools
.iter()
.any(|tool| tool.name() == CHANNEL_POST_MESSAGE_TOOL_NAME)
{
agent.tools.push(ChannelPostMessageTool.to_definition());
}
if !agent.system_prompt.contains(PROMPT_MARKER) {
agent.system_prompt = format!("{SYSTEM_PROMPT}\n\n{}", agent.system_prompt);
}
agent
}
pub struct ChannelPostMessageTool;
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct MessageArguments {
text: String,
}
#[async_trait]
impl Tool for ChannelPostMessageTool {
fn name(&self) -> &str {
CHANNEL_POST_MESSAGE_TOOL_NAME
}
fn display_name(&self) -> Option<&str> {
Some("Post to conversation")
}
fn description(&self) -> &str {
"Post a message to the external conversation that triggered this turn. Use for meaningful updates, questions, and final answers. Write the complete user-facing message in Markdown; no status heading is added. The runtime selects the destination and channel identity. Success confirms delivery and returns a message reference for later edits. Normal assistant replies are not published in agent-controlled mode."
}
fn parameters_schema(&self) -> Value {
json!({"type":"object", "properties": {"text": {
"type":"string", "minLength":1, "maxLength":MAX_CHANNEL_MESSAGE_CHARS,
"description":"Complete message to send to the user. Supports Markdown. Use concise updates, clear questions, or the final answer."
}}, "required":["text"], "additionalProperties":false})
}
fn requires_context(&self) -> bool {
true
}
fn hints(&self) -> ToolHints {
ToolHints::default()
.with_readonly(false)
.with_idempotent(false)
}
fn narrate(
&self,
_call: &everruns_contracts::tool_types::ToolCall,
phase: crate::tool_narration::ToolNarrationPhase,
_locale: Option<&str>,
_ctx: crate::tool_narration::ToolNarrationContext<'_>,
) -> Option<String> {
use crate::tool_narration::ToolNarrationPhase;
Some(
match phase {
ToolNarrationPhase::Started | ToolNarrationPhase::Waiting => {
"Posting to conversation"
}
ToolNarrationPhase::Completed => "Posted to conversation",
ToolNarrationPhase::Failed => "Could not post to conversation",
}
.into(),
)
}
async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
ToolExecutionResult::tool_error(
"Posting requires an external conversation and invocation context",
)
}
async fn execute_with_context(
&self,
arguments: Value,
context: &ToolContext,
) -> ToolExecutionResult {
let args = match serde_json::from_value::<MessageArguments>(arguments) {
Ok(args) => args,
Err(error) => {
return ToolExecutionResult::tool_error(format!(
"Invalid channel_post_message arguments: {error}"
));
}
};
if args.text.trim().is_empty() {
return ToolExecutionResult::tool_error("Message text must not be empty");
}
if args.text.chars().count() > MAX_CHANNEL_MESSAGE_CHARS {
return ToolExecutionResult::tool_error(
"Message text must not exceed 12000 characters; share a file for longer content",
);
}
let Some(sender) = context.extensions.get::<ChannelMessageSenderExt>() else {
return self.execute(Value::Null).await;
};
let Some(input_id) = context
.event_context
.as_ref()
.and_then(|ctx| ctx.input_message_id)
else {
return self.execute(Value::Null).await;
};
let Some(call_id) = context.tool_call_id.as_deref().filter(|id| !id.is_empty()) else {
return self.execute(Value::Null).await;
};
match sender.0.post_message(&args.text, input_id, call_id).await {
Ok(receipt) => ToolExecutionResult::success(json!({
"delivered":true, "platform":receipt.platform,
"channel":receipt.channel, "message_ref":receipt.message_ref
})),
Err(error) => error,
}
}
}
#[cfg(test)]
mod tests;