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}