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 pub name: String,
34 #[serde(default)]
36 pub topic: Option<String>,
37}
38
39#[derive(Debug, Deserialize, JsonSchema)]
40pub struct PostMessageArgs {
41 #[serde(default)]
43 pub channel: Option<String>,
44 #[serde(default)]
46 pub to: Option<String>,
47 pub body: String,
50 #[serde(default)]
52 pub reply_to: Option<i64>,
53 #[serde(default)]
55 #[schemars(schema_with = "crate::model::any_json_schema")]
56 pub metadata: Option<serde_json::Value>,
57 #[serde(default)]
60 pub attachments: Option<Vec<AttachmentInput>>,
61}
62
63#[derive(Debug, Deserialize, JsonSchema)]
64pub struct AttachmentInput {
65 pub filename: String,
66 #[serde(default)]
68 pub content_type: Option<String>,
69 pub data_base64: String,
71}
72
73#[derive(Debug, Deserialize, JsonSchema)]
74pub struct ReadMessagesArgs {
75 #[serde(default = "default_scope")]
78 pub scope: String,
79 #[serde(default = "default_true")]
82 pub only_new: bool,
83 #[serde(default = "default_limit")]
85 pub limit: i64,
86}
87
88#[derive(Debug, Deserialize, JsonSchema)]
89pub struct AskAgentArgs {
90 pub to: String,
92 #[serde(default)]
95 pub question: Option<String>,
96 #[serde(default)]
99 pub timeout_seconds: Option<i64>,
100 #[serde(default)]
103 pub resume_message_id: Option<i64>,
104}
105
106#[derive(Debug, Deserialize, JsonSchema)]
107pub struct SearchMessagesArgs {
108 pub query: String,
110 #[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 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 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 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 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 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}