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