Skip to main content

rig_core/tool/
context.rs

1//! Typed inbound context and explicitly published host-only tool-result metadata.
2//!
3//! ```
4//! use rig_core::tool::{ContextValue, ToolContext};
5//! #[derive(serde::Serialize, serde::Deserialize)]
6//! struct Session(String);
7//! impl ContextValue for Session { const KEY: &'static str = "session"; }
8//! let mut context = ToolContext::new();
9//! context.insert(Session("example".into()))?;
10//! assert_eq!(context.require::<Session>()?.0, "example");
11//! # Ok::<(), rig_core::tool::ToolContextError>(())
12//! ```
13
14use std::any::Any;
15use std::collections::BTreeMap;
16
17use serde::{Deserialize, Serialize, de::DeserializeOwned};
18
19use super::ToolExecutionError;
20
21/// Context passed to every tool execution.
22///
23/// Callers insert typed inbound values with [`insert`](Self::insert). Tools read
24/// those values with [`get`](Self::get) or [`require`](Self::require), and attach
25/// host-only result metadata with [`insert_result`](Self::insert_result). Result
26/// hooks inspect that metadata through [`result`](Self::result). Neither inbound
27/// values nor result metadata are sent to the model.
28///
29/// Every value is **data**: inserting serializes it, reading deserializes it,
30/// and the whole context is itself `Serialize + Deserialize`. Explicit scene
31/// serialization can therefore contain sensitive inbound values and must be
32/// protected by the host. Effect logs record only [`ToolResultContext`]:
33/// values explicitly published through `insert_result`, never inbound slots
34/// or runtime scopes. Replay uses the current dispatch's inbound context;
35/// code must not treat mutations to inbound slots as durable output. Values with
36/// interior sharing (`Arc<Mutex<_>>`, atomics, channels) do not belong here;
37/// they belong to the tool instance.
38///
39/// Registry, server, and agent dispatch clone inbound values once per call.
40/// Map-level mutations (inserting, replacing, or removing typed slots) affect
41/// only that execution. The dispatch surface returns result metadata without
42/// replacing the caller's inbound slots.
43///
44/// Slots use the value type's declared [`ContextValue::KEY`]. Accessors need
45/// no key argument; types must use distinct keys to occupy distinct slots.
46#[derive(Default, Clone, Serialize, Deserialize)]
47pub struct ToolContext {
48    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
49    inbound: BTreeMap<String, serde_json::Value>,
50    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
51    result: BTreeMap<String, serde_json::Value>,
52    /// Live runtime scopes, excluded from serialization and equality.
53    /// Publication clears them before the result is resolved.
54    #[serde(skip)]
55    scopes: Vec<std::sync::Arc<dyn Any + Send + Sync>>,
56}
57
58/// Explicitly published, durable tool-result metadata. Unlike [`ToolContext`],
59/// this contains no inbound credentials or live runtime scopes. Values are
60/// keyed by [`ContextValue::KEY`]; applications own their schema versions.
61#[derive(Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
62#[serde(transparent)]
63pub struct ToolResultContext(BTreeMap<String, serde_json::Value>);
64
65impl std::fmt::Debug for ToolResultContext {
66    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
67        f.debug_tuple("ToolResultContext")
68            .field(&self.0.keys().collect::<Vec<_>>())
69            .finish()
70    }
71}
72
73impl ToolResultContext {
74    /// Read a published value without exposing ambient execution inputs.
75    pub fn get<T: ContextValue>(&self) -> Result<Option<T>, ToolContextError> {
76        get_slot(&self.0)
77    }
78}
79
80impl PartialEq for ToolContext {
81    fn eq(&self, other: &Self) -> bool {
82        self.inbound == other.inbound && self.result == other.result
83    }
84}
85
86impl Eq for ToolContext {}
87
88/// Serializable context data with a stable slot key. Distinct value types must
89/// use distinct keys, and persisted keys must remain stable across schema changes.
90/// The derive macro defaults to the type name; `#[context(key = "…")]` overrides it.
91#[diagnostic::on_unimplemented(
92    message = "`{Self}` declares no `ToolContext` key",
93    label = "not a `ContextValue`",
94    note = "derive it (`#[derive(rig::ContextValue)]`, optionally `#[context(key = \"…\")]`) or write `impl ContextValue for {Self} {{ const KEY: &'static str = \"…\"; }}`; a bare `String`, integer or `serde_json::Value` cannot be stored — wrap it in a newtype"
95)]
96pub trait ContextValue: Serialize + DeserializeOwned + 'static {
97    /// The slot this value lives under.
98    const KEY: &'static str;
99}
100
101fn encode<T: ContextValue>(value: &T) -> Result<serde_json::Value, ToolContextError> {
102    serde_json::to_value(value).map_err(|error| ToolContextError::Encode {
103        key: T::KEY,
104        source: error,
105    })
106}
107
108fn decode<T: ContextValue>(value: &serde_json::Value) -> Result<T, ToolContextError> {
109    T::deserialize(value).map_err(|error| ToolContextError::Decode {
110        key: T::KEY,
111        source: error,
112    })
113}
114
115/// Replaces a slot after successful encoding, returning the displaced value
116/// only if it decodes as `T`.
117fn insert_slot<T: ContextValue>(
118    map: &mut BTreeMap<String, serde_json::Value>,
119    value: T,
120) -> Result<Option<T>, ToolContextError> {
121    let encoded = encode(&value)?;
122    Ok(map
123        .insert(T::KEY.to_owned(), encoded)
124        .and_then(|previous| decode(&previous).ok()))
125}
126
127/// `Ok(None)` when the slot is empty, `Err(Decode)` when it holds something
128/// that is not a `T`: absence and a shape mismatch are different facts.
129fn get_slot<T: ContextValue>(
130    map: &BTreeMap<String, serde_json::Value>,
131) -> Result<Option<T>, ToolContextError> {
132    map.get(T::KEY).map(decode).transpose()
133}
134
135impl ToolContext {
136    /// Create an empty context.
137    pub const fn new() -> Self {
138        Self {
139            inbound: BTreeMap::new(),
140            result: BTreeMap::new(),
141            scopes: Vec::new(),
142        }
143    }
144
145    /// Insert an inbound typed value, returning the displaced value if it decodes
146    /// as `T`. A differently shaped displaced value is replaced successfully and
147    /// returns `None`; decoding the previous value cannot undo a successful write.
148    ///
149    /// Returns an encoding error if serialization fails, leaving the slot unchanged.
150    pub fn insert<T: ContextValue>(&mut self, value: T) -> Result<Option<T>, ToolContextError> {
151        insert_slot(&mut self.inbound, value)
152    }
153
154    /// Read an inbound typed value: `Ok(None)` when absent, a decode error when
155    /// the stored value does not match `T`.
156    pub fn get<T: ContextValue>(&self) -> Result<Option<T>, ToolContextError> {
157        get_slot(&self.inbound)
158    }
159
160    /// Require an inbound typed value.
161    pub fn require<T: ContextValue>(&self) -> Result<T, ToolContextError> {
162        get_slot(&self.inbound)?.ok_or(ToolContextError::Missing(T::KEY))
163    }
164
165    /// Remove an inbound typed value. The slot is removed even if decoding its
166    /// former value fails.
167    pub fn remove<T: ContextValue>(&mut self) -> Result<Option<T>, ToolContextError> {
168        self.inbound
169            .remove(T::KEY)
170            .map(|value| decode(&value))
171            .transpose()
172    }
173
174    /// Attach host-only metadata to this execution's result.
175    pub fn insert_result<T: ContextValue>(
176        &mut self,
177        value: T,
178    ) -> Result<Option<T>, ToolContextError> {
179        insert_slot(&mut self.result, value)
180    }
181
182    /// Read host-only result metadata.
183    pub fn result<T: ContextValue>(&self) -> Result<Option<T>, ToolContextError> {
184        get_slot(&self.result)
185    }
186
187    /// Whether this context contains the inbound type `T`.
188    pub fn contains<T: ContextValue>(&self) -> bool {
189        self.inbound.contains_key(T::KEY)
190    }
191
192    /// Whether both maps are empty.
193    pub fn is_empty(&self) -> bool {
194        self.inbound.is_empty() && self.result.is_empty()
195    }
196
197    /// Build the snapshot one dispatch runs against: the same inbound values
198    /// and no result metadata. Runtimes call this per tool call so map-level
199    /// mutations inside the call cannot leak into the caller's context.
200    pub fn for_dispatch(&self) -> Self {
201        Self {
202            inbound: self.inbound.clone(),
203            result: BTreeMap::new(),
204            scopes: self.scopes.clone(),
205        }
206    }
207
208    /// Attach one of the driver's scopes for the call (see the field).
209    pub fn with_scope(mut self, scope: std::sync::Arc<dyn Any + Send + Sync>) -> Self {
210        self.scopes.push(scope);
211        self
212    }
213
214    /// Attach the driver's scopes for the call, as the sink carries them.
215    pub fn with_scopes(mut self, scopes: Vec<std::sync::Arc<dyn Any + Send + Sync>>) -> Self {
216        self.scopes.extend(scopes);
217        self
218    }
219
220    /// Returns the first attached scope of type `T`, or `None` if absent.
221    pub fn scope<T: Any + Send + Sync>(&self) -> Option<std::sync::Arc<T>> {
222        self.scopes
223            .iter()
224            .find_map(|scope| std::sync::Arc::downcast::<T>(scope.clone()).ok())
225    }
226
227    /// Drop the scopes: the call is over and the context is data again.
228    pub fn clear_scope(&mut self) {
229        self.scopes.clear();
230    }
231
232    /// Publish the result metadata a dispatch produced (see
233    /// [`Self::for_dispatch`]) while keeping the caller's inbound values.
234    pub fn accept_dispatch_result(&mut self, dispatched: Self) {
235        self.result = dispatched.result;
236    }
237
238    /// Snapshot only the values explicitly published with `insert_result`.
239    pub fn result_context(&self) -> ToolResultContext {
240        ToolResultContext(self.result.clone())
241    }
242
243    /// The values explicitly published with `insert_result`, taken.
244    pub fn into_result_context(self) -> ToolResultContext {
245        ToolResultContext(self.result)
246    }
247
248    /// Restore durable output while preserving this dispatch's inbound values.
249    pub fn with_result_context(mut self, result: ToolResultContext) -> Self {
250        self.result = result.0;
251        self
252    }
253
254    /// Drop the previous dispatch's result metadata before starting another.
255    pub fn clear_dispatch_result(&mut self) {
256        self.result.clear();
257    }
258}
259
260impl std::fmt::Debug for ToolContext {
261    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
262        f.debug_struct("ToolContext")
263            .field("inbound_types", &self.inbound.keys().collect::<Vec<_>>())
264            .field("result_types", &self.result.keys().collect::<Vec<_>>())
265            .finish()
266    }
267}
268
269/// What one dispatch published beside its answer, passed back beside the
270/// sink rather than on the wire. A tool's adapter fills it with the tool's
271/// whole context (a second publish replaces the first); any other layer,
272/// such as a stream rewrite
273/// ([`ErasedHandler::map_stream`](crate::serve::ErasedHandler::map_stream)),
274/// adds single facts with [`insert`](Self::insert).
275#[derive(Debug, Default)]
276pub struct PublishedContext(std::sync::Mutex<Option<ToolContext>>);
277
278impl PublishedContext {
279    /// An empty one, shared between the driver and the sink.
280    pub fn new() -> std::sync::Arc<Self> {
281        std::sync::Arc::new(Self::default())
282    }
283
284    /// The recorder's non-consuming snapshot. Publish before resolving the
285    /// sink; values published after resolution are not part of that outcome.
286    pub fn result_context(&self) -> Option<ToolResultContext> {
287        self.0
288            .lock()
289            .unwrap_or_else(std::sync::PoisonError::into_inner)
290            .as_ref()
291            .map(ToolContext::result_context)
292    }
293
294    /// The adapter's write: the context after the tool ran, scopes cleared.
295    /// All publication must finish before resolving the sink. Publishing
296    /// afterward violates the delivery contract: consumers and recorders may
297    /// observe different versions. The slot does not enforce this ordering.
298    pub fn publish(&self, mut context: ToolContext) {
299        context.clear_scope();
300        *self
301            .0
302            .lock()
303            .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(context);
304    }
305
306    /// Adds `value` to what is published, beside what is there, as
307    /// [`ToolContext::insert_result`] does, and returns the value it
308    /// replaced. Publish before the dispatch's answer is resolved, as for
309    /// [`publish`](Self::publish).
310    ///
311    /// # Errors
312    ///
313    /// When `value` cannot be encoded; nothing changes then.
314    pub fn insert<T: ContextValue>(&self, value: T) -> Result<Option<T>, ToolContextError> {
315        self.0
316            .lock()
317            .unwrap_or_else(std::sync::PoisonError::into_inner)
318            .get_or_insert_with(ToolContext::new)
319            .insert_result(value)
320    }
321
322    /// The driver's read: what was published, if anything, leaving nothing.
323    pub fn take(&self) -> Option<ToolContext> {
324        self.0
325            .lock()
326            .unwrap_or_else(std::sync::PoisonError::into_inner)
327            .take()
328    }
329}
330
331/// A [`ToolContext`] slot could not be read or written.
332#[derive(Debug, thiserror::Error)]
333pub enum ToolContextError {
334    /// A required typed value was absent.
335    #[error("required tool context value `{0}` was not found")]
336    Missing(&'static str),
337    /// A value could not be represented as JSON.
338    #[error("tool context value `{key}` could not be encoded: {source}")]
339    Encode {
340        /// The slot's type name.
341        key: &'static str,
342        /// The serializer's failure.
343        #[source]
344        source: serde_json::Error,
345    },
346    /// A stored value could not be decoded as the requested type.
347    #[error("tool context value `{key}` could not be decoded: {source}")]
348    Decode {
349        /// The slot's type name.
350        key: &'static str,
351        /// The deserializer's failure.
352        #[source]
353        source: serde_json::Error,
354    },
355}
356
357impl From<ToolContextError> for ToolExecutionError {
358    fn from(error: ToolContextError) -> Self {
359        ToolExecutionError::other(error.to_string()).with_source(error)
360    }
361}
362
363// The context crosses the effect wire: it must serialize and cross threads on
364// every target, browser wasm included.
365const _: fn() = || {
366    fn assert_wire<T: Send + Sync + 'static + Serialize + DeserializeOwned>() {}
367    assert_wire::<ToolContext>();
368    fn assert_shared<T: Send + Sync + 'static>() {}
369    assert_shared::<PublishedContext>();
370};
371
372#[cfg(test)]
373mod tests;
374
375#[cfg(test)]
376mod migrated_tests;