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