Skip to main content

atman_runtime/
rendezvous.rs

1use std::sync::Arc;
2
3use tokio::sync::oneshot;
4use uuid::Uuid;
5
6use crate::error::RuntimeError;
7
8// PromptId lives in atman-proto but runtime can't depend on proto (proto has no runtime deps).
9// Represent as Uuid at this trait boundary; adapter in daemon maps to proto::PromptId.
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
11pub struct PromptId(pub Uuid);
12
13impl PromptId {
14    pub fn now() -> Self {
15        Self(Uuid::now_v7())
16    }
17}
18
19impl std::fmt::Display for PromptId {
20    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
21        self.0.fmt(f)
22    }
23}
24
25pub trait PromptResolver: Send + Sync {
26    fn register(&self, id: PromptId) -> oneshot::Receiver<serde_json::Value>;
27    fn drop_pending(&self, id: &PromptId);
28
29    fn expire_pending(&self, id: &PromptId) -> bool {
30        self.drop_pending(id);
31        true
32    }
33
34    fn register_with_payload(
35        &self,
36        id: PromptId,
37        _kind: &str,
38        _payload: serde_json::Value,
39    ) -> oneshot::Receiver<serde_json::Value> {
40        self.register(id)
41    }
42}
43
44// In-proc fallback: prompts are auto-answered by a caller-provided default. Used when
45// no daemon is present; the flow author owns the auto-answer contract via tool args.
46pub struct AutoResolveResolver {
47    pub default: serde_json::Value,
48}
49
50impl PromptResolver for AutoResolveResolver {
51    fn register(&self, _id: PromptId) -> oneshot::Receiver<serde_json::Value> {
52        let (tx, rx) = oneshot::channel();
53        let _ = tx.send(self.default.clone());
54        rx
55    }
56    fn drop_pending(&self, _id: &PromptId) {}
57}
58
59pub async fn await_prompt(
60    resolver: &Arc<dyn PromptResolver>,
61    id: PromptId,
62    timeout: std::time::Duration,
63) -> Result<serde_json::Value, RuntimeError> {
64    await_prompt_inner(resolver.register(id), resolver, id, timeout).await
65}
66
67pub async fn await_prompt_with_payload(
68    resolver: &Arc<dyn PromptResolver>,
69    id: PromptId,
70    kind: &str,
71    payload: serde_json::Value,
72    timeout: std::time::Duration,
73) -> Result<serde_json::Value, RuntimeError> {
74    await_prompt_inner(
75        resolver.register_with_payload(id, kind, payload),
76        resolver,
77        id,
78        timeout,
79    )
80    .await
81}
82
83pub async fn await_prompt_with_payload_cancel(
84    resolver: &Arc<dyn PromptResolver>,
85    id: PromptId,
86    kind: &str,
87    payload: serde_json::Value,
88    timeout: std::time::Duration,
89    cancel: &tokio_util::sync::CancellationToken,
90) -> Result<serde_json::Value, RuntimeError> {
91    await_prompt_inner_cancel(
92        resolver.register_with_payload(id, kind, payload),
93        resolver,
94        id,
95        timeout,
96        cancel,
97    )
98    .await
99}
100
101pub async fn await_expirable_prompt_with_payload(
102    resolver: &Arc<dyn PromptResolver>,
103    id: PromptId,
104    kind: &str,
105    payload: serde_json::Value,
106    timeout: std::time::Duration,
107) -> Result<serde_json::Value, RuntimeError> {
108    let mut rx = resolver.register_with_payload(id, kind, payload);
109    match tokio::time::timeout(timeout, &mut rx).await {
110        Ok(Ok(value)) => Ok(value),
111        Ok(Err(_)) => {
112            resolver.drop_pending(&id);
113            Err(RuntimeError::ToolFailed(format!(
114                "prompt {id} channel closed before answer"
115            )))
116        }
117        Err(_) => {
118            if resolver.expire_pending(&id) {
119                Err(RuntimeError::ToolFailed(format!(
120                    "prompt {id} timed out after {}s",
121                    timeout.as_secs()
122                )))
123            } else {
124                rx.await.map_err(|_| {
125                    RuntimeError::ToolFailed(format!("prompt {id} channel closed before answer"))
126                })
127            }
128        }
129    }
130}
131
132pub async fn await_expirable_prompt_with_payload_cancel(
133    resolver: &Arc<dyn PromptResolver>,
134    id: PromptId,
135    kind: &str,
136    payload: serde_json::Value,
137    timeout: std::time::Duration,
138    cancel: &tokio_util::sync::CancellationToken,
139) -> Result<Option<serde_json::Value>, RuntimeError> {
140    let mut rx = resolver.register_with_payload(id, kind, payload);
141    tokio::select! {
142        result = tokio::time::timeout(timeout, &mut rx) => match result {
143            Ok(Ok(value)) => Ok(Some(value)),
144            Ok(Err(_)) => {
145                resolver.drop_pending(&id);
146                Err(RuntimeError::ToolFailed(format!(
147                    "prompt {id} channel closed before answer"
148                )))
149            }
150            Err(_) => {
151                if resolver.expire_pending(&id) {
152                    Ok(None)
153                } else {
154                    tokio::select! {
155                        result = &mut rx => result.map(Some).map_err(|_| {
156                            RuntimeError::ToolFailed(format!(
157                                "prompt {id} channel closed before answer"
158                            ))
159                        }),
160                        _ = cancel.cancelled() => {
161                            resolver.drop_pending(&id);
162                            Err(RuntimeError::Cancelled(format!("prompt {id} cancelled")))
163                        }
164                    }
165                }
166            }
167        },
168        _ = cancel.cancelled() => {
169            resolver.drop_pending(&id);
170            Err(RuntimeError::Cancelled(format!("prompt {id} cancelled")))
171        }
172    }
173}
174
175async fn await_prompt_inner(
176    rx: oneshot::Receiver<serde_json::Value>,
177    resolver: &Arc<dyn PromptResolver>,
178    id: PromptId,
179    timeout: std::time::Duration,
180) -> Result<serde_json::Value, RuntimeError> {
181    match tokio::time::timeout(timeout, rx).await {
182        Ok(Ok(v)) => Ok(v),
183        Ok(Err(_)) => {
184            resolver.drop_pending(&id);
185            Err(RuntimeError::ToolFailed(format!(
186                "prompt {id} channel closed before answer"
187            )))
188        }
189        Err(_) => {
190            resolver.drop_pending(&id);
191            Err(RuntimeError::ToolFailed(format!(
192                "prompt {id} timed out after {}s",
193                timeout.as_secs()
194            )))
195        }
196    }
197}
198
199async fn await_prompt_inner_cancel(
200    mut rx: oneshot::Receiver<serde_json::Value>,
201    resolver: &Arc<dyn PromptResolver>,
202    id: PromptId,
203    timeout: std::time::Duration,
204    cancel: &tokio_util::sync::CancellationToken,
205) -> Result<serde_json::Value, RuntimeError> {
206    tokio::select! {
207        result = tokio::time::timeout(timeout, &mut rx) => match result {
208            Ok(Ok(value)) => Ok(value),
209            Ok(Err(_)) => {
210                resolver.drop_pending(&id);
211                Err(RuntimeError::ToolFailed(format!(
212                    "prompt {id} channel closed before answer"
213                )))
214            }
215            Err(_) => {
216                resolver.drop_pending(&id);
217                Err(RuntimeError::ToolFailed(format!(
218                    "prompt {id} timed out after {}s",
219                    timeout.as_secs()
220                )))
221            }
222        },
223        _ = cancel.cancelled() => {
224            resolver.drop_pending(&id);
225            Err(RuntimeError::Cancelled(format!("prompt {id} cancelled")))
226        }
227    }
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233    use std::sync::Mutex;
234    use std::sync::atomic::{AtomicBool, Ordering};
235
236    struct PendingResolver {
237        sender: Mutex<Option<oneshot::Sender<serde_json::Value>>>,
238        dropped: AtomicBool,
239        expired: AtomicBool,
240    }
241
242    impl PendingResolver {
243        fn new() -> Self {
244            Self {
245                sender: Mutex::new(None),
246                dropped: AtomicBool::new(false),
247                expired: AtomicBool::new(false),
248            }
249        }
250    }
251
252    impl PromptResolver for PendingResolver {
253        fn register(&self, _id: PromptId) -> oneshot::Receiver<serde_json::Value> {
254            let (tx, rx) = oneshot::channel();
255            *self.sender.lock().unwrap() = Some(tx);
256            rx
257        }
258
259        fn drop_pending(&self, _id: &PromptId) {
260            self.dropped.store(true, Ordering::SeqCst);
261            self.sender.lock().unwrap().take();
262        }
263
264        fn expire_pending(&self, _id: &PromptId) -> bool {
265            self.expired.store(true, Ordering::SeqCst);
266            self.sender.lock().unwrap().take();
267            true
268        }
269    }
270
271    #[tokio::test]
272    async fn expirable_prompt_timeout_is_not_a_tool_error() {
273        let concrete = Arc::new(PendingResolver::new());
274        let resolver: Arc<dyn PromptResolver> = concrete.clone();
275        let result = await_expirable_prompt_with_payload_cancel(
276            &resolver,
277            PromptId::now(),
278            "form_ask",
279            serde_json::Value::Null,
280            std::time::Duration::from_millis(1),
281            &tokio_util::sync::CancellationToken::new(),
282        )
283        .await
284        .unwrap();
285
286        assert!(result.is_none());
287        assert!(concrete.expired.load(Ordering::SeqCst));
288        assert!(!concrete.dropped.load(Ordering::SeqCst));
289    }
290
291    #[tokio::test]
292    async fn cancelling_prompt_drops_it_immediately() {
293        let concrete = Arc::new(PendingResolver::new());
294        let resolver: Arc<dyn PromptResolver> = concrete.clone();
295        let cancel = tokio_util::sync::CancellationToken::new();
296        cancel.cancel();
297        let result = await_prompt_with_payload_cancel(
298            &resolver,
299            PromptId::now(),
300            "form_ask",
301            serde_json::Value::Null,
302            std::time::Duration::from_secs(300),
303            &cancel,
304        )
305        .await;
306
307        assert!(matches!(result, Err(RuntimeError::Cancelled(_))));
308        assert!(concrete.dropped.load(Ordering::SeqCst));
309    }
310}