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}
33
34async fn unread_dms(pool: &PgPool, agent_id: Uuid) -> Result<i64, sqlx::Error> {
35 sqlx::query_scalar(
36 r#"
37 SELECT count(*)
38 FROM messages m
39 LEFT JOIN read_cursors c ON c.agent_id = $1 AND c.scope = 'inbox'
40 WHERE m.recipient_agent_id = $1
41 AND m.id > COALESCE(c.last_message_id, 0)
42 "#,
43 )
44 .bind(agent_id)
45 .fetch_one(pool)
46 .await
47}
48
49async fn unread_anything(pool: &PgPool, auth: &AuthCtx) -> Result<i64, sqlx::Error> {
50 sqlx::query_scalar(
51 r#"
52 SELECT count(*)
53 FROM messages m
54 LEFT JOIN read_cursors c ON c.agent_id = $1 AND c.scope = 'all'
55 WHERE m.team_id = $2
56 AND m.id > COALESCE(c.last_message_id, 0)
57 AND m.sender_agent_id <> $1
58 AND (m.channel_id IS NOT NULL OR m.recipient_agent_id = $1)
59 "#,
60 )
61 .bind(auth.agent_id)
62 .bind(auth.team_id)
63 .fetch_one(pool)
64 .await
65}
66
67async fn describe(pool: &PgPool, event: &BusEvent) -> Option<WaitEvent> {
69 match event.kind() {
70 "message" => {
71 let id = event.message_id()?;
72 let row: (String, Option<String>, Option<String>, String) =
73 sqlx::query_as::<_, (String, Option<String>, Option<String>, String)>(
74 r#"
75 SELECT s.name, ch.name, r.name, left(m.body, 200)
76 FROM messages m
77 JOIN agents s ON s.id = m.sender_agent_id
78 LEFT JOIN channels ch ON ch.id = m.channel_id
79 LEFT JOIN agents r ON r.id = m.recipient_agent_id
80 WHERE m.id = $1
81 "#,
82 )
83 .bind(id)
84 .fetch_optional(pool)
85 .await
86 .ok()
87 .flatten()?;
88 let (sender, channel, recipient, body) = row;
89 let target = channel
90 .map(|c| format!("#{c}"))
91 .or(recipient.map(|r| format!("@{r}")))
92 .unwrap_or_default();
93 Some(WaitEvent {
94 kind: "message".into(),
95 summary: format!("{sender} → {target}: {body}"),
96 })
97 }
98 "task" => {
99 let key = event.0.get("key").and_then(|v| v.as_str())?;
100 let status = event
101 .0
102 .get("status")
103 .and_then(|v| v.as_str())
104 .unwrap_or("?");
105 let holder = match event.0.get("claimed_by").and_then(|v| v.as_str()) {
106 Some(uuid) => {
107 sqlx::query_scalar::<_, String>("SELECT name FROM agents WHERE id = $1::uuid")
108 .bind(uuid)
109 .fetch_optional(pool)
110 .await
111 .ok()
112 .flatten()
113 .map(|n| format!(" by {n}"))
114 .unwrap_or_default()
115 }
116 None => String::new(),
117 };
118 Some(WaitEvent {
119 kind: "task".into(),
120 summary: format!("task '{key}' is now {status}{holder}"),
121 })
122 }
123 "lock" => {
124 let name = event.0.get("name").and_then(|v| v.as_str())?;
125 let what = event
126 .0
127 .get("event")
128 .and_then(|v| v.as_str())
129 .unwrap_or("changed");
130 Some(WaitEvent {
131 kind: "lock".into(),
132 summary: format!("lock '{name}' {what}"),
133 })
134 }
135 "note" => {
136 let scope = event
137 .0
138 .get("scope")
139 .and_then(|v| v.as_str())
140 .unwrap_or("global");
141 let key = event.0.get("key").and_then(|v| v.as_str())?;
142 Some(WaitEvent {
143 kind: "note".into(),
144 summary: format!("note {scope}/{key} was updated"),
145 })
146 }
147 _ => None,
148 }
149}
150
151fn suggestion_for(events: &[WaitEvent], unread: i64) -> String {
152 if events.iter().any(|e| e.kind == "message") || unread > 0 {
153 "Call read_messages to fetch the new messages.".into()
154 } else if events.iter().any(|e| e.kind == "task") {
155 "Call list_tasks (or get_task) to see what changed.".into()
156 } else if events.iter().any(|e| e.kind == "lock") {
157 "Call list_locks (or retry acquire_lock) now.".into()
158 } else if events.iter().any(|e| e.kind == "note") {
159 "Call get_note to read the updated note.".into()
160 } else {
161 "Nothing happened; do other work or wait again.".into()
162 }
163}
164
165#[tool_router(router = events_router, vis = "pub")]
166impl Bus {
167 #[tool(
168 description = "Block until something happens on the bus that concerns you (a message \
169 arrives, a task changes state, a lock is released, a note is updated) or \
170 the timeout elapses. Use this instead of polling read_messages in a loop: \
171 call it when you are waiting on teammates and idle. Returns immediately \
172 if you already have unread messages."
173 )]
174 async fn wait_for_updates(
175 &self,
176 ctx: RequestContext<rmcp::RoleServer>,
177 Parameters(args): Parameters<WaitArgs>,
178 ) -> Result<Json<WaitResult>, ErrorData> {
179 let auth = auth_of(&ctx)?;
180 let timeout = args
181 .timeout_seconds
182 .unwrap_or(DEFAULT_TIMEOUT_SECS)
183 .clamp(5, MAX_TIMEOUT_SECS);
184 let kind_filter: Option<Vec<String>> = args
185 .kinds
186 .map(|ks| ks.into_iter().map(|k| k.trim().to_lowercase()).collect());
187 let wants = |kind: &str| {
188 kind_filter
189 .as_ref()
190 .map(|ks| ks.iter().any(|k| k == kind))
191 .unwrap_or(true)
192 };
193
194 let mut rx = self.hub.subscribe();
197
198 let pending = unread_anything(&self.db, &auth).await.map_err(|e| {
199 tracing::error!(error = %e, "unread check failed");
200 ErrorData::internal_error("database error", None)
201 })?;
202 if pending > 0 && wants("message") {
203 let dms = unread_dms(&self.db, auth.agent_id).await.unwrap_or(0);
204 return Ok(Json(WaitResult {
205 woke: true,
206 timed_out: false,
207 events: vec![WaitEvent {
208 kind: "message".into(),
209 summary: format!("{pending} unread message(s) already waiting"),
210 }],
211 unread_direct_messages: dms,
212 suggestion: "Call read_messages to fetch them.".into(),
213 }));
214 }
215
216 let deadline = tokio::time::Instant::now() + Duration::from_secs(timeout as u64);
217 let mut events: Vec<WaitEvent> = Vec::new();
218
219 while events.is_empty() {
220 let event = tokio::select! {
221 _ = tokio::time::sleep_until(deadline) => break,
222 recv = rx.recv() => match recv {
223 Ok(ev) => ev,
224 Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {
227 events.push(WaitEvent {
228 kind: "unknown".into(),
229 summary: "event stream lagged; re-check the bus".into(),
230 });
231 break;
232 }
233 Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
234 },
235 };
236
237 if !event.visible_to(auth.team_id, auth.agent_id) || !wants(event.kind()) {
238 continue;
239 }
240 if event.kind() == "message" && event.sender_agent_id() == Some(auth.agent_id) {
242 continue;
243 }
244 if let Some(described) = describe(&self.db, &event).await {
245 events.push(described);
246 let grace = tokio::time::Instant::now() + Duration::from_millis(150);
248 while let Ok(Ok(more)) = tokio::time::timeout_at(grace, rx.recv()).await {
249 if more.visible_to(auth.team_id, auth.agent_id)
250 && wants(more.kind())
251 && !(more.kind() == "message"
252 && more.sender_agent_id() == Some(auth.agent_id))
253 && let Some(d) = describe(&self.db, &more).await
254 {
255 events.push(d);
256 }
257 }
258 }
259 }
260
261 let unread = unread_dms(&self.db, auth.agent_id).await.unwrap_or(0);
262 let woke = !events.is_empty();
263 Ok(Json(WaitResult {
264 woke,
265 timed_out: !woke,
266 suggestion: suggestion_for(&events, unread),
267 events,
268 unread_direct_messages: unread,
269 }))
270 }
271}