Skip to main content

aither_core/llm/tool/
context.rs

1//! The per-call context a [`Tool`](super::Tool) runs with.
2//!
3//! Every invocation of [`Tool::call`](super::Tool::call) receives its own
4//! [`ToolContext`]. Whoever dispatches the call decides what the context is
5//! connected to: an MCP server connects it to the request's progress token, an
6//! agent that has nowhere to show progress passes [`ToolContext::new`]. The
7//! tool reports the same way in both cases and never learns which it got.
8
9use alloc::boxed::Box;
10use alloc::string::String;
11use core::fmt::{self, Debug, Display};
12use core::future::Future;
13use core::pin::Pin;
14
15use super::CallIdentity;
16
17/// How far a running tool call has got.
18///
19/// `progress` is the amount of work done so far and `total`, when known, the
20/// amount there is; both may be fractional and neither has a unit. `message`
21/// is a human-readable line describing the current state.
22#[derive(Debug, Clone, PartialEq)]
23pub struct Progress {
24    done: f64,
25    total: Option<f64>,
26    message: Option<String>,
27}
28
29impl Progress {
30    /// Progress with the given amount of work done, no total, and no message.
31    #[must_use]
32    pub const fn new(progress: f64) -> Self {
33        Self {
34            done: progress,
35            total: None,
36            message: None,
37        }
38    }
39
40    /// Sets the total amount of work.
41    #[must_use]
42    pub const fn with_total(mut self, total: f64) -> Self {
43        self.total = Some(total);
44        self
45    }
46
47    /// Sets a human-readable description of the current state.
48    #[must_use]
49    pub fn with_message(mut self, message: impl Into<String>) -> Self {
50        self.message = Some(message.into());
51        self
52    }
53
54    /// The amount of work done so far.
55    #[must_use]
56    pub const fn progress(&self) -> f64 {
57        self.done
58    }
59
60    /// The total amount of work, if known.
61    #[must_use]
62    pub const fn total(&self) -> Option<f64> {
63        self.total
64    }
65
66    /// The human-readable description of the current state, if any.
67    #[must_use]
68    pub fn message(&self) -> Option<&str> {
69        self.message.as_deref()
70    }
71}
72
73/// Where a [`ToolContext`] delivers the progress its tool reports.
74///
75/// Implemented by whatever dispatches tool calls and has somewhere to show
76/// progress, such as an MCP server forwarding it to the client as
77/// `notifications/progress`. Every report reaching a sink has already been
78/// validated by [`ToolContext::report_progress`]: its values are finite and
79/// its `progress` is greater than that of the report before it.
80pub trait ProgressSink: Send + 'static {
81    /// Delivers one progress report.
82    ///
83    /// The returned future may wait for room to deliver (backpressure) but
84    /// must not block the thread. A sink whose destination is gone drops the
85    /// report: progress is advisory, and the call it belongs to is being torn
86    /// down anyway.
87    fn report(&mut self, progress: Progress) -> impl Future<Output = ()> + Send;
88}
89
90/// Object-safe twin of [`ProgressSink`], so [`ToolContext`] can hold any sink.
91trait ProgressSinkImpl: Send {
92    fn report(&mut self, progress: Progress) -> Pin<Box<dyn Future<Output = ()> + Send + '_>>;
93}
94
95impl<S: ProgressSink> ProgressSinkImpl for S {
96    fn report(&mut self, progress: Progress) -> Pin<Box<dyn Future<Output = ()> + Send + '_>> {
97        Box::pin(ProgressSink::report(self, progress))
98    }
99}
100
101/// A progress report that no receiver could make sense of.
102///
103/// Both variants are bugs in the reporting tool, so they are returned to it
104/// rather than papered over.
105#[derive(Debug, Clone, PartialEq)]
106pub enum ProgressError {
107    /// `progress` or `total` was NaN or infinite.
108    NotFinite(Progress),
109    /// `progress` did not exceed the previous report's. Progress must increase
110    /// with every report, even when the total is unknown.
111    NotIncreasing {
112        /// The `progress` of the previous accepted report.
113        previous: f64,
114        /// The rejected report.
115        reported: Progress,
116    },
117}
118
119impl Display for ProgressError {
120    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
121        match self {
122            Self::NotFinite(progress) => write!(
123                f,
124                "progress report is not finite: progress {}, total {:?}",
125                progress.done, progress.total
126            ),
127            Self::NotIncreasing { previous, reported } => write!(
128                f,
129                "progress must increase with every report: {} does not exceed {previous}",
130                reported.done
131            ),
132        }
133    }
134}
135
136impl core::error::Error for ProgressError {}
137
138/// The context of one tool call.
139///
140/// A tool receives it by value in [`Tool::call`](super::Tool::call) and may
141/// move it wherever the work happens. It is deliberately not `Clone`: one
142/// owner reports in sequence, which is what lets it guarantee that progress
143/// increases report by report.
144pub struct ToolContext {
145    sink: Option<Box<dyn ProgressSinkImpl>>,
146    last_progress: Option<f64>,
147    identity: Option<CallIdentity>,
148    working_dir: Option<String>,
149}
150
151impl ToolContext {
152    /// A context whose caller does not listen for progress.
153    ///
154    /// Reports are still validated, so a tool that misreports fails the same
155    /// way whether or not anyone is listening, but they are delivered nowhere.
156    #[must_use]
157    pub const fn new() -> Self {
158        Self {
159            sink: None,
160            last_progress: None,
161            identity: None,
162            working_dir: None,
163        }
164    }
165
166    /// A context that delivers its tool's progress reports to `sink`.
167    #[must_use]
168    pub fn with_progress(sink: impl ProgressSink) -> Self {
169        Self {
170            sink: Some(Box::new(sink)),
171            last_progress: None,
172            identity: None,
173            working_dir: None,
174        }
175    }
176
177    /// Attaches the working directory of the process that made the call.
178    #[must_use]
179    pub fn with_working_dir(mut self, working_dir: impl Into<String>) -> Self {
180        self.working_dir = Some(working_dir.into());
181        self
182    }
183
184    /// The working directory of the process that made the call, when the
185    /// dispatcher knows it.
186    ///
187    /// A command a shell script runs after `cd` sees the directory the script
188    /// changed to; a relative path argument resolves against it. `None` for
189    /// dispatchers that have no calling process, such as the agent loop.
190    #[must_use]
191    pub fn working_dir(&self) -> Option<&str> {
192        self.working_dir.as_deref()
193    }
194
195    /// Attaches the identity of the call this context belongs to.
196    #[must_use]
197    pub fn with_identity(mut self, identity: CallIdentity) -> Self {
198        self.identity = Some(identity);
199        self
200    }
201
202    /// The id of this call and the agent making it.
203    ///
204    /// `None` when the dispatcher does not identify its calls, such as an MCP
205    /// server or a direct call from a test; the agent loop always does.
206    #[must_use]
207    pub const fn identity(&self) -> Option<&CallIdentity> {
208        self.identity.as_ref()
209    }
210
211    /// Whether the caller listens for progress.
212    ///
213    /// A tool whose work can outlast its caller's idle limit decides from
214    /// this whether reporting progress keeps the call alive: without a
215    /// listener, reports go nowhere and the caller sees nothing until the
216    /// call returns.
217    #[must_use]
218    pub const fn is_listening(&self) -> bool {
219        self.sink.is_some()
220    }
221
222    /// Reports how far the call has got.
223    ///
224    /// Delivered to the caller when it listens for progress, and a no-op when
225    /// it does not. Awaiting the returned future may wait for the caller to
226    /// take earlier reports.
227    ///
228    /// # Errors
229    ///
230    /// Returns [`ProgressError::NotFinite`] when `progress` or `total` is NaN
231    /// or infinite, and [`ProgressError::NotIncreasing`] when `progress` does
232    /// not exceed the previous report's. A rejected report is not delivered
233    /// and does not count as the previous report.
234    pub async fn report_progress(&mut self, progress: Progress) -> Result<(), ProgressError> {
235        if !progress.done.is_finite() || progress.total.is_some_and(|total| !total.is_finite()) {
236            return Err(ProgressError::NotFinite(progress));
237        }
238        if let Some(previous) = self.last_progress
239            && progress.done <= previous
240        {
241            return Err(ProgressError::NotIncreasing {
242                previous,
243                reported: progress,
244            });
245        }
246        self.last_progress = Some(progress.done);
247        if let Some(sink) = &mut self.sink {
248            sink.report(progress).await;
249        }
250        Ok(())
251    }
252}
253
254impl Default for ToolContext {
255    fn default() -> Self {
256        Self::new()
257    }
258}
259
260impl Debug for ToolContext {
261    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
262        f.debug_struct("ToolContext")
263            .field("listening", &self.sink.is_some())
264            .field("last_progress", &self.last_progress)
265            .field("identity", &self.identity)
266            .field("working_dir", &self.working_dir)
267            .finish()
268    }
269}
270
271#[cfg(test)]
272mod tests {
273    extern crate std;
274
275    use super::*;
276    use alloc::vec::Vec;
277    use std::sync::mpsc;
278
279    /// Forwards every report it is handed to a channel the test drains.
280    struct Collect(mpsc::Sender<Progress>);
281
282    impl ProgressSink for Collect {
283        async fn report(&mut self, progress: Progress) {
284            self.0.send(progress).expect("test receiver alive");
285        }
286    }
287
288    fn listening() -> (ToolContext, mpsc::Receiver<Progress>) {
289        let (tx, rx) = mpsc::channel();
290        (ToolContext::with_progress(Collect(tx)), rx)
291    }
292
293    #[test]
294    fn reports_whether_the_caller_listens() {
295        assert!(!ToolContext::new().is_listening());
296        assert!(listening().0.is_listening());
297    }
298
299    #[test]
300    fn carries_the_identity_it_was_given() {
301        use super::super::{AgentIdentity, ToolCallId};
302
303        assert_eq!(ToolContext::new().identity(), None);
304        let identity = CallIdentity::new(ToolCallId::new("call_1"), AgentIdentity::Main);
305        let cx = ToolContext::new().with_identity(identity.clone());
306        assert_eq!(cx.identity(), Some(&identity));
307        assert_eq!(identity.call().as_str(), "call_1");
308    }
309
310    #[tokio::test]
311    async fn delivers_increasing_reports_in_order() {
312        let (mut cx, seen) = listening();
313
314        cx.report_progress(Progress::new(1.0)).await.unwrap();
315        cx.report_progress(Progress::new(2.5).with_total(10.0).with_message("half"))
316            .await
317            .unwrap();
318
319        assert_eq!(
320            seen.try_iter().collect::<Vec<_>>(),
321            [
322                Progress::new(1.0),
323                Progress::new(2.5).with_total(10.0).with_message("half"),
324            ]
325        );
326    }
327
328    #[tokio::test]
329    async fn rejects_non_increasing_and_non_finite_reports() {
330        let (mut cx, seen) = listening();
331
332        cx.report_progress(Progress::new(3.0)).await.unwrap();
333        assert_eq!(
334            cx.report_progress(Progress::new(3.0)).await,
335            Err(ProgressError::NotIncreasing {
336                previous: 3.0,
337                reported: Progress::new(3.0),
338            })
339        );
340        assert!(matches!(
341            cx.report_progress(Progress::new(f64::NAN)).await,
342            Err(ProgressError::NotFinite(_))
343        ));
344        assert!(matches!(
345            cx.report_progress(Progress::new(4.0).with_total(f64::INFINITY))
346                .await,
347            Err(ProgressError::NotFinite(_))
348        ));
349        // A rejected report is not the new baseline.
350        cx.report_progress(Progress::new(3.5)).await.unwrap();
351
352        assert_eq!(
353            seen.try_iter().collect::<Vec<_>>(),
354            [Progress::new(3.0), Progress::new(3.5)]
355        );
356    }
357
358    #[tokio::test]
359    async fn context_without_a_listener_still_validates() {
360        let mut cx = ToolContext::new();
361        cx.report_progress(Progress::new(1.0)).await.unwrap();
362        assert!(cx.report_progress(Progress::new(0.5)).await.is_err());
363    }
364}