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;