Skip to main content

agentcat_sdk/
capture.rs

1//! The errors of the current tool call that the result cannot show: a panic of the tool, and an
2//! error the tool records with [`capture_error`]. The call path runs the inner handler inside
3//! [`scope`] with a fresh [`CaptureSlot`], so `capture_error` reaches the record of that call only.
4//! The `error` they give: a panic gives [`panic_error`]; otherwise a captured error replaces the
5//! error that the result or the JSON-RPC error would give.
6use std::any::{Any, type_name};
7use std::backtrace::{Backtrace, BacktraceStatus};
8use std::error::Error;
9use std::sync::{Arc, Mutex, PoisonError};
10
11use serde_json::{Map, Value};
12
13use crate::call::PLATFORM;
14
15#[cfg(test)]
16mod tests;
17
18tokio::task_local! {
19    /// The slot of the tool call that the current task runs, while [`scope`] polls it.
20    static SLOT: CaptureSlot;
21}
22
23/// The `type` of the `error` of a panic.
24const PANIC_TYPE: &str = "panic";
25
26/// The message of a panic whose payload is not text: the name Rust's panic hook gives it.
27const OPAQUE_PAYLOAD: &str = "Box<dyn Any>";
28
29/// The most `chained_errors` entries an `error` holds.
30const MAX_CHAINED_ERRORS: usize = 10;
31
32/// Records `error` as the error of the tool call that is running, for `AgentCat` to report with
33/// the call: its `Display` text, its type path (`std::any::type_name`), the text of each error
34/// of its `source()` chain (at most 10), and a backtrace of this call site when the environment
35/// turns backtraces on (`RUST_BACKTRACE` or `RUST_LIB_BACKTRACE`, see
36/// [`Backtrace::capture`]). The backtrace is turned into text later, off the tool's task.
37///
38/// rmcp turns the error a tool returns into the text of an `is_error` result before `AgentCat`
39/// sees it, so the type and the chain are lost. Call this before the tool returns the error:
40/// when the call then fails (an `is_error` result or an `Err(ErrorData)`), the recorded error
41/// takes the place of the one `AgentCat` reads from the result. When the call succeeds, nothing
42/// is reported. When the tool panics, the panic is reported instead. When a tool calls this
43/// function more than once, the last error counts. The tool's result and error reach the client
44/// unchanged.
45///
46/// `error` may be a `dyn Error`, for example `&*boxed`. Its recorded type is then the name of the
47/// `dyn` type (`dyn core::error::Error`), not that of the value behind it.
48///
49/// The call is found through a tokio task-local value that `AgentCat` sets while it runs the
50/// tool's future. Outside a tool call that `AgentCat` traces, this function does nothing. A task
51/// the tool spawns (`tokio::spawn`) or a thread it starts does not belong to the call, so
52/// `capture_error` does nothing there either: call it from the tool's own future.
53///
54/// ```
55/// use agentcat_sdk::capture_error;
56///
57/// fn read_config(path: &str) -> Result<String, String> {
58///     std::fs::read_to_string(path).map_err(|error| {
59///         capture_error(&error);
60///         error.to_string()
61///     })
62/// }
63/// # let _ = read_config;
64/// ```
65pub fn capture_error<E: Error + ?Sized + 'static>(error: &E) {
66    // Outside a call the task-local value is not set: nothing to record.
67    let _outside = SLOT.try_with(|slot| {
68        slot.put((error_shape(error), captured_backtrace(Backtrace::capture())));
69    });
70}
71
72/// An error that [`capture_error`] recorded: its [`error_shape`], and the backtrace of the call
73/// site when one was captured, which the delivery pipeline renders as the error's `stack`
74/// ([`render_backtrace`](crate::event::assemble::render_backtrace)).
75pub(crate) type Captured = (Map<String, Value>, Option<Backtrace>);
76
77/// Where [`capture_error`] leaves the error of one tool call. Clones share it: the call path
78/// keeps one and hands another to [`scope`].
79#[derive(Debug, Clone, Default)]
80pub(crate) struct CaptureSlot(Arc<Mutex<Option<Captured>>>);
81
82impl CaptureSlot {
83    /// The captured error, which leaves the slot empty.
84    pub(crate) fn take(&self) -> Option<Captured> {
85        self.0.lock().unwrap_or_else(PoisonError::into_inner).take()
86    }
87
88    /// Puts `captured` in the slot, in place of any earlier one.
89    fn put(&self, captured: Captured) {
90        *self.0.lock().unwrap_or_else(PoisonError::into_inner) = Some(captured);
91    }
92}
93
94/// `backtrace` when it was captured; `None` when the environment turns backtraces off. Nothing
95/// here turns it into text: that resolves symbols, which the tool's task must not wait for.
96pub(crate) fn captured_backtrace(backtrace: Backtrace) -> Option<Backtrace> {
97    (backtrace.status() == BacktraceStatus::Captured).then_some(backtrace)
98}
99
100/// Runs `future` with `slot` as the slot of the current tool call, so that [`capture_error`]
101/// called while `future` is polled records into `slot`. The value is task-local: a task that
102/// `future` spawns does not see it.
103pub(crate) async fn scope<F: Future>(slot: CaptureSlot, future: F) -> F::Output {
104    SLOT.scope(slot, future).await
105}
106
107/// The `error` of a panic with `payload`: `{message, type: "panic", platform: "rust"}`.
108/// Pass the payload itself (`&*payload`), not the `Box` that holds it.
109pub(crate) fn panic_error(payload: &(dyn Any + Send)) -> Map<String, Value> {
110    let mut error = Map::new();
111    error.insert("message".to_owned(), Value::String(panic_message(payload)));
112    error.insert("type".to_owned(), Value::String(PANIC_TYPE.to_owned()));
113    error.insert("platform".to_owned(), Value::String(PLATFORM.to_owned()));
114    error
115}
116
117/// The message of a panic with `payload`: the text of a `&str` or `String` payload (what
118/// `panic!` gives), else `Box<dyn Any>`.
119pub(crate) fn panic_message(payload: &(dyn Any + Send)) -> String {
120    if let Some(text) = payload.downcast_ref::<&str>() {
121        (*text).to_owned()
122    } else if let Some(text) = payload.downcast_ref::<String>() {
123        text.clone()
124    } else {
125        OPAQUE_PAYLOAD.to_owned()
126    }
127}
128
129/// The `error` that [`capture_error`] records for `error`: `{message, type,
130/// platform: "rust", chained_errors?}`. `chained_errors` has one `{message}` entry per error of
131/// the `source()` chain, at most [`MAX_CHAINED_ERRORS`], and is absent for an error without a
132/// source. The `stack` of a captured backtrace joins it in the delivery pipeline, after
133/// `platform`.
134pub(crate) fn error_shape<E: Error + ?Sized + 'static>(error: &E) -> Map<String, Value> {
135    let mut shape = Map::new();
136    shape.insert("message".to_owned(), Value::String(error.to_string()));
137    shape.insert(
138        "type".to_owned(),
139        Value::String(type_name::<E>().to_owned()),
140    );
141    shape.insert("platform".to_owned(), Value::String(PLATFORM.to_owned()));
142    let chain: Vec<Value> = std::iter::successors(error.source(), |&cause| cause.source())
143        .take(MAX_CHAINED_ERRORS)
144        .map(|cause| {
145            let mut entry = Map::new();
146            entry.insert("message".to_owned(), Value::String(cause.to_string()));
147            Value::Object(entry)
148        })
149        .collect();
150    if !chain.is_empty() {
151        shape.insert("chained_errors".to_owned(), Value::Array(chain));
152    }
153    shape
154}