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;
9use sqlx::PgPool;
10use uuid::Uuid;
11
12use super::{Bus, auth_of};
13use crate::{
14 auth::AuthCtx,
15 events::BusEvent,
16 model::{WaitEvent, WaitResult},
17};
18
19const DEFAULT_TIMEOUT_SECS: i64 = 25;
20const MAX_TIMEOUT_SECS: i64 = 55;
21
22#[derive(Debug, Deserialize, JsonSchema)]
23pub struct WaitArgs {
24 #[serde(default)]
27 pub timeout_seconds: Option<i64>,
28 #[serde(default)]
31 pub kinds: Option<Vec<String>>,
32 #[serde(default)]
37 pub all_channels: bool,
38}
39
40async fn unread_dms(pool: &PgPool, auth: &AuthCtx) -> Result<i64, sqlx::Error> {
41 sqlx::query_scalar(
45 r#"
46 SELECT count(*)
47 FROM messages m
48 LEFT JOIN read_cursors c ON c.agent_id = $1 AND c.scope = $3
49 WHERE m.recipient_agent_id = $1
50 AND m.id > COALESCE(c.last_message_id, 0)
51 AND (m.recipient_session IS NULL OR m.recipient_session = $2)
52 "#,
53 )
54 .bind(auth.agent_id)
55 .bind(&auth.session)
56 .bind(crate::store::messaging::cursor_scope(
57 "inbox",
58 &auth.session,
59 ))
60 .fetch_one(pool)
61 .await
62}
63
64async fn unread_anything(
65 pool: &PgPool,
66 auth: &AuthCtx,
67 focus: Option<Uuid>,
68) -> Result<i64, sqlx::Error> {
69 sqlx::query_scalar(
70 r#"
71 SELECT count(*)
72 FROM messages m
73 LEFT JOIN read_cursors c ON c.agent_id = $1 AND c.scope = $4
74 WHERE m.team_id = $2
75 AND m.id > COALESCE(c.last_message_id, 0)
76 -- Same rule as the wake filter: only this session's own messages
77 -- are excluded, so a sibling window's announcement still counts.
78 AND NOT (m.sender_agent_id = $1 AND COALESCE(m.sender_session, '') = $3)
79 AND ((m.channel_id IS NOT NULL
80 -- Same rule as the wake filter: an announcement counts as
81 -- pending whatever this session is focused on. Reporting a
82 -- backlog the wait would not wake for is a lie the caller
83 -- cannot act on.
84 AND ($5::uuid IS NULL OR m.channel_id = $5 OR m.announce))
85 OR (m.recipient_agent_id = $1
86 AND (m.recipient_session IS NULL OR m.recipient_session = $3)))
87 "#,
88 )
89 .bind(auth.agent_id)
90 .bind(auth.team_id)
91 .bind(&auth.session)
92 .bind(crate::store::messaging::cursor_scope("all", &auth.session))
93 .bind(focus)
94 .fetch_one(pool)
95 .await
96}
97
98fn in_focus(event: &BusEvent, focus: Option<Uuid>) -> bool {
104 if event.is_announcement() {
108 return true;
109 }
110 match (focus, event.channel_id()) {
111 (Some(channel), Some(posted_in)) => channel == posted_in,
112 _ => true,
113 }
114}
115
116async fn describe(pool: &PgPool, event: &BusEvent) -> Option<WaitEvent> {
118 match event.kind() {
119 "message" => {
120 let id = event.message_id()?;
121 let row: (String, Option<String>, Option<String>, String) =
122 sqlx::query_as::<_, (String, Option<String>, Option<String>, String)>(
123 r#"
124 SELECT s.name, ch.name, r.name, left(m.body, 200)
125 FROM messages m
126 JOIN agents s ON s.id = m.sender_agent_id
127 LEFT JOIN channels ch ON ch.id = m.channel_id
128 LEFT JOIN agents r ON r.id = m.recipient_agent_id
129 WHERE m.id = $1
130 "#,
131 )
132 .bind(id)
133 .fetch_optional(pool)
134 .await
135 .ok()
136 .flatten()?;
137 let (sender, channel, recipient, body) = row;
138 let target = channel
139 .map(|c| format!("#{c}"))
140 .or(recipient.map(|r| format!("@{r}")))
141 .unwrap_or_default();
142 Some(WaitEvent {
143 kind: "message".into(),
144 summary: format!("{sender} → {target}: {body}"),
145 })
146 }
147 "task" => {
148 let key = event.0.get("key").and_then(|v| v.as_str())?;
149 let status = event
150 .0
151 .get("status")
152 .and_then(|v| v.as_str())
153 .unwrap_or("?");
154 let holder = match event.0.get("claimed_by").and_then(|v| v.as_str()) {
155 Some(uuid) => {
156 sqlx::query_scalar::<_, String>("SELECT name FROM agents WHERE id = $1::uuid")
157 .bind(uuid)
158 .fetch_optional(pool)
159 .await
160 .ok()
161 .flatten()
162 .map(|n| format!(" by {n}"))
163 .unwrap_or_default()
164 }
165 None => String::new(),
166 };
167 Some(WaitEvent {
168 kind: "task".into(),
169 summary: format!("task '{key}' is now {status}{holder}"),
170 })
171 }
172 "lock" => {
173 let name = event.0.get("name").and_then(|v| v.as_str())?;
174 let what = event
175 .0
176 .get("event")
177 .and_then(|v| v.as_str())
178 .unwrap_or("changed");
179 Some(WaitEvent {
180 kind: "lock".into(),
181 summary: format!("lock '{name}' {what}"),
182 })
183 }
184 "note" => {
185 let scope = event
186 .0
187 .get("scope")
188 .and_then(|v| v.as_str())
189 .unwrap_or("global");
190 let key = event.0.get("key").and_then(|v| v.as_str())?;
191 Some(WaitEvent {
192 kind: "note".into(),
193 summary: format!("note {scope}/{key} was updated"),
194 })
195 }
196 _ => None,
197 }
198}
199
200fn suggestion_for(events: &[WaitEvent], unread: i64) -> String {
201 if events.iter().any(|e| e.kind == "message") || unread > 0 {
202 "Call read_messages to fetch the new messages.".into()
203 } else if events.iter().any(|e| e.kind == "task") {
204 "Call list_tasks (or get_task) to see what changed.".into()
205 } else if events.iter().any(|e| e.kind == "lock") {
206 "Call list_locks (or retry acquire_lock) now.".into()
207 } else if events.iter().any(|e| e.kind == "note") {
208 "Call get_note to read the updated note.".into()
209 } else {
210 "Nothing happened; do other work or wait again.".into()
211 }
212}
213
214#[tool_router(router = events_router, vis = "pub")]
215impl Bus {
216 #[tool(
217 description = "Block until something happens on the bus that concerns you (a message \
218 arrives, a task changes state, a lock is released, a note is updated) or \
219 the timeout elapses. Use this instead of polling read_messages in a loop: \
220 call it when you are waiting on teammates and idle. Returns immediately \
221 if you already have unread messages."
222 )]
223 async fn wait_for_updates(
224 &self,
225 ctx: RequestContext<rmcp::RoleServer>,
226 Parameters(args): Parameters<WaitArgs>,
227 ) -> Result<Json<WaitResult>, ErrorData> {
228 let auth = auth_of(&ctx)?;
229 let timeout = args
230 .timeout_seconds
231 .unwrap_or(DEFAULT_TIMEOUT_SECS)
232 .clamp(5, MAX_TIMEOUT_SECS);
233 let kind_filter: Option<Vec<String>> = args
234 .kinds
235 .map(|ks| ks.into_iter().map(|k| k.trim().to_lowercase()).collect());
236 let wants = |kind: &str| {
237 kind_filter
238 .as_ref()
239 .map(|ks| ks.iter().any(|k| k == kind))
240 .unwrap_or(true)
241 };
242
243 let focus = match args.all_channels {
248 true => None,
249 false => crate::store::messaging::default_channel(&self.db, &auth)
250 .await?
251 .map(|(id, _)| id),
252 };
253
254 let mut rx = self.hub.subscribe();
257
258 let pending = unread_anything(&self.db, &auth, focus).await.map_err(|e| {
261 tracing::error!(error = %e, "unread check failed");
262 ErrorData::internal_error("database error", None)
263 })?;
264 if pending > 0 && wants("message") {
265 let dms = unread_dms(&self.db, &auth).await.unwrap_or(0);
266 return Ok(Json(WaitResult {
267 woke: true,
268 timed_out: false,
269 events: vec![WaitEvent {
270 kind: "message".into(),
271 summary: format!("{pending} unread message(s) already waiting"),
272 }],
273 unread_direct_messages: dms,
274 suggestion: "Call read_messages to fetch them.".into(),
275 }));
276 }
277
278 let deadline = tokio::time::Instant::now() + Duration::from_secs(timeout as u64);
279 let mut events: Vec<WaitEvent> = Vec::new();
280
281 while events.is_empty() {
282 let event = tokio::select! {
283 _ = tokio::time::sleep_until(deadline) => break,
284 recv = rx.recv() => match recv {
285 Ok(ev) => ev,
286 Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {
289 events.push(WaitEvent {
290 kind: "unknown".into(),
291 summary: "event stream lagged; re-check the bus".into(),
292 });
293 break;
294 }
295 Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
296 },
297 };
298
299 if !event.visible_to(auth.team_id, auth.agent_id, &auth.session)
300 || !wants(event.kind())
301 || !in_focus(&event, focus)
302 {
303 continue;
304 }
305 if event.kind() == "message"
311 && event.sender_agent_id() == Some(auth.agent_id)
312 && event.sender_session().unwrap_or("") == auth.session
313 {
314 continue;
315 }
316 if let Some(described) = describe(&self.db, &event).await {
317 events.push(described);
318 let grace = tokio::time::Instant::now() + Duration::from_millis(150);
320 while let Ok(Ok(more)) = tokio::time::timeout_at(grace, rx.recv()).await {
321 if more.visible_to(auth.team_id, auth.agent_id, &auth.session)
322 && in_focus(&more, focus)
323 && wants(more.kind())
324 && !(more.kind() == "message"
325 && more.sender_agent_id() == Some(auth.agent_id))
326 && let Some(d) = describe(&self.db, &more).await
327 {
328 events.push(d);
329 }
330 }
331 }
332 }
333
334 let unread = unread_dms(&self.db, &auth).await.unwrap_or(0);
335 let woke = !events.is_empty();
336 Ok(Json(WaitResult {
337 woke,
338 timed_out: !woke,
339 suggestion: suggestion_for(&events, unread),
340 events,
341 unread_direct_messages: unread,
342 }))
343 }
344}