Skip to main content

behest_runtime/
tool_scope.rs

1//! LIFO scoped tool registry with shadow-stack semantics.
2//!
3//! [`ScopedToolRegistry`] wraps a base [`ToolRegistry`] with a stack of
4//! named scopes. Tools registered in higher scopes shadow those with the
5//! same name in lower scopes. When a scope is popped, all tools registered
6//! in that scope are removed, revealing any previously-shadowed tools.
7//!
8//! # Scope model
9//!
10//! ```text
11//!   Scope 2 (Turn)     ── top
12//!   Scope 1 (Run)
13//!   Scope 0 (Agent)    ── bottom
14//!   Base registry       ── always present
15//! ```
16//!
17//! Resolution order: top scope → ... → bottom scope → base registry.
18//! First match wins.
19//!
20//! # Thread safety
21//!
22//! The scope stack uses interior mutability (`Mutex`) so that scopes can
23//! be pushed/popped through a shared reference. The base registry is
24//! lock-free. Cloning captures the current scope snapshot; cloned copies
25//! operate independently.
26//!
27//! # Example
28//!
29//! ```rust
30//! use behest_tool::{FunctionTool, ToolRegistry};
31//! use behest_runtime::tool_scope::ScopedToolRegistry;
32//! use serde_json::json;
33//!
34//! let base = ToolRegistry::new();
35//! let scoped = ScopedToolRegistry::new(base);
36//!
37//! let scope = scoped.push_scope();
38//! scoped.register_in_scope(scope, FunctionTool::new(
39//!     "my_tool", "desc", json!({}),
40//!     |args| async move { Ok(args) },
41//! ));
42//!
43//! assert!(scoped.get("my_tool").is_some());
44//!
45//! scoped.pop_scope(scope);
46//! assert!(scoped.get("my_tool").is_none());
47//! ```
48
49use std::collections::HashMap;
50use std::sync::{Arc, Mutex, MutexGuard};
51
52use behest_provider::ToolSpec;
53use behest_tool::{Tool, ToolRegistry};
54
55/// Scope identifier returned by [`ScopedToolRegistry::push_scope`].
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
57pub struct ScopeId(pub(crate) usize);
58
59/// Well-known scope levels for documentation and ordering.
60///
61/// These are advisory labels — the actual ordering is determined by
62/// push/pop sequence, not by the enum value. Use these as convention
63/// when building runtime integrations.
64#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
65pub enum ScopeLevel {
66    /// Global tools available to all agents (e.g., shell, file system).
67    Global = 0,
68    /// Agent-level tools registered when an agent definition is loaded.
69    Agent = 1,
70    /// Run-level tools for the lifetime of a single `run()` invocation.
71    Run = 2,
72    /// Turn-level tools for a single iteration within a run.
73    Turn = 3,
74}
75
76/// A layer in the tool scope stack, holding tools registered at that scope.
77#[derive(Clone)]
78struct ScopeLayer {
79    tools: HashMap<String, Arc<dyn Tool>>,
80}
81
82/// Interior state protected by a mutex.
83struct ScopeState {
84    layers: Vec<(ScopeId, ScopeLayer)>,
85    next_id: usize,
86}
87
88impl Clone for ScopeState {
89    fn clone(&self) -> Self {
90        Self {
91            layers: self.layers.clone(),
92            next_id: self.next_id,
93        }
94    }
95}
96
97/// Result of registering an already-Arc'd tool in a scope.
98type RegisterArcResult = Result<Option<Arc<dyn Tool>>, (String, Arc<dyn Tool>)>;
99
100/// LIFO scoped tool registry with shadow-stack semantics.
101///
102/// Wraps a base [`ToolRegistry`] with a stack of scopes. Tools in
103/// higher scopes shadow identically-named tools in lower scopes.
104/// Popping a scope removes all its tools and restores shadows.
105///
106/// The scope stack is protected by a `Mutex` for interior mutability,
107/// enabling push/pop through `&self`. The base registry is lock-free.
108pub struct ScopedToolRegistry {
109    base: ToolRegistry,
110    state: Mutex<ScopeState>,
111}
112
113impl Clone for ScopedToolRegistry {
114    fn clone(&self) -> Self {
115        let state = self.lock_state_poison_safe();
116        Self {
117            base: self.base.clone(),
118            state: Mutex::new(state.clone()),
119        }
120    }
121}
122
123impl ScopedToolRegistry {
124    /// Creates a new scoped registry with the given base.
125    ///
126    /// The base registry provides tools at the lowest resolution level.
127    #[must_use]
128    pub fn new(base: ToolRegistry) -> Self {
129        Self {
130            base,
131            state: Mutex::new(ScopeState {
132                layers: Vec::new(),
133                next_id: 0,
134            }),
135        }
136    }
137
138    /// Pushes a new empty scope onto the stack and returns its [`ScopeId`].
139    #[must_use]
140    pub fn push_scope(&self) -> ScopeId {
141        let mut state = self.lock_state_poison_safe();
142        let id = ScopeId(state.next_id);
143        state.next_id += 1;
144        state.layers.push((
145            id,
146            ScopeLayer {
147                tools: HashMap::new(),
148            },
149        ));
150        id
151    }
152
153    /// Pops the given scope from the stack.
154    ///
155    /// Returns `true` if the scope was found and removed, `false` if it
156    /// was not the top scope or does not exist. Only the topmost scope
157    /// can be popped — attempting to pop a non-top scope is a no-op.
158    pub fn pop_scope(&self, id: ScopeId) -> bool {
159        let mut state = self.lock_state_poison_safe();
160        if let Some(&(top_id, _)) = state.layers.last()
161            && top_id == id
162        {
163            state.layers.pop();
164            return true;
165        }
166        false
167    }
168
169    /// Registers a tool in the specified scope.
170    ///
171    /// If a tool with the same name already exists in that scope, it is
172    /// replaced and the old tool is returned. Tools in other scopes or
173    /// the base registry are not affected — they are simply shadowed.
174    ///
175    /// # Errors
176    ///
177    /// Returns the tool back if the scope does not exist.
178    pub fn register_in_scope<T: Tool + 'static>(
179        &self,
180        scope: ScopeId,
181        tool: T,
182    ) -> Result<Option<Arc<dyn Tool>>, T> {
183        let name = tool.name().to_owned();
184        let mut state = self.lock_state_poison_safe();
185        for (id, layer) in &mut state.layers {
186            if *id == scope {
187                let arc: Arc<dyn Tool> = Arc::new(tool);
188                return Ok(layer.tools.insert(name, arc));
189            }
190        }
191        Err(tool)
192    }
193
194    /// Registers an already-`Arc`'d tool in the specified scope.
195    ///
196    /// Returns `Ok(old)` if the scope exists, or `Err((name, tool))` if not.
197    ///
198    /// # Errors
199    ///
200    /// Returns `Err((name, tool))` when the requested `scope` does not exist
201    /// in the layer stack. The returned tuple lets the caller recover the
202    /// name and `Arc`'d tool without an extra allocation.
203    pub fn register_arc_in_scope(
204        &self,
205        scope: ScopeId,
206        name: String,
207        tool: Arc<dyn Tool>,
208    ) -> RegisterArcResult {
209        let mut state = self.lock_state_poison_safe();
210        for (id, layer) in &mut state.layers {
211            if *id == scope {
212                return Ok(layer.tools.insert(name, tool));
213            }
214        }
215        Err((name, tool))
216    }
217
218    /// Returns a tool by searching from top scope to base.
219    #[must_use]
220    pub fn get(&self, name: &str) -> Option<Arc<dyn Tool>> {
221        let state = self.lock_state_poison_safe();
222        // Search scopes top-down (last = topmost).
223        for (_, layer) in state.layers.iter().rev() {
224            if let Some(tool) = layer.tools.get(name) {
225                return Some(Arc::clone(tool));
226            }
227        }
228        // Fall back to base.
229        self.base.get(name)
230    }
231
232    /// Generates merged [`ToolSpec`]s from all scopes and base,
233    /// sorted alphabetically by tool name.
234    ///
235    /// Tools in higher scopes shadow those with the same name in lower
236    /// scopes or the base. The returned list contains one entry per
237    /// unique tool name.
238    ///
239    /// The sorted output ensures deterministic prompt caching across
240    /// turns and provider calls.
241    #[must_use]
242    pub fn specs(&self) -> Vec<ToolSpec> {
243        let state = self.lock_state_poison_safe();
244        let mut merged: HashMap<String, Arc<dyn Tool>> = HashMap::new();
245
246        // Start with base (lowest priority).
247        for spec in self.base.specs() {
248            if let Some(tool) = self.base.get(&spec.name) {
249                merged.insert(spec.name, tool);
250            }
251        }
252
253        // Overlay each scope bottom-up (top scope wins).
254        for (_, layer) in &state.layers {
255            for (name, tool) in &layer.tools {
256                merged.insert(name.clone(), Arc::clone(tool));
257            }
258        }
259
260        let mut specs: Vec<ToolSpec> = merged.values().map(|t| t.to_spec()).collect();
261        specs.sort_by(|a, b| a.name.cmp(&b.name));
262        specs
263    }
264
265    /// Returns the number of active scopes.
266    #[must_use]
267    pub fn scope_depth(&self) -> usize {
268        let state = self.lock_state_poison_safe();
269        state.layers.len()
270    }
271
272    /// Returns a reference to the base registry.
273    #[must_use]
274    pub fn base(&self) -> &ToolRegistry {
275        &self.base
276    }
277
278    /// Returns a mutable reference to the base registry.
279    pub fn base_mut(&mut self) -> &mut ToolRegistry {
280        &mut self.base
281    }
282
283    /// Removes a tool from the base registry by name.
284    pub fn unregister_from_base(&self, name: &str) -> Option<Arc<dyn Tool>> {
285        self.base.unregister(name)
286    }
287
288    /// Returns the total number of unique tools visible (base + all scopes).
289    #[must_use]
290    pub fn len(&self) -> usize {
291        self.specs().len()
292    }
293
294    /// Returns `true` if no tools are visible at all.
295    #[must_use]
296    pub fn is_empty(&self) -> bool {
297        self.specs().is_empty()
298    }
299
300    /// Executes a tool call using the merged tool view.
301    ///
302    /// This delegates to the first matching tool from top scope to base.
303    ///
304    /// # Errors
305    ///
306    /// Returns [`ToolError::NotFound`](behest_core::error::ToolError::NotFound)
307    /// when the tool is not in any scope or the base.
308    pub async fn execute(
309        &self,
310        call: &behest_provider::ToolCall,
311    ) -> behest_tool::ToolResult<behest_tool::ToolOutput> {
312        let tool = self
313            .get(&call.name)
314            .ok_or_else(|| behest_core::error::ToolError::NotFound {
315                name: call.name.clone(),
316            })?;
317        tool.execute(call.arguments.clone()).await
318    }
319
320    /// Pushes a new scope and returns a RAII guard that pops it on drop.
321    ///
322    /// This is the preferred way to manage scope lifetimes. The returned
323    /// [`ScopeGuard`] automatically calls [`pop_scope`](Self::pop_scope)
324    /// when dropped, ensuring cleanup even in the presence of early
325    /// returns or panics.
326    #[must_use]
327    pub fn push_scope_guarded(self: &Arc<Self>) -> ScopeGuard {
328        let id = self.push_scope();
329        ScopeGuard {
330            scope: Arc::clone(self),
331            id,
332        }
333    }
334
335    fn lock_state_poison_safe(&self) -> MutexGuard<'_, ScopeState> {
336        match self.state.lock() {
337            Ok(guard) => guard,
338            Err(poisoned) => poisoned.into_inner(),
339        }
340    }
341}
342
343/// RAII guard that pops a scope from a [`ScopedToolRegistry`] on drop.
344///
345/// Created by [`ScopedToolRegistry::push_scope_guarded`]. The scope is
346/// popped when the guard is dropped, regardless of whether the scope is
347/// still the top of the stack (non-top pops are silently ignored by
348/// the underlying [`pop_scope`](ScopedToolRegistry::pop_scope)).
349pub struct ScopeGuard {
350    scope: Arc<ScopedToolRegistry>,
351    id: ScopeId,
352}
353
354impl ScopeGuard {
355    /// Returns the [`ScopeId`] of the scope this guard will pop.
356    #[must_use]
357    pub fn id(&self) -> ScopeId {
358        self.id
359    }
360
361    /// Consumes the guard without popping. The scope remains on the stack.
362    ///
363    /// Returns the underlying [`ScopedToolRegistry`] reference.
364    #[must_use]
365    pub fn into_inner(self) -> Arc<ScopedToolRegistry> {
366        let scope = Arc::clone(&self.scope);
367        std::mem::forget(self);
368        scope
369    }
370}
371
372impl Drop for ScopeGuard {
373    fn drop(&mut self) {
374        self.scope.pop_scope(self.id);
375    }
376}
377
378impl std::fmt::Debug for ScopeGuard {
379    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
380        f.debug_struct("ScopeGuard")
381            .field("id", &self.id)
382            .finish_non_exhaustive()
383    }
384}
385
386#[cfg(test)]
387#[allow(clippy::unwrap_used, clippy::type_complexity, clippy::expect_used)]
388mod tests {
389    use super::*;
390    use behest_tool::FunctionTool;
391    use serde_json::{Value, json};
392
393    fn make_tool(name: &'static str) -> FunctionTool {
394        FunctionTool::new(
395            name,
396            format!("{name} desc"),
397            json!({"type": "object"}),
398            |args| -> std::pin::Pin<
399                Box<dyn std::future::Future<Output = behest_tool::ToolResult<Value>> + Send>,
400            > { Box::pin(async move { Ok(args) }) },
401        )
402    }
403
404    #[test]
405    fn new_should_have_base_tools() {
406        let base = ToolRegistry::new();
407        base.register(make_tool("base_tool"));
408        let scoped = ScopedToolRegistry::new(base);
409
410        assert!(scoped.get("base_tool").is_some());
411        assert!(scoped.get("missing").is_none());
412        assert_eq!(scoped.scope_depth(), 0);
413    }
414
415    #[test]
416    fn push_scope_should_create_empty_scope() {
417        let base = ToolRegistry::new();
418        let scoped = ScopedToolRegistry::new(base);
419        let scope = scoped.push_scope();
420
421        assert_eq!(scoped.scope_depth(), 1);
422        assert!(scoped.get("anything").is_none());
423        let _ = scope;
424    }
425
426    #[test]
427    fn register_in_scope_should_shadow_base() {
428        let base = ToolRegistry::new();
429        base.register(make_tool("shared"));
430        let scoped = ScopedToolRegistry::new(base);
431
432        let scope = scoped.push_scope();
433        scoped
434            .register_in_scope(scope, make_tool("shared"))
435            .ok()
436            .unwrap();
437
438        assert!(scoped.get("shared").is_some());
439        assert_eq!(scoped.specs().len(), 1);
440    }
441
442    #[test]
443    fn pop_scope_should_unshadow_base_tool() {
444        let base = ToolRegistry::new();
445        base.register(make_tool("shared"));
446        let scoped = ScopedToolRegistry::new(base);
447
448        let scope = scoped.push_scope();
449        scoped
450            .register_in_scope(scope, make_tool("shared"))
451            .ok()
452            .unwrap();
453        assert!(scoped.get("shared").is_some());
454
455        scoped.pop_scope(scope);
456        assert!(scoped.get("shared").is_some());
457        assert_eq!(scoped.scope_depth(), 0);
458    }
459
460    #[test]
461    fn pop_scope_should_remove_scope_only_tools() {
462        let base = ToolRegistry::new();
463        let scoped = ScopedToolRegistry::new(base);
464
465        let scope = scoped.push_scope();
466        scoped
467            .register_in_scope(scope, make_tool("scope_only"))
468            .ok()
469            .unwrap();
470        assert!(scoped.get("scope_only").is_some());
471
472        scoped.pop_scope(scope);
473        assert!(scoped.get("scope_only").is_none());
474    }
475
476    #[test]
477    fn pop_scope_non_top_should_return_false() {
478        let base = ToolRegistry::new();
479        let registry = ScopedToolRegistry::new(base);
480
481        let scope1 = registry.push_scope();
482        let scope2 = registry.push_scope();
483
484        assert!(!registry.pop_scope(scope1));
485        assert_eq!(registry.scope_depth(), 2);
486
487        assert!(registry.pop_scope(scope2));
488        assert_eq!(registry.scope_depth(), 1);
489    }
490
491    #[test]
492    fn pop_nonexistent_scope_should_return_false() {
493        let base = ToolRegistry::new();
494        let scoped = ScopedToolRegistry::new(base);
495        assert!(!scoped.pop_scope(ScopeId(999)));
496    }
497
498    #[test]
499    fn multiple_scopes_shadow_correctly() {
500        let base = ToolRegistry::new();
501        let registry = ScopedToolRegistry::new(base);
502
503        let scope1 = registry.push_scope();
504        registry
505            .register_in_scope(scope1, make_tool("tool"))
506            .ok()
507            .unwrap();
508
509        let scope2 = registry.push_scope();
510        registry
511            .register_in_scope(scope2, make_tool("tool"))
512            .ok()
513            .unwrap();
514
515        assert_eq!(registry.specs().len(), 1);
516
517        registry.pop_scope(scope2);
518        assert!(registry.get("tool").is_some());
519        assert_eq!(registry.specs().len(), 1);
520
521        registry.pop_scope(scope1);
522        assert!(registry.get("tool").is_none());
523    }
524
525    #[test]
526    fn register_in_nonexistent_scope_returns_error() {
527        let base = ToolRegistry::new();
528        let scoped = ScopedToolRegistry::new(base);
529        let result = scoped.register_in_scope(ScopeId(999), make_tool("nope"));
530        assert!(result.is_err());
531    }
532
533    #[test]
534    fn register_replaces_within_same_scope() {
535        let base = ToolRegistry::new();
536        let scoped = ScopedToolRegistry::new(base);
537
538        let scope = scoped.push_scope();
539        let old = scoped
540            .register_in_scope(scope, make_tool("tool"))
541            .ok()
542            .unwrap();
543        assert!(old.is_none());
544
545        let old = scoped
546            .register_in_scope(scope, make_tool("tool"))
547            .ok()
548            .unwrap();
549        assert!(old.is_some());
550        assert_eq!(scoped.specs().len(), 1);
551    }
552
553    #[tokio::test]
554    async fn execute_should_resolve_from_scoped_registry() {
555        let base = ToolRegistry::new();
556        let scoped = ScopedToolRegistry::new(base);
557
558        let scope = scoped.push_scope();
559        scoped
560            .register_in_scope(scope, make_tool("tool"))
561            .ok()
562            .unwrap();
563
564        let call = behest_provider::ToolCall::new("c1", "tool", json!({"x": 1}));
565        let output = scoped.execute(&call).await.unwrap();
566        assert_eq!(output.value, json!({"x": 1}));
567    }
568
569    #[tokio::test]
570    async fn execute_unknown_tool_returns_not_found() {
571        let base = ToolRegistry::new();
572        let scoped = ScopedToolRegistry::new(base);
573
574        let call = behest_provider::ToolCall::new("c1", "missing", json!({}));
575        let result = scoped.execute(&call).await;
576        assert!(result.is_err());
577    }
578
579    #[test]
580    fn scope_id_increments_monotonically() {
581        let base = ToolRegistry::new();
582        let scoped = ScopedToolRegistry::new(base);
583        let s1 = scoped.push_scope();
584        let s2 = scoped.push_scope();
585        assert!(s2.0 > s1.0);
586    }
587
588    #[test]
589    fn base_mut_allows_modifying_base() {
590        let base = ToolRegistry::new();
591        let mut scoped = ScopedToolRegistry::new(base);
592        scoped.base_mut().register(make_tool("new_base"));
593        assert!(scoped.get("new_base").is_some());
594    }
595
596    #[test]
597    fn clone_captures_scope_snapshot() {
598        let base = ToolRegistry::new();
599        let scoped = ScopedToolRegistry::new(base);
600
601        let scope = scoped.push_scope();
602        scoped
603            .register_in_scope(scope, make_tool("tool"))
604            .ok()
605            .unwrap();
606
607        let cloned = scoped.clone();
608        assert!(cloned.get("tool").is_some());
609
610        // Pop original scope — clone unaffected.
611        scoped.pop_scope(scope);
612        assert!(scoped.get("tool").is_none());
613        assert!(cloned.get("tool").is_some());
614    }
615
616    #[test]
617    fn scope_guard_pops_on_drop() {
618        let base = ToolRegistry::new();
619        let scoped = Arc::new(ScopedToolRegistry::new(base));
620
621        {
622            let _guard = scoped.push_scope_guarded();
623            assert_eq!(scoped.scope_depth(), 1);
624        }
625        // Guard dropped — scope popped.
626        assert_eq!(scoped.scope_depth(), 0);
627    }
628
629    #[test]
630    fn scope_guard_id_matches_pushed_scope() {
631        let base = ToolRegistry::new();
632        let scoped = Arc::new(ScopedToolRegistry::new(base));
633
634        let guard = scoped.push_scope_guarded();
635        let id = guard.id();
636        // Verify the scope exists.
637        assert_eq!(scoped.scope_depth(), 1);
638        // Pop manually to verify the id matches.
639        assert!(scoped.pop_scope(id));
640    }
641
642    #[test]
643    fn scope_guard_into_inner_prevents_drop_cleanup() {
644        let base = ToolRegistry::new();
645        let scoped = Arc::new(ScopedToolRegistry::new(base));
646
647        let guard = scoped.push_scope_guarded();
648        let id = guard.id();
649
650        // Consume guard without dropping it.
651        let _ = guard.into_inner();
652        // Scope should still be present (drop was skipped).
653        assert_eq!(scoped.scope_depth(), 1);
654        // Clean up manually.
655        scoped.pop_scope(id);
656    }
657
658    #[test]
659    fn scope_guard_tools_within_scope_are_visible() {
660        let base = ToolRegistry::new();
661        let scoped = Arc::new(ScopedToolRegistry::new(base));
662
663        let guard = scoped.push_scope_guarded();
664        scoped
665            .register_in_scope(guard.id(), make_tool("scoped_tool"))
666            .ok()
667            .unwrap();
668
669        assert!(scoped.get("scoped_tool").is_some());
670        assert_eq!(scoped.specs().len(), 1);
671
672        drop(guard);
673        // Tool should be gone after guard drop.
674        assert!(scoped.get("scoped_tool").is_none());
675        assert_eq!(scoped.specs().len(), 0);
676    }
677
678    #[test]
679    fn push_scope_is_thread_safe() {
680        use std::thread;
681
682        let base = ToolRegistry::new();
683        let scoped = Arc::new(ScopedToolRegistry::new(base));
684
685        let handles: Vec<_> = (0..4)
686            .map(|_| {
687                let s = Arc::clone(&scoped);
688                thread::spawn(move || {
689                    let _scope = s.push_scope();
690                })
691            })
692            .collect();
693
694        for h in handles {
695            h.join().unwrap();
696        }
697
698        assert_eq!(scoped.scope_depth(), 4);
699    }
700
701    #[test]
702    fn specs_should_return_sorted_by_name() {
703        let base = ToolRegistry::new();
704        base.register(make_tool("zebra"));
705        base.register(make_tool("alpha"));
706        let scoped = ScopedToolRegistry::new(base);
707
708        let scope = scoped.push_scope();
709        scoped
710            .register_in_scope(scope, make_tool("mike"))
711            .ok()
712            .unwrap();
713
714        let specs = scoped.specs();
715        assert_eq!(specs.len(), 3);
716        assert_eq!(specs[0].name, "alpha");
717        assert_eq!(specs[1].name, "mike");
718        assert_eq!(specs[2].name, "zebra");
719    }
720}