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