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/// Does this agent have a live presence row for some *other* session?
40///
41/// Used to tell an impossible question from a reasonable one: asking yourself
42/// as a person is fine when another of your windows is around to answer, and
43/// hopeless when it is not.
44async fn has_another_live_session(
45    pool: &sqlx::PgPool,
46    auth: &crate::auth::AuthCtx,
47) -> Result<bool, crate::error::BusError> {
48    let (other,): (bool,) = sqlx::query_as(
49        "SELECT EXISTS (
50             SELECT 1 FROM agent_presence
51             WHERE agent_id = $1 AND session <> $2 AND expires_at > now()
52         )",
53    )
54    .bind(auth.agent_id)
55    .bind(&auth.session)
56    .fetch_one(pool)
57    .await?;
58    Ok(other)
59}
60
61#[derive(Debug, Deserialize, JsonSchema)]
62pub struct PostMessageArgs {
63    /// Channel to broadcast to. Mutually exclusive with `to`.
64    #[serde(default)]
65    pub channel: Option<String>,
66    /// Interrupt the whole team with this one. A channel message normally
67    /// only wakes sessions focused on that channel; an announcement wakes
68    /// every session, whatever they are working on. Reserve it for what
69    /// genuinely blocks others — a deploy, a migration, a breaking change,
70    /// "stop pushing to main". Routine progress does not qualify, and a team
71    /// that is interrupted for everything stops reading announcements.
72    /// Channel messages only: a direct message already arrives unfiltered.
73    #[serde(default)]
74    pub announce: bool,
75    /// Who to send a direct message to. Mutually exclusive with `channel`.
76    /// `"dani"` reaches every session that teammate has open; `"dani/api"`
77    /// reaches only their `api` working context. Addressing one of your own
78    /// sessions is allowed and is how a coordinating window hands work to the
79    /// one that has the repository open.
80    #[serde(default)]
81    pub to: Option<String>,
82    /// The message text. Keep it short and factual; teammates' agents read
83    /// this. Hard limit 1 MiB — attach a file rather than pasting a huge log.
84    pub body: String,
85    /// Id of the message this replies to, to keep a thread together.
86    #[serde(default)]
87    pub reply_to: Option<i64>,
88    /// Optional structured payload attached to the message (any JSON object).
89    #[serde(default)]
90    #[schemars(schema_with = "crate::model::any_json_schema")]
91    pub metadata: Option<serde_json::Value>,
92    /// Small files to ship with the message (diffs, logs, configs). Max 8
93    /// files, 256 KiB each (decoded).
94    #[serde(default)]
95    pub attachments: Option<Vec<AttachmentInput>>,
96}
97
98#[derive(Debug, Deserialize, JsonSchema)]
99pub struct AttachmentInput {
100    pub filename: String,
101    /// MIME type; defaults to application/octet-stream.
102    #[serde(default)]
103    pub content_type: Option<String>,
104    /// File content, base64-encoded.
105    pub data_base64: String,
106}
107
108#[derive(Debug, Deserialize, JsonSchema)]
109pub struct ReadMessagesArgs {
110    /// What to read: "all" (everything visible to you), "inbox" (direct
111    /// messages addressed to you), or a channel name such as "deploys".
112    #[serde(default = "default_scope")]
113    pub scope: String,
114    /// When true (the default) return only messages you have not read yet and
115    /// advance your read cursor. Set false to re-read recent history.
116    #[serde(default = "default_true")]
117    pub only_new: bool,
118    /// Maximum messages to return (1-200).
119    #[serde(default = "default_limit")]
120    pub limit: i64,
121    /// Also return direct messages addressed to your *other* sessions. Off by
122    /// default so each working context sees its own; turn it on to catch up on
123    /// everything addressed to you anywhere.
124    #[serde(default)]
125    pub all_sessions: bool,
126}
127
128#[derive(Debug, Deserialize, JsonSchema)]
129pub struct AskAgentArgs {
130    /// Who to ask: an agent handle, or `agent/session` for one of their
131    /// working contexts. Use list_agents to see who is around and which
132    /// sessions they have open.
133    pub to: String,
134    /// The question, sent as a direct message. Omit when resuming with
135    /// `resume_message_id`.
136    #[serde(default)]
137    pub question: Option<String>,
138    /// How long to wait for the answer, in seconds (5-55, default 45).
139    /// Kept under a minute so HTTP intermediaries do not cut the call.
140    #[serde(default)]
141    pub timeout_seconds: Option<i64>,
142    /// Keep waiting on an earlier question instead of sending a new one:
143    /// pass the `question_message_id` from a timed-out ask_agent call.
144    #[serde(default)]
145    pub resume_message_id: Option<i64>,
146}
147
148#[derive(Debug, Deserialize, JsonSchema)]
149pub struct SearchMessagesArgs {
150    /// Full-text search terms.
151    pub query: String,
152    /// Maximum messages to return (1-200).
153    #[serde(default = "default_limit")]
154    pub limit: i64,
155}
156
157#[tool_router(router = messaging_router, vis = "pub")]
158impl Bus {
159    #[tool(
160        description = "List the team's channels with their topic and message count. \
161                       Use this before posting so you pick an existing channel."
162    )]
163    async fn list_channels(
164        &self,
165        ctx: RequestContext<rmcp::RoleServer>,
166    ) -> Result<Json<ChannelList>, ErrorData> {
167        let auth = auth_of(&ctx)?;
168        Ok(Json(messaging::list_channels(&self.db, &auth).await?))
169    }
170
171    #[tool(
172        description = "Create a channel (or update its topic if it already exists). \
173                       Channels are shared by the whole team."
174    )]
175    async fn create_channel(
176        &self,
177        ctx: RequestContext<rmcp::RoleServer>,
178        Parameters(args): Parameters<CreateChannelArgs>,
179    ) -> Result<Json<ChannelInfo>, ErrorData> {
180        let auth = auth_of(&ctx)?;
181        Ok(Json(
182            messaging::create_channel(&self.db, &auth, &args.name, args.topic).await?,
183        ))
184    }
185
186    #[tool(
187        description = "Send a message to the team. Set `channel` to broadcast, or `to` \
188                       with an agent handle to send a direct message. The sender is \
189                       your own identity and cannot be spoofed."
190    )]
191    async fn post_message(
192        &self,
193        ctx: RequestContext<rmcp::RoleServer>,
194        Parameters(args): Parameters<PostMessageArgs>,
195    ) -> Result<Json<PostMessageResult>, ErrorData> {
196        let auth = auth_of(&ctx)?;
197        let raw = args.attachments.unwrap_or_default();
198        if raw.len() > 8 {
199            return Err(
200                crate::error::BusError::invalid("a message carries at most 8 attachments").into(),
201            );
202        }
203        let attachments = raw
204            .into_iter()
205            .map(|a| {
206                crate::store::attachments::decode_input(&a.filename, a.content_type, &a.data_base64)
207            })
208            .collect::<Result<Vec<_>, _>>()?;
209        let input = messaging::PostInput {
210            channel: args.channel,
211            to: args.to,
212            announce: args.announce,
213            body: args.body,
214            reply_to: args.reply_to,
215            metadata: args.metadata,
216            attachments,
217        };
218        Ok(Json(messaging::post_message(&self.db, &auth, input).await?))
219    }
220
221    #[tool(
222        description = "Read messages from the bus. By default returns only what you \
223                       have not seen and marks it as read, so calling it repeatedly \
224                       gives you an incremental feed of what teammates are saying."
225    )]
226    async fn read_messages(
227        &self,
228        ctx: RequestContext<rmcp::RoleServer>,
229        Parameters(args): Parameters<ReadMessagesArgs>,
230    ) -> Result<Json<MessageList>, ErrorData> {
231        let auth = auth_of(&ctx)?;
232        let input = messaging::ReadInput {
233            scope: args.scope,
234            only_new: args.only_new,
235            limit: args.limit,
236            all_sessions: args.all_sessions,
237        };
238        Ok(Json(
239            messaging::read_messages(&self.db, &auth, input).await?,
240        ))
241    }
242
243    #[tool(
244        description = "Ask a teammate's agent a question and block until they answer or the \
245                       timeout passes. Sends the question as a direct message and waits for \
246                       their reply, so one call replaces post_message + wait_for_updates + \
247                       read_messages. On timeout, call it again with `resume_message_id` set \
248                       to the returned question_message_id to keep waiting without asking \
249                       twice. If you receive a question yourself, answer with post_message \
250                       (`to` the asker, `reply_to` the question id)."
251    )]
252    async fn ask_agent(
253        &self,
254        ctx: RequestContext<rmcp::RoleServer>,
255        Parameters(args): Parameters<AskAgentArgs>,
256    ) -> Result<Json<AskResult>, ErrorData> {
257        let auth = auth_of(&ctx)?;
258        let to = args.to.trim().to_owned();
259        let timeout = args
260            .timeout_seconds
261            .unwrap_or(ASK_DEFAULT_TIMEOUT_SECS)
262            .clamp(5, ASK_MAX_TIMEOUT_SECS);
263
264        let (target_name, target_session) = messaging::parse_address(&to)?;
265        let target_id = agent_id_by_name(&self.db, auth.team_id, &target_name).await?;
266        // Asking another of your own sessions is the point of session
267        // addressing — a coordinating window handing work to the one with the
268        // repository open. Only asking *this* window is impossible: nothing
269        // would ever read the question, and the call would block until timeout.
270        // Asking another of your own sessions is the point of session
271        // addressing. What cannot work is an address only *this* window can
272        // read, because the call blocks until something answers it.
273        if target_id == auth.agent_id {
274            let only_me = match target_session.as_deref() {
275                // This exact window: nothing else will ever read it.
276                Some(s) => s == auth.session,
277                // The person: another live window of yours can answer, so this
278                // is only hopeless when there is no other one.
279                None => !has_another_live_session(&self.db, &auth).await?,
280            };
281            if only_me {
282                return Err(crate::error::BusError::invalid(
283                    "nothing would ever read that question: the address resolves to this \
284                     session and no other window of yours is live. Ask a teammate, or \
285                     another of your own sessions as 'you/<session>' — list_agents shows \
286                     which are open.",
287                )
288                .into());
289            }
290        }
291
292        // Subscribe before posting or checking so an answer arriving in
293        // between still wakes us.
294        let mut rx = self.hub.subscribe();
295
296        let question_id = match args.resume_message_id {
297            Some(id) => {
298                messaging::verify_question(
299                    &self.db,
300                    &auth,
301                    target_id,
302                    target_session.as_deref(),
303                    id,
304                )
305                .await?;
306                id
307            }
308            None => {
309                let question = args
310                    .question
311                    .as_deref()
312                    .map(str::trim)
313                    .filter(|q| !q.is_empty())
314                    .ok_or_else(|| {
315                        crate::error::BusError::invalid(
316                            "provide `question`, or `resume_message_id` to keep waiting on \
317                             an earlier one",
318                        )
319                    })?;
320                let posted = messaging::post_message(
321                    &self.db,
322                    &auth,
323                    messaging::PostInput {
324                        channel: None,
325                        to: Some(to.clone()),
326                        // A question is a direct message; it already reaches
327                        // the addressee whatever they are focused on.
328                        announce: false,
329                        body: question.to_owned(),
330                        reply_to: None,
331                        metadata: Some(serde_json::json!({ "question": true })),
332                        attachments: Vec::new(),
333                    },
334                )
335                .await?;
336                posted.message.id
337            }
338        };
339
340        let deadline = tokio::time::Instant::now() + Duration::from_secs(timeout as u64);
341        loop {
342            // Check the database first: covers resumed asks whose answer
343            // already landed, and events lost while lagging.
344            if let Some(answer) = messaging::find_answer(
345                &self.db,
346                &auth,
347                target_id,
348                target_session.as_deref(),
349                question_id,
350            )
351            .await?
352            {
353                return Ok(Json(AskResult {
354                    answered: true,
355                    to,
356                    question_message_id: question_id,
357                    answer: Some(answer),
358                    suggestion: "The answer also sits unread in your inbox; read_messages \
359                                 will mark it read."
360                        .into(),
361                }));
362            }
363
364            // Wait for a direct message from the target (or the deadline).
365            let woke = loop {
366                tokio::select! {
367                    _ = tokio::time::sleep_until(deadline) => break false,
368                    recv = rx.recv() => match recv {
369                        Ok(ev) => {
370                            // Later than the question, so asking another of your
371                            // own sessions does not wake on your own question.
372                            // Addressed here or to the person, so a message to a
373                            // sibling window does not either.
374                            let for_us = ev.recipient_session().is_none_or(|s| s == auth.session);
375                            if ev.kind() == "message"
376                                && ev.sender_agent_id() == Some(target_id)
377                                && ev.recipient_agent_id() == Some(auth.agent_id)
378                                && ev.message_id().is_some_and(|id| id > question_id)
379                                && for_us
380                            {
381                                break true;
382                            }
383                        }
384                        // Lagged: events were dropped; resync from the database.
385                        Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => break true,
386                        Err(tokio::sync::broadcast::error::RecvError::Closed) => break false,
387                    }
388                }
389            };
390
391            if !woke {
392                // One last look: the answer may have raced the deadline.
393                let answer = messaging::find_answer(
394                    &self.db,
395                    &auth,
396                    target_id,
397                    target_session.as_deref(),
398                    question_id,
399                )
400                .await?;
401                let answered = answer.is_some();
402                return Ok(Json(AskResult {
403                    answered,
404                    suggestion: if answered {
405                        "The answer also sits unread in your inbox; read_messages will \
406                         mark it read."
407                            .into()
408                    } else {
409                        format!(
410                            "{to} has not answered within {timeout}s. Call ask_agent again \
411                             with resume_message_id={question_id} to keep waiting without \
412                             re-sending, or do other work and check read_messages later."
413                        )
414                    },
415                    to,
416                    question_message_id: question_id,
417                    answer,
418                }));
419            }
420        }
421    }
422
423    #[tool(
424        description = "Full-text search across the team's channel history and your own \
425                       direct messages. Does not affect your read cursor."
426    )]
427    async fn search_messages(
428        &self,
429        ctx: RequestContext<rmcp::RoleServer>,
430        Parameters(args): Parameters<SearchMessagesArgs>,
431    ) -> Result<Json<MessageList>, ErrorData> {
432        let auth = auth_of(&ctx)?;
433        Ok(Json(
434            messaging::search_messages(&self.db, &auth, &args.query, args.limit).await?,
435        ))
436    }
437}