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}