contextvm_sdk/rmcp_transport/progress.rs
1//! CEP-22: progress-aware request options for rmcp consumers.
2//!
3//! Under the rmcp fork, plain [`Peer`] calls such as `call_tool` use
4//! `PeerRequestOptions::no_options()` — **no timeout at all**: a stalled
5//! oversized response hangs the caller forever. This module closes that gap
6//! the way the TS SDK's docs do — by passing request options on the existing
7//! call — via an extension trait carrying an options-taking `call_tool`
8//! variant, plus a constructor for the recommended progress-aware settings.
9//!
10//! With CEP-22 enabled, the Nostr transports forward each inbound transfer
11//! frame to the requester as a plain progress notification, so an idle
12//! timeout built by [`progress_aware_options`] resets on every chunk: a live
13//! transfer can run long, a stalled one fails after `idle`, and
14//! `max_total` caps the call regardless of progress.
15//!
16//! ```ignore
17//! use contextvm_sdk::{progress_aware_options, PeerRequestOptionsExt};
18//!
19//! let result = running_service
20//! .peer()
21//! .call_tool_with_options(
22//! params,
23//! progress_aware_options(
24//! contextvm_sdk::DEFAULT_OVERSIZED_IDLE_TIMEOUT,
25//! contextvm_sdk::DEFAULT_OVERSIZED_MAX_TOTAL_TIMEOUT,
26//! ),
27//! )
28//! .await?;
29//! ```
30
31use std::time::Duration;
32
33use rmcp::model::{
34 CallToolRequest, CallToolRequestParams, CallToolResult, ClientRequest, ServerResult,
35};
36use rmcp::service::{Peer, PeerRequestOptions, ServiceError};
37use rmcp::RoleClient;
38
39/// Default requester-side idle timeout for requests that may trigger a CEP-22
40/// oversized transfer: 60 s.
41///
42/// TS parity twice over: equals the upstream TS SDK's blanket per-request
43/// timeout (`DEFAULT_REQUEST_TIMEOUT_MSEC`), and exceeds the worst-case
44/// inter-chunk gap including the 30 s accept wait
45/// ([`DEFAULT_ACCEPT_TIMEOUT_MS`](crate::transport::oversized_transfer::DEFAULT_ACCEPT_TIMEOUT_MS)).
46pub const DEFAULT_OVERSIZED_IDLE_TIMEOUT: Duration = Duration::from_secs(60);
47
48/// Default requester-side max-total timeout: 300 s.
49///
50/// Aligned with the receiver-side watchdog default
51/// ([`DEFAULT_TRANSFER_TIMEOUT_MS`](crate::transport::oversized_transfer::DEFAULT_TRANSFER_TIMEOUT_MS))
52/// so the requester gives up in the same window the receiver reaps state —
53/// symmetric failure. (Upstream TS sets no max-total default; here it stays
54/// opt-in via [`progress_aware_options`], never baked into plain calls.)
55pub const DEFAULT_OVERSIZED_MAX_TOTAL_TIMEOUT: Duration = Duration::from_secs(300);
56
57/// Build the recommended [`PeerRequestOptions`] for requests whose responses
58/// may arrive as CEP-22 oversized transfers: an `idle` timeout that resets on
59/// every progress notification, capped by `max_total`.
60///
61/// Equivalent to
62/// `PeerRequestOptions::with_timeout(idle).reset_timeout_on_progress().with_max_total_timeout(max_total)`.
63/// Named after the mechanism (progress-aware timeouts), not the oversized use
64/// case — mirroring the TS SDK, where "oversized" appears in docs but never in
65/// the API surface.
66///
67/// Sizing notes:
68/// - `reset_timeout_on_progress` without an idle timeout is a **no-op** — the
69/// fork registers a progress watcher only when *both* are set, which is why
70/// this constructor takes `idle` rather than making it optional.
71/// - The client→server upload direction receives at most **one** inbound
72/// reset (the server's `accept` handshake frame) — and it reaches the rmcp
73/// service loop only after the whole upload send returns. Size `idle` and
74/// `max_total` to cover the full upload duration, not just the gaps.
75/// - Sensible defaults: [`DEFAULT_OVERSIZED_IDLE_TIMEOUT`] /
76/// [`DEFAULT_OVERSIZED_MAX_TOTAL_TIMEOUT`]. They intentionally mirror the
77/// transport's `OversizedTransferConfig` numbers but are not read from it —
78/// the peer layer has no access to transport config by design; align them
79/// manually if you tune the transport.
80pub fn progress_aware_options(idle: Duration, max_total: Duration) -> PeerRequestOptions {
81 PeerRequestOptions::with_timeout(idle)
82 .reset_timeout_on_progress()
83 .with_max_total_timeout(max_total)
84}
85
86/// Options-taking call variants for [`Peer<RoleClient>`] — the rs-side analog
87/// of passing `RequestOptions` inline to the TS SDK's `client.callTool`.
88///
89/// Without these, high-level fork calls (`peer.call_tool(..)` etc.) run with
90/// `PeerRequestOptions::no_options()`: **no timeout, infinite await**. Pair
91/// with [`progress_aware_options`] for any call whose response may be large
92/// (CEP-22 fragments every rmcp request's response once the peer advertises
93/// support — rmcp stamps a progress token into every outgoing request).
94///
95/// For request types without a dedicated variant here, the generic path is
96/// two lines on public fork API — no wrapper needed:
97///
98/// ```ignore
99/// let handle = peer.send_cancellable_request(request, options).await?;
100/// let response = handle.await_response().await?;
101/// ```
102///
103/// On timeout the call fails with `ServiceError::Timeout { timeout }` (the
104/// value identifies which timer fired: `idle` vs `max_total`) and rmcp
105/// publishes a `notifications/cancelled` for the request.
106pub trait PeerRequestOptionsExt {
107 /// `call_tool` with explicit [`PeerRequestOptions`] — the direct analog of
108 /// TS `client.callTool(params, schema, options)`.
109 fn call_tool_with_options(
110 &self,
111 params: CallToolRequestParams,
112 options: PeerRequestOptions,
113 ) -> impl std::future::Future<Output = Result<CallToolResult, ServiceError>> + Send;
114}
115
116impl PeerRequestOptionsExt for Peer<RoleClient> {
117 async fn call_tool_with_options(
118 &self,
119 params: CallToolRequestParams,
120 options: PeerRequestOptions,
121 ) -> Result<CallToolResult, ServiceError> {
122 // Mirrors the fork's `method!` expansion for `call_tool`
123 // (service/client.rs), with options threaded through
124 // `send_cancellable_request` instead of the option-less default.
125 let result = self
126 .send_cancellable_request(
127 ClientRequest::CallToolRequest(CallToolRequest::new(params)),
128 options,
129 )
130 .await?
131 .await_response()
132 .await?;
133 match result {
134 ServerResult::CallToolResult(result) => Ok(result),
135 _ => Err(ServiceError::UnexpectedResponse),
136 }
137 }
138}
139
140#[cfg(test)]
141mod tests {
142 use super::*;
143
144 /// Pins the `progress_aware_options` wiring: the constructor must (a) set the
145 /// idle `timeout`, (b) enable `reset_timeout_on_progress` — without which the
146 /// fork registers no progress watcher (`send_request_with_option` only arms
147 /// one when *both* the flag and a timeout are set), so inbound chunks would
148 /// never reset the timer — and (c) populate `max_total_timeout`. Distinct
149 /// idle/max-total values also catch an accidental argument swap in the
150 /// builder chain. End-to-end behavior is covered by the timeout tests in
151 /// `tests/oversized_timeout_e2e.rs`; this is the fast unit guard on the flags.
152 #[test]
153 fn progress_aware_options_sets_reset_and_both_timeouts() {
154 let idle = Duration::from_millis(250);
155 let max_total = Duration::from_secs(42);
156
157 let options = progress_aware_options(idle, max_total);
158
159 assert_eq!(options.timeout, Some(idle), "idle timeout must be set");
160 assert!(
161 options.reset_timeout_on_progress,
162 "reset_timeout_on_progress must be enabled or the fork arms no progress watcher"
163 );
164 assert_eq!(
165 options.max_total_timeout,
166 Some(max_total),
167 "max_total_timeout must be set"
168 );
169 }
170}