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