Skip to main content

llm_tool/types/
context.rs

1//! Execution context and shared state store passed to Rust tools during dispatch.
2
3use alloc::{borrow::ToOwned, boxed::Box, format, string::String, sync::Arc};
4use core::any::{Any, TypeId};
5
6use super::ToolError;
7use crate::compat::{HashMap, RwLock, read_lock, write_lock};
8
9/// A cheaply-cloneable handle to a tool's shared key-value state store.
10///
11/// [`ToolContext`] keeps its state behind this handle so multiple contexts
12/// (e.g. successive tool calls within the same agent turn) can read and write
13/// the **same** underlying store. Clone it and hand it to another context via
14/// [`ToolContext::with_shared_state`].
15///
16/// The concrete lock and map types are an implementation detail — they differ
17/// between `std` and `no_std` builds — so they are deliberately not exposed.
18#[derive(Clone)]
19pub struct SharedState(pub(super) Arc<RwLock<HashMap<String, serde_json::Value>>>);
20
21impl SharedState {
22    /// Create a new, empty shared state store.
23    #[must_use]
24    pub fn new() -> Self {
25        Self::default()
26    }
27
28    /// Retrieve a value from the shared state, returning `default` if the key
29    /// is absent or the lock is poisoned.
30    #[must_use]
31    pub fn get_state(&self, key: &str, default: serde_json::Value) -> serde_json::Value {
32        match read_lock(&self.0) {
33            Ok(guard) => guard.get(key).cloned().unwrap_or(default),
34            Err(e) => {
35                tracing::warn!(key, error = %e, "SharedState::get_state: lock poisoned, returning default");
36                default
37            }
38        }
39    }
40
41    /// Insert or update a value in the shared state.
42    ///
43    /// # Errors
44    ///
45    /// Returns [`ToolError`] if the lock is poisoned.
46    pub fn set_state(&self, key: &str, value: serde_json::Value) -> Result<(), ToolError> {
47        match write_lock(&self.0) {
48            Ok(mut guard) => {
49                guard.insert(key.to_owned(), value);
50                Ok(())
51            }
52            Err(e) => {
53                let msg = format!("SharedState::set_state: lock poisoned for key '{key}': {e}");
54                tracing::warn!("{msg}");
55                Err(ToolError::new(msg))
56            }
57        }
58    }
59
60    /// Remove a value from the shared state by key, returning `true` if it was present.
61    #[must_use]
62    pub fn remove_state(&self, key: &str) -> bool {
63        match write_lock(&self.0) {
64            Ok(mut guard) => guard.remove(key).is_some(),
65            Err(e) => {
66                tracing::warn!(key, error = %e, "SharedState::remove_state: lock poisoned");
67                false
68            }
69        }
70    }
71
72    /// Clear all entries from the shared state.
73    pub fn clear_state(&self) {
74        match write_lock(&self.0) {
75            Ok(mut guard) => guard.clear(),
76            Err(e) => {
77                tracing::warn!(error = %e, "SharedState::clear_state: lock poisoned");
78            }
79        }
80    }
81}
82
83impl Default for SharedState {
84    fn default() -> Self {
85        Self(Arc::new(RwLock::new(HashMap::new())))
86    }
87}
88
89impl core::fmt::Debug for SharedState {
90    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
91        f.debug_struct("SharedState").finish_non_exhaustive()
92    }
93}
94
95/// Context passed to Rust tools during dispatch.
96///
97/// Provides access to the current conversation ID, a shared key-value state
98/// store ([`SharedState`]) that persists across tool calls within the same
99/// agent turn, and typed extensions via [`set_ext`](Self::set_ext) / [`get_ext`](Self::get_ext).
100pub struct ToolContext {
101    conversation_id: Option<String>,
102    pub(super) state: SharedState,
103    pub(super) extensions: Arc<RwLock<HashMap<TypeId, Box<dyn Any + Send + Sync>>>>,
104}
105
106impl Clone for ToolContext {
107    fn clone(&self) -> Self {
108        Self {
109            conversation_id: self.conversation_id.clone(),
110            state: self.state.clone(),
111            extensions: Arc::clone(&self.extensions),
112        }
113    }
114}
115
116impl core::fmt::Debug for ToolContext {
117    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
118        let count = match read_lock(&self.extensions) {
119            Ok(g) => g.len(),
120            Err(e) => {
121                tracing::warn!(error = %e, "ToolContext::fmt: extensions lock poisoned");
122                0
123            }
124        };
125        f.debug_struct("ToolContext")
126            .field("conversation_id", &self.conversation_id)
127            .field("state", &self.state)
128            .field("extensions_count", &count)
129            .finish()
130    }
131}
132
133impl Default for ToolContext {
134    fn default() -> Self {
135        Self::new()
136    }
137}
138
139impl ToolContext {
140    /// Create a new, empty context: no conversation ID and a fresh state store.
141    ///
142    /// Customize it with [`with_conversation_id`](Self::with_conversation_id)
143    /// and [`with_shared_state`](Self::with_shared_state).
144    #[must_use]
145    pub fn new() -> Self {
146        Self {
147            conversation_id: None,
148            state: SharedState::new(),
149            extensions: Arc::new(RwLock::new(HashMap::new())),
150        }
151    }
152
153    /// Set the conversation ID. Chainable.
154    #[must_use]
155    pub fn with_conversation_id(mut self, conversation_id: impl Into<String>) -> Self {
156        self.conversation_id = Some(conversation_id.into());
157        self
158    }
159
160    /// Derive a new context that carries a different conversation ID while
161    /// **sharing** this context's state store and typed extensions.
162    ///
163    /// Both the shared state ([`SharedState`]) and the typed extension map are
164    /// held behind `Arc`, so the returned context reads and writes the *same*
165    /// underlying stores — only the conversation identity differs. This is the
166    /// primitive a single MCP server uses to serve **many** callers: each
167    /// connection derives its own identity from the shared, session-wide
168    /// context without duplicating extensions like injected session state.
169    ///
170    /// ```rust
171    /// use std::sync::Arc;
172    ///
173    /// use llm_tool::ToolContext;
174    ///
175    /// let session = ToolContext::new().with_conversation_id("server");
176    /// session.set_ext(Arc::new(42u64)).unwrap();
177    ///
178    /// let alice = session.with_caller("alice");
179    /// assert_eq!(alice.conversation_id(), Some("alice"));
180    /// // Extensions are shared, not copied.
181    /// assert_eq!(alice.get_ext::<Arc<u64>>().as_deref(), Some(&42));
182    /// ```
183    #[must_use]
184    pub fn with_caller(&self, conversation_id: impl Into<String>) -> Self {
185        Self {
186            conversation_id: Some(conversation_id.into()),
187            state: self.state.clone(),
188            extensions: Arc::clone(&self.extensions),
189        }
190    }
191
192    /// Use an externally-provided [`SharedState`] as this context's state store.
193    ///
194    /// Use this when multiple `ToolContext` instances (e.g. successive tool
195    /// calls within the same agent) must read/write the **same** state store.
196    /// Obtain a handle from an existing context via
197    /// [`shared_state`](Self::shared_state).
198    #[must_use]
199    pub fn with_shared_state(mut self, state: SharedState) -> Self {
200        self.state = state;
201        self
202    }
203
204    /// Return a cloneable handle to this context's shared state store.
205    ///
206    /// Pass the returned handle to [`with_shared_state`](Self::with_shared_state)
207    /// on another context to share the same underlying store.
208    #[must_use]
209    pub fn shared_state(&self) -> SharedState {
210        self.state.clone()
211    }
212
213    /// Return the conversation ID, if one has been set.
214    #[must_use]
215    pub fn conversation_id(&self) -> Option<&str> {
216        self.conversation_id.as_deref()
217    }
218
219    /// Retrieve a value from the shared state, returning `default` if the key
220    /// is absent or the lock is poisoned.
221    ///
222    /// This method never fails — on a poisoned lock it logs a warning and
223    /// returns `default`. See the [struct-level docs](Self) for rationale.
224    #[must_use]
225    pub fn get_state(&self, key: &str, default: serde_json::Value) -> serde_json::Value {
226        match read_lock(&self.state.0) {
227            Ok(guard) => guard.get(key).cloned().unwrap_or(default),
228            Err(e) => {
229                tracing::warn!(key, error = %e, "ToolContext::get_state: lock poisoned, returning default");
230                default
231            }
232        }
233    }
234
235    /// Insert or update a value in the shared state.
236    ///
237    /// Unlike [`get_state`](Self::get_state), this method returns `Err` on a
238    /// poisoned lock because silently dropping a write can cause subtle bugs.
239    /// See the [struct-level docs](Self) for rationale.
240    ///
241    /// # Errors
242    ///
243    /// Returns [`ToolError`]
244    /// if the lock is poisoned.
245    pub fn set_state(&self, key: &str, value: serde_json::Value) -> Result<(), ToolError> {
246        match write_lock(&self.state.0) {
247            Ok(mut guard) => {
248                guard.insert(key.to_owned(), value);
249                Ok(())
250            }
251            Err(e) => {
252                let msg = format!("ToolContext::set_state: lock poisoned for key '{key}': {e}");
253                tracing::warn!("{msg}");
254                Err(ToolError::new(msg))
255            }
256        }
257    }
258
259    /// Store a typed value in the extensions map.
260    ///
261    /// Values are keyed by `TypeId`, so each concrete type can only appear
262    /// once. Typically used to store `Arc<T>` for shared, cloneable access.
263    ///
264    /// # Errors
265    ///
266    /// Returns [`ToolError`] if the lock is poisoned.
267    pub fn set_ext<T: Send + Sync + 'static>(&self, value: T) -> Result<(), ToolError> {
268        match write_lock(&self.extensions) {
269            Ok(mut exts) => {
270                exts.insert(TypeId::of::<T>(), Box::new(value));
271                Ok(())
272            }
273            Err(e) => {
274                let msg = format!(
275                    "ToolContext::set_ext: lock poisoned for type '{}': {e}",
276                    core::any::type_name::<T>()
277                );
278                tracing::warn!("{msg}");
279                Err(ToolError::new(msg))
280            }
281        }
282    }
283
284    /// Retrieve a clone of a typed value from the extensions map.
285    ///
286    /// Returns `None` if no value of type `T` has been stored via
287    /// [`set_ext`](Self::set_ext).
288    #[must_use]
289    pub fn get_ext<T: Clone + Send + Sync + 'static>(&self) -> Option<T> {
290        match read_lock(&self.extensions) {
291            Ok(exts) => exts
292                .get(&TypeId::of::<T>())
293                .and_then(|v| v.downcast_ref::<T>())
294                .cloned(),
295            Err(e) => {
296                tracing::warn!(error = %e, "ToolContext::get_ext: lock poisoned, returning None");
297                None
298            }
299        }
300    }
301}