Skip to main content

ai_crew_sync/tools/
messaging.rs

1use std::time::Duration;
2
3use rmcp::{
4    ErrorData, Json, handler::server::wrapper::Parameters, service::RequestContext, tool,
5    tool_router,
6};
7use schemars::JsonSchema;
8use serde::Deserialize;
9
10use super::{Bus, auth_of};
11use crate::{
12    model::{AskResult, ChannelInfo, ChannelList, MessageList, PostMessageResult},
13    store::{agent_id_by_name, messaging},
14};
15
16const ASK_DEFAULT_TIMEOUT_SECS: i64 = 45;
17const ASK_MAX_TIMEOUT_SECS: i64 = 55;
18
19fn default_limit() -> i64 {
20    50
21}
22fn default_true() -> bool {
23    true
24}
25fn default_scope() -> String {
26    "all".into()
27}
28
29#[derive(Debug, Deserialize, JsonSchema)]
30pub struct CreateChannelArgs {
31    /// Channel name, e.g. "deploys". A leading '#' is optional and names are
32    /// lowercased. Creating a channel that already exists is not an error.
33    pub name: String,
34    /// Optional one-line description of what belongs in this channel.
35    #[serde(default)]
36    pub topic: Option<String>,
37}
38
39#[derive(Debug, Deserialize, JsonSchema)]
40pub struct PostMessageArgs {
41    /// Channel to broadcast to. Mutually exclusive with `to`.
42    #[serde(default)]
43    pub channel: Option<String>,
44    /// Agent handle to send a direct message to. Mutually exclusive with `channel`.
45    #[serde(default)]
46    pub to: Option<String>,
47    /// The message text. Keep it short and factual; teammates' agents read this.
48    pub body: String,
49    /// Id of the message this replies to, to keep a thread together.
50    #[serde(default)]
51    pub reply_to: Option<i64>,
52    /// Optional structured payload attached to the message (any JSON object).
53    #[serde(default)]
54    pub metadata: Option<serde_json::Value>,
55    /// Small files to ship with the message (diffs, logs, configs). Max 8
56    /// files, 256 KiB each (decoded).
57    #[serde(default)]
58    pub attachments: Option<Vec<AttachmentInput>>,
59}
60
61#[derive(Debug, Deserialize, JsonSchema)]
62pub struct AttachmentInput {
63    pub filename: String,
64    /// MIME type; defaults to application/octet-stream.
65    #[serde(default)]
66    pub content_type: Option<String>,
67    /// File content, base64-encoded.
68    pub data_base64: String,
69}
70
71#[derive(Debug, Deserialize, JsonSchema)]
72pub struct ReadMessagesArgs {
73    /// What to read: "all" (everything visible to you), "inbox" (direct
74    /// messages addressed to you), or a channel name such as "deploys".
75    #[serde(default = "default_scope")]
76    pub scope: String,
77    /// When true (the default) return only messages you have not read yet and
78    /// advance your read cursor. Set false to re-read recent history.
79    #[serde(default = "default_true")]
80    pub only_new: bool,
81    /// Maximum messages to return (1-200).
82    #[serde(default = "default_limit")]
83    pub limit: i64,
84}
85
86#[derive(Debug, Deserialize, JsonSchema)]
87pub struct AskAgentArgs {
88    /// Agent handle to ask. Use list_agents to see who is around.
89    pub to: String,
90    /// The question, sent as a direct message. Omit when resuming with
91    /// `resume_message_id`.
92    #[serde(default)]
93    pub question: Option<String>,
94    /// How long to wait for the answer, in seconds (5-55, default 45).
95    /// Kept under a minute so HTTP intermediaries do not cut the call.
96    #[serde(default)]
97    pub timeout_seconds: Option<i64>,
98    /// Keep waiting on an earlier question instead of sending a new one:
99    /// pass the `question_message_id` from a timed-out ask_agent call.
100    #[serde(default)]
101    pub resume_message_id: Option<i64>,
102}
103
104#[derive(Debug, Deserialize, JsonSchema)]
105pub struct SearchMessagesArgs {
106    /// Full-text search terms.
107    pub query: String,
108    /// Maximum messages to return (1-200).
109    #[serde(default = "default_limit")]
110    pub limit: i64,
111}
112
113#[tool_router(router = messaging_router, vis = "pub")]
114impl Bus {
115    #[tool(
116        description = "List the team's channels with their topic and message count. \
117                       Use this before posting so you pick an existing channel."
118    )]
119    async fn list_channels(
120        &self,
121        ctx: RequestContext<rmcp::RoleServer>,
122    ) -> Result<Json<ChannelList>, ErrorData> {
123        let auth = auth_of(&ctx)?;
124        Ok(Json(messaging::list_channels(&self.db, &auth).await?))
125    }
126
127    #[tool(
128        description = "Create a channel (or update its topic if it already exists). \
129                       Channels are shared by the whole team."
130    )]
131    async fn create_channel(
132        &self,
133        ctx: RequestContext<rmcp::RoleServer>,
134        Parameters(args): Parameters<CreateChannelArgs>,
135    ) -> Result<Json<ChannelInfo>, ErrorData> {
136        let auth = auth_of(&ctx)?;
137        Ok(Json(
138            messaging::create_channel(&self.db, &auth, &args.name, args.topic).await?,
139        ))
140    }
141
142    #[tool(
143        description = "Send a message to the team. Set `channel` to broadcast, or `to` \
144                       with an agent handle to send a direct message. The sender is \
145                       your own identity and cannot be spoofed."
146    )]
147    async fn post_message(
148        &self,
149        ctx: RequestContext<rmcp::RoleServer>,
150        Parameters(args): Parameters<PostMessageArgs>,
151    ) -> Result<Json<PostMessageResult>, ErrorData> {
152        let auth = auth_of(&ctx)?;
153        let raw = args.attachments.unwrap_or_default();
154        if raw.len() > 8 {
155            return Err(
156                crate::error::BusError::invalid("a message carries at most 8 attachments").into(),
157            );
158        }
159        let attachments = raw
160            .into_iter()
161            .map(|a| {
162                crate::store::attachments::decode_input(&a.filename, a.content_type, &a.data_base64)
163            })
164            .collect::<Result<Vec<_>, _>>()?;
165        let input = messaging::PostInput {
166            channel: args.channel,
167            to: args.to,
168            body: args.body,
169            reply_to: args.reply_to,
170            metadata: args.metadata,
171            attachments,
172        };
173        Ok(Json(messaging::post_message(&self.db, &auth, input).await?))
174    }
175
176    #[tool(
177        description = "Read messages from the bus. By default returns only what you \
178                       have not seen and marks it as read, so calling it repeatedly \
179                       gives you an incremental feed of what teammates are saying."
180    )]
181    async fn read_messages(
182        &self,
183        ctx: RequestContext<rmcp::RoleServer>,
184        Parameters(args): Parameters<ReadMessagesArgs>,
185    ) -> Result<Json<MessageList>, ErrorData> {
186        let auth = auth_of(&ctx)?;
187        let input = messaging::ReadInput {
188            scope: args.scope,
189            only_new: args.only_new,
190            limit: args.limit,
191        };
192        Ok(Json(
193            messaging::read_messages(&self.db, &auth, input).await?,
194        ))
195    }
196
197    #[tool(
198        description = "Ask a teammate's agent a question and block until they answer or the \
199                       timeout passes. Sends the question as a direct message and waits for \
200                       their reply, so one call replaces post_message + wait_for_updates + \
201                       read_messages. On timeout, call it again with `resume_message_id` set \
202                       to the returned question_message_id to keep waiting without asking \
203                       twice. If you receive a question yourself, answer with post_message \
204                       (`to` the asker, `reply_to` the question id)."
205    )]
206    async fn ask_agent(
207        &self,
208        ctx: RequestContext<rmcp::RoleServer>,
209        Parameters(args): Parameters<AskAgentArgs>,
210    ) -> Result<Json<AskResult>, ErrorData> {
211        let auth = auth_of(&ctx)?;
212        let to = args.to.trim().to_owned();
213        let timeout = args
214            .timeout_seconds
215            .unwrap_or(ASK_DEFAULT_TIMEOUT_SECS)
216            .clamp(5, ASK_MAX_TIMEOUT_SECS);
217
218        let target_id = agent_id_by_name(&self.db, auth.team_id, &to).await?;
219        if target_id == auth.agent_id {
220            return Err(crate::error::BusError::invalid(
221                "you cannot ask yourself; call list_agents and pick a teammate",
222            )
223            .into());
224        }
225
226        // Subscribe before posting or checking so an answer arriving in
227        // between still wakes us.
228        let mut rx = self.hub.subscribe();
229
230        let question_id = match args.resume_message_id {
231            Some(id) => {
232                messaging::verify_question(&self.db, &auth, target_id, id).await?;
233                id
234            }
235            None => {
236                let question = args
237                    .question
238                    .as_deref()
239                    .map(str::trim)
240                    .filter(|q| !q.is_empty())
241                    .ok_or_else(|| {
242                        crate::error::BusError::invalid(
243                            "provide `question`, or `resume_message_id` to keep waiting on \
244                             an earlier one",
245                        )
246                    })?;
247                let posted = messaging::post_message(
248                    &self.db,
249                    &auth,
250                    messaging::PostInput {
251                        channel: None,
252                        to: Some(to.clone()),
253                        body: question.to_owned(),
254                        reply_to: None,
255                        metadata: Some(serde_json::json!({ "question": true })),
256                        attachments: Vec::new(),
257                    },
258                )
259                .await?;
260                posted.message.id
261            }
262        };
263
264        let deadline = tokio::time::Instant::now() + Duration::from_secs(timeout as u64);
265        loop {
266            // Check the database first: covers resumed asks whose answer
267            // already landed, and events lost while lagging.
268            if let Some(answer) =
269                messaging::find_answer(&self.db, &auth, target_id, question_id).await?
270            {
271                return Ok(Json(AskResult {
272                    answered: true,
273                    to,
274                    question_message_id: question_id,
275                    answer: Some(answer),
276                    suggestion: "The answer also sits unread in your inbox; read_messages \
277                                 will mark it read."
278                        .into(),
279                }));
280            }
281
282            // Wait for a direct message from the target (or the deadline).
283            let woke = loop {
284                tokio::select! {
285                    _ = tokio::time::sleep_until(deadline) => break false,
286                    recv = rx.recv() => match recv {
287                        Ok(ev) => {
288                            if ev.kind() == "message"
289                                && ev.sender_agent_id() == Some(target_id)
290                                && ev.recipient_agent_id() == Some(auth.agent_id)
291                            {
292                                break true;
293                            }
294                        }
295                        // Lagged: events were dropped; resync from the database.
296                        Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => break true,
297                        Err(tokio::sync::broadcast::error::RecvError::Closed) => break false,
298                    }
299                }
300            };
301
302            if !woke {
303                // One last look: the answer may have raced the deadline.
304                let answer =
305                    messaging::find_answer(&self.db, &auth, target_id, question_id).await?;
306                let answered = answer.is_some();
307                return Ok(Json(AskResult {
308                    answered,
309                    suggestion: if answered {
310                        "The answer also sits unread in your inbox; read_messages will \
311                         mark it read."
312                            .into()
313                    } else {
314                        format!(
315                            "{to} has not answered within {timeout}s. Call ask_agent again \
316                             with resume_message_id={question_id} to keep waiting without \
317                             re-sending, or do other work and check read_messages later."
318                        )
319                    },
320                    to,
321                    question_message_id: question_id,
322                    answer,
323                }));
324            }
325        }
326    }
327
328    #[tool(
329        description = "Full-text search across the team's channel history and your own \
330                       direct messages. Does not affect your read cursor."
331    )]
332    async fn search_messages(
333        &self,
334        ctx: RequestContext<rmcp::RoleServer>,
335        Parameters(args): Parameters<SearchMessagesArgs>,
336    ) -> Result<Json<MessageList>, ErrorData> {
337        let auth = auth_of(&ctx)?;
338        Ok(Json(
339            messaging::search_messages(&self.db, &auth, &args.query, args.limit).await?,
340        ))
341    }
342}