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