Skip to main content

rig_agent/tool/
server.rs

1use std::{collections::BTreeSet, sync::Arc};
2
3#[cfg(all(feature = "rmcp", not(target_family = "wasm")))]
4use std::collections::HashMap;
5
6use indexmap::IndexMap;
7use tokio::sync::RwLock;
8
9#[cfg(all(feature = "rmcp", not(target_family = "wasm")))]
10use crate::tool::ErasedTool;
11
12use crate::{
13    completion::{CompletionError, ToolDefinition},
14    tool::{
15        DynamicTool, PortableDynamicTool, RegisteredTool, Tool, ToolContext, ToolDispatch,
16        ToolResult, ToolSet, dispatch_tool,
17    },
18};
19use rig_core::vector_store::{
20    VectorSearchRequest, VectorStoreError, VectorStoreIndexDyn, request::Filter,
21};
22
23/// One turn's provider definitions and the exact registry entries behind them.
24///
25/// Registration changes after this snapshot is built take effect on the next
26/// turn. Calls from the current turn dispatch through these pinned handles, so
27/// the implementation cannot drift from the schema the provider received.
28#[derive(Clone)]
29pub(crate) struct ToolRegistrySnapshot {
30    definitions: Vec<ToolDefinition>,
31    tools: IndexMap<String, RegisteredTool>,
32}
33
34impl ToolRegistrySnapshot {
35    fn new(tools: IndexMap<String, RegisteredTool>) -> Self {
36        let definitions = tools
37            .iter()
38            .map(|(name, tool)| tool.definition_with_name(name.clone()))
39            .collect();
40        Self { definitions, tools }
41    }
42
43    /// Provider-facing definitions in the same order as their pinned handles.
44    pub(crate) fn definitions(&self) -> &[ToolDefinition] {
45        &self.definitions
46    }
47
48    /// Narrow both provider exposure and dispatch to one per-turn allow-list.
49    pub(crate) fn retain_names(&mut self, names: &BTreeSet<String>) {
50        self.definitions
51            .retain(|definition| names.contains(&definition.name));
52        self.tools.retain(|name, _| names.contains(name));
53    }
54
55    /// Dispatch through the exact implementation advertised for this turn.
56    pub(crate) async fn dispatch(
57        &self,
58        tool_name: &str,
59        args: &str,
60        context: &ToolContext,
61    ) -> ToolDispatch {
62        let tool = self.tools.get(tool_name).cloned();
63        dispatch_tool(tool_name, args.to_string(), tool, context).await
64    }
65}
66
67/// Shared state behind a `ToolServerHandle`.
68struct ToolServerState {
69    /// Vector indexes used to select retrieval-only tools for each prompt.
70    retrieval_indexes: Vec<(usize, Arc<dyn VectorStoreIndexDyn + Send + Sync>)>,
71    /// The authoritative ordered registry for execution and exposure.
72    toolset: ToolSet,
73    /// Generation tokens for registrations managed by MCP client handlers.
74    /// A normal registration clears the token, preventing a stale handler
75    /// refresh from replacing or removing the newer tool.
76    #[cfg(all(feature = "rmcp", not(target_family = "wasm")))]
77    managed_generations: HashMap<String, ManagedToolToken>,
78}
79
80#[cfg(all(feature = "rmcp", not(target_family = "wasm")))]
81impl ToolServerState {
82    /// Remove remote registrations whose transport can no longer accept calls.
83    /// In-process tools use the default live state, while both handler-managed
84    /// and directly registered MCP tools report their transport state.
85    fn retire_disconnected_tools(&mut self) {
86        let disconnected = self
87            .toolset
88            .tools
89            .keys()
90            .filter(|name| self.toolset.get(name).is_none_or(|tool| !tool.is_live()))
91            .cloned()
92            .collect::<Vec<_>>();
93
94        for name in disconnected {
95            self.toolset.delete_tool(&name);
96            self.managed_generations.remove(&name);
97            tracing::debug!(tool_name = %name, "retired disconnected MCP tool registration");
98        }
99    }
100}
101
102/// Opaque identity for one MCP-managed registry generation.
103#[cfg(all(feature = "rmcp", not(target_family = "wasm")))]
104#[derive(Clone, Debug)]
105pub(crate) struct ManagedToolToken(Arc<()>);
106
107#[cfg(all(feature = "rmcp", not(target_family = "wasm")))]
108impl ManagedToolToken {
109    fn new() -> Self {
110        Self(Arc::new(()))
111    }
112}
113
114#[cfg(all(feature = "rmcp", not(target_family = "wasm")))]
115impl PartialEq for ManagedToolToken {
116    fn eq(&self, other: &Self) -> bool {
117        Arc::ptr_eq(&self.0, &other.0)
118    }
119}
120
121#[cfg(all(feature = "rmcp", not(target_family = "wasm")))]
122impl Eq for ManagedToolToken {}
123
124/// Builder for constructing a [`ToolServerHandle`].
125///
126/// Accumulates tools and configuration, then produces a shared handle via
127/// [`run()`](ToolServer::run).
128pub struct ToolServer {
129    retrieval_indexes: Vec<(usize, Arc<dyn VectorStoreIndexDyn + Send + Sync>)>,
130    toolset: ToolSet,
131}
132
133impl Default for ToolServer {
134    fn default() -> Self {
135        Self::new()
136    }
137}
138
139impl ToolServer {
140    pub fn new() -> Self {
141        Self {
142            retrieval_indexes: Vec::new(),
143            toolset: ToolSet::default(),
144        }
145    }
146
147    /// Add a static tool to the agent. Re-registering an existing name
148    /// replaces the implementation (last wins) and keeps its position.
149    pub fn tool(mut self, tool: impl Tool + 'static) -> Self {
150        self.toolset.add_tool(tool);
151        self
152    }
153
154    /// Add a runtime-defined tool. Re-registering an existing name replaces
155    /// the implementation and keeps its position.
156    pub fn dynamic_tool(mut self, tool: DynamicTool) -> Self {
157        self.toolset.add_dynamic_tool(tool);
158        self
159    }
160
161    /// Add several runtime-defined tools in order.
162    pub fn dynamic_tools(self, tools: Vec<DynamicTool>) -> Self {
163        tools.into_iter().fold(self, Self::dynamic_tool)
164    }
165
166    /// Add a context-free dynamic tool through the classic registry adapter.
167    pub fn portable_dynamic_tool(mut self, tool: PortableDynamicTool) -> Self {
168        self.toolset.add_portable_dynamic_tool(tool);
169        self
170    }
171
172    /// Add an MCP tool (from `rmcp`) to the agent, bounded by
173    /// [`DEFAULT_MCP_TOOL_TIMEOUT`](crate::tool::rmcp::DEFAULT_MCP_TOOL_TIMEOUT)
174    /// (see issue #1914). Use [`rmcp_tool_with_timeout`](Self::rmcp_tool_with_timeout)
175    /// to change or disable it.
176    #[cfg_attr(docsrs, doc(cfg(feature = "rmcp")))]
177    #[cfg(all(feature = "rmcp", not(target_family = "wasm")))]
178    pub fn rmcp_tool(self, tool: rmcp::model::Tool, client: rmcp::service::ServerSink) -> Self {
179        self.rmcp_tool_with_timeout(tool, client, crate::tool::rmcp::DEFAULT_MCP_TOOL_TIMEOUT)
180    }
181
182    /// Add an MCP tool (from `rmcp`) with a per-call timeout (see issue #1914).
183    ///
184    /// Pass a [`Duration`](std::time::Duration) to bound the call, or `None` to
185    /// disable the timeout (unbounded).
186    #[cfg_attr(docsrs, doc(cfg(feature = "rmcp")))]
187    #[cfg(all(feature = "rmcp", not(target_family = "wasm")))]
188    pub fn rmcp_tool_with_timeout(
189        mut self,
190        tool: rmcp::model::Tool,
191        client: rmcp::service::ServerSink,
192        timeout: impl Into<Option<std::time::Duration>>,
193    ) -> Self {
194        use crate::tool::rmcp::McpTool;
195        self.toolset.add_erased(Arc::new(
196            McpTool::from_mcp_server(tool, client).with_timeout(timeout),
197        ));
198        self
199    }
200
201    /// Add several MCP tools (from `rmcp`) sharing one client, each bounded by
202    /// [`DEFAULT_MCP_TOOL_TIMEOUT`](crate::tool::rmcp::DEFAULT_MCP_TOOL_TIMEOUT)
203    /// (see issue #1914). Use [`rmcp_tools_with_timeout`](Self::rmcp_tools_with_timeout)
204    /// to change or disable it.
205    #[cfg_attr(docsrs, doc(cfg(feature = "rmcp")))]
206    #[cfg(all(feature = "rmcp", not(target_family = "wasm")))]
207    pub fn rmcp_tools(
208        self,
209        tools: Vec<rmcp::model::Tool>,
210        client: rmcp::service::ServerSink,
211    ) -> Self {
212        self.rmcp_tools_with_timeout(tools, client, crate::tool::rmcp::DEFAULT_MCP_TOOL_TIMEOUT)
213    }
214
215    /// Add several MCP tools (from `rmcp`) sharing one client, each with the same
216    /// per-call timeout (see issue #1914).
217    ///
218    /// Pass a [`Duration`](std::time::Duration) to bound the calls, or `None` to
219    /// disable the timeout (unbounded).
220    #[cfg_attr(docsrs, doc(cfg(feature = "rmcp")))]
221    #[cfg(all(feature = "rmcp", not(target_family = "wasm")))]
222    pub fn rmcp_tools_with_timeout(
223        self,
224        tools: Vec<rmcp::model::Tool>,
225        client: rmcp::service::ServerSink,
226        timeout: impl Into<Option<std::time::Duration>>,
227    ) -> Self {
228        let timeout = timeout.into();
229        tools.into_iter().fold(self, |server, tool| {
230            server.rmcp_tool_with_timeout(tool, client.clone(), timeout)
231        })
232    }
233
234    /// Configure tools retrieved from a vector index for each prompt.
235    pub fn retrieved_tools(
236        mut self,
237        sample: usize,
238        index: impl VectorStoreIndexDyn + Send + Sync + 'static,
239        toolset: ToolSet,
240    ) -> Self {
241        self.retrieval_indexes.push((sample, Arc::new(index)));
242        self.toolset.add_retrievable_tools(toolset);
243        self
244    }
245
246    /// Consume the builder and return a shared [`ToolServerHandle`].
247    pub fn run(self) -> ToolServerHandle {
248        ToolServerHandle(Arc::new(RwLock::new(ToolServerState {
249            retrieval_indexes: self.retrieval_indexes,
250            toolset: self.toolset,
251            #[cfg(all(feature = "rmcp", not(target_family = "wasm")))]
252            managed_generations: HashMap::new(),
253        })))
254    }
255}
256
257/// A cheaply-cloneable handle to the shared tool server state.
258///
259/// All operations acquire locks directly on the underlying state.
260/// Multiple handles (e.g. across agents) can share the same state
261/// without channel-based message routing.
262#[derive(Clone)]
263pub struct ToolServerHandle(Arc<RwLock<ToolServerState>>);
264
265impl ToolServerHandle {
266    /// Register through `add`, then drop any stale MCP managed-generation
267    /// entry so the (re)registered name follows last-registration-wins.
268    async fn register(&self, add: impl FnOnce(&mut ToolSet) -> String) {
269        let mut state = self.0.write().await;
270        let _name = add(&mut state.toolset);
271        #[cfg(all(feature = "rmcp", not(target_family = "wasm")))]
272        state.managed_generations.remove(&_name);
273    }
274
275    /// Register a new static tool. Re-registering an existing name replaces
276    /// the implementation (last wins) and keeps its position.
277    pub async fn add_tool<T>(&self, tool: T)
278    where
279        T: Tool + 'static,
280    {
281        self.register(|toolset| toolset.add_tool(tool)).await
282    }
283
284    /// Register a runtime-defined static tool.
285    pub async fn add_dynamic_tool(&self, tool: DynamicTool) {
286        self.register(|toolset| toolset.add_dynamic_tool(tool))
287            .await
288    }
289
290    /// Register a context-free dynamic tool through the classic adapter.
291    pub async fn add_portable_dynamic_tool(&self, tool: PortableDynamicTool) {
292        self.register(|toolset| toolset.add_portable_dynamic_tool(tool))
293            .await
294    }
295
296    /// Atomically install the initial tools owned by one MCP handler.
297    /// Initial connection retains the registry's last-registration-wins policy.
298    #[cfg(all(feature = "rmcp", not(target_family = "wasm")))]
299    pub(crate) async fn add_managed_erased_tools(
300        &self,
301        tools: Vec<Arc<dyn ErasedTool>>,
302    ) -> HashMap<String, ManagedToolToken> {
303        let mut state = self.0.write().await;
304        let mut managed = HashMap::with_capacity(tools.len());
305
306        for tool in tools {
307            // The initial list fetch can complete just before the transport
308            // closes. Avoid installing a registration that can never execute.
309            if !tool.is_live() {
310                tracing::debug!(
311                    tool_name = %tool.name(),
312                    "ignored initial registration from disconnected MCP owner"
313                );
314                continue;
315            }
316
317            let name = state.toolset.add_erased(tool);
318            let token = ManagedToolToken::new();
319            state
320                .managed_generations
321                .insert(name.clone(), token.clone());
322            managed.insert(name, token);
323        }
324
325        managed
326    }
327
328    /// Atomically reconcile one handler's MCP registrations with a refreshed
329    /// tool list. Existing names are changed only when their expected generation
330    /// remains current; newer local or peer-handler registrations win.
331    #[cfg(all(feature = "rmcp", not(target_family = "wasm")))]
332    pub(crate) async fn reconcile_managed_erased_tools(
333        &self,
334        mut expected: HashMap<String, ManagedToolToken>,
335        tools: Vec<Arc<dyn ErasedTool>>,
336    ) -> HashMap<String, ManagedToolToken> {
337        let mut state = self.0.write().await;
338        let mut refreshed = HashMap::with_capacity(tools.len());
339        let mut managed_order = Vec::with_capacity(tools.len());
340        let mut seen = std::collections::HashSet::with_capacity(tools.len());
341
342        // A generation only protects a live owner. MCP service shutdown closes
343        // the sink held by its registered tools, so retire those generations
344        // before deciding whether another handler may reclaim a name. Local
345        // in-process registrations stay live; directly registered MCP tools are
346        // also retired when their sink closes.
347        state.retire_disconnected_tools();
348
349        for tool in tools {
350            // A refresh that raced with service shutdown may already have
351            // fetched definitions before the transport closed. Do not let
352            // that stale refresh recreate an owner we just retired.
353            if !tool.is_live() {
354                tracing::debug!(
355                    tool_name = %tool.name(),
356                    "ignored registration from disconnected MCP owner"
357                );
358                continue;
359            }
360
361            let name = tool.name();
362            if !seen.insert(name.clone()) {
363                tracing::warn!(tool_name = %name, "ignoring duplicate MCP tool definition");
364                continue;
365            }
366            let present = state.toolset.contains(&name);
367            let may_register = match expected.remove(&name) {
368                Some(token) if present => state.managed_generations.get(&name) == Some(&token),
369                // A stale expected token protects a live newer registration,
370                // not an empty slot. Once the competitor disappears, this full
371                // server snapshot must converge in one reconciliation.
372                Some(_) => true,
373                None => !present,
374            };
375
376            if may_register {
377                state.toolset.add_erased(tool);
378                let token = ManagedToolToken::new();
379                state
380                    .managed_generations
381                    .insert(name.clone(), token.clone());
382                refreshed.insert(name.clone(), token);
383                managed_order.push(name);
384            } else {
385                tracing::debug!(
386                    tool_name = name,
387                    "MCP refresh left a newer same-name registration intact"
388                );
389            }
390        }
391
392        for (name, token) in expected {
393            if state.managed_generations.get(&name) == Some(&token) {
394                state.toolset.delete_tool(&name);
395                state.managed_generations.remove(&name);
396            }
397        }
398
399        // A full MCP list is ordered. Move only entries this handler actually
400        // owns to the end in that order, matching remove/re-register semantics;
401        // live local or peer-handler competitors retain their relative slots.
402        let mut ordered_entries = Vec::with_capacity(managed_order.len());
403        for name in managed_order {
404            if let Some(entry) = state.toolset.tools.shift_remove_entry(&name) {
405                ordered_entries.push(entry);
406            }
407        }
408        for (name, registration) in ordered_entries {
409            state.toolset.tools.insert(name, registration);
410        }
411
412        refreshed
413    }
414
415    /// Merge an entire toolset into the server in registration order.
416    /// Existing names are replaced (last wins) and keep their position.
417    pub async fn append_toolset(&self, toolset: ToolSet) {
418        let mut state = self.0.write().await;
419        #[cfg(all(feature = "rmcp", not(target_family = "wasm")))]
420        let names = toolset.tools.keys().cloned().collect::<Vec<_>>();
421        state.toolset.add_tools(toolset);
422        #[cfg(all(feature = "rmcp", not(target_family = "wasm")))]
423        for name in names {
424            state.managed_generations.remove(&name);
425        }
426    }
427
428    /// Remove a tool by name.
429    pub async fn remove_tool(&self, tool_name: &str) {
430        let mut state = self.0.write().await;
431        state.toolset.delete_tool(tool_name);
432        #[cfg(all(feature = "rmcp", not(target_family = "wasm")))]
433        state.managed_generations.remove(tool_name);
434    }
435
436    /// Look up and execute a tool through the canonical structured path.
437    ///
438    /// The implementation handle is cloned under a brief read lock, so a long
439    /// execution never blocks registration changes. The tool receives one
440    /// snapshot of the supplied inbound values. Its result metadata is
441    /// published back to `context`, while mutations to its inbound snapshot are
442    /// discarded.
443    pub async fn execute(
444        &self,
445        tool_name: &str,
446        args: &str,
447        context: &mut ToolContext,
448    ) -> ToolResult {
449        context.clear_dispatch_result();
450        let dispatch = self.dispatch(tool_name, args, context).await;
451        dispatch.publish_to(context)
452    }
453
454    /// Run `f` against the registry state, first retiring disconnected MCP
455    /// tools (which needs a write lock) when that feature is compiled in.
456    async fn with_registry<R>(&self, f: impl FnOnce(&ToolServerState) -> R) -> R {
457        #[cfg(all(feature = "rmcp", not(target_family = "wasm")))]
458        {
459            let mut state = self.0.write().await;
460            state.retire_disconnected_tools();
461            f(&state)
462        }
463        #[cfg(not(all(feature = "rmcp", not(target_family = "wasm"))))]
464        {
465            let state = self.0.read().await;
466            f(&state)
467        }
468    }
469
470    /// Run one isolated dispatch and retain its full context for agent hooks.
471    pub(crate) async fn dispatch(
472        &self,
473        tool_name: &str,
474        args: &str,
475        context: &ToolContext,
476    ) -> ToolDispatch {
477        let tool = self
478            .with_registry(|state| state.toolset.get(tool_name).cloned())
479            .await;
480        dispatch_tool(tool_name, args.to_string(), tool, context).await
481    }
482
483    /// Retrieve tool definitions, optionally using a prompt to select
484    /// dynamic tools from configured vector stores.
485    pub async fn get_tool_defs(
486        &self,
487        prompt: Option<String>,
488    ) -> Result<Vec<ToolDefinition>, ToolServerError> {
489        Ok(self.snapshot_tool_defs(prompt).await?.definitions.clone())
490    }
491
492    /// Resolve one ordered provider/dispatch snapshot for an agent turn.
493    ///
494    /// Retrieval runs without holding the registry lock. Once the selected IDs
495    /// are known, one read lock resolves every dynamic and always-exposed name
496    /// to an exact implementation. That single instant is the turn boundary:
497    /// later replacements are visible only to the next snapshot.
498    pub(crate) async fn snapshot_tool_defs(
499        &self,
500        prompt: Option<String>,
501    ) -> Result<ToolRegistrySnapshot, ToolServerError> {
502        let retrieval_indexes = {
503            let state = self.0.read().await;
504            state.retrieval_indexes.clone()
505        };
506
507        let dynamic_tool_ids = if let Some(ref text) = prompt {
508            // Create a future for each dynamic tool index
509            let search_futures = retrieval_indexes.iter().map(|(num_sample, index)| {
510                let text = text.clone();
511                let num_sample = *num_sample;
512                let index = index.clone();
513
514                async move {
515                    let req = VectorSearchRequest::builder()
516                        .query(text)
517                        .samples(num_sample as u64)
518                        .build();
519
520                    let ids = index
521                        .as_ref()
522                        .top_n_ids(req.map_filter(Filter::interpret))
523                        .await?
524                        .into_iter()
525                        .map(|(_, id)| id)
526                        .collect::<Vec<String>>();
527
528                    Ok::<_, VectorStoreError>(ids)
529                }
530            });
531
532            // Execute searches concurrently and collect/flatten the IDs
533            futures::future::try_join_all(search_futures)
534                .await
535                .map_err(|e| {
536                    ToolServerError::DefinitionError(CompletionError::RequestError(Box::new(e)))
537                })?
538                .into_iter()
539                .flatten()
540                .collect::<Vec<String>>()
541        } else {
542            Vec::new()
543        };
544
545        let tools = self
546            .with_registry(|state| snapshot_registered_tools(state, dynamic_tool_ids))
547            .await;
548
549        Ok(ToolRegistrySnapshot::new(tools))
550    }
551}
552
553fn snapshot_registered_tools(
554    state: &ToolServerState,
555    dynamic_tool_ids: Vec<String>,
556) -> IndexMap<String, RegisteredTool> {
557    let mut tools = IndexMap::new();
558    let insert = |tools: &mut IndexMap<String, RegisteredTool>, name: &str, warn_missing| {
559        if tools.contains_key(name) {
560            tracing::debug!(
561                tool_name = %name,
562                "dropping duplicate tool definition from the request"
563            );
564            return;
565        }
566        match state.toolset.get(name).cloned() {
567            Some(tool) => {
568                tools.insert(name.to_string(), tool);
569            }
570            // A dynamic ID the model asked for but the toolset lacks is worth
571            // an operator warning; a retired always-exposed tool is not.
572            None if warn_missing => {
573                tracing::warn!("Tool implementation not found in toolset: {name}");
574            }
575            None => {}
576        }
577    };
578
579    // Retrieved tools remain first, in index/result order. Duplicate IDs and
580    // dynamic/static overlap retain the first provider declaration.
581    for name in &dynamic_tool_ids {
582        insert(&mut tools, name, true);
583    }
584    for name in state.toolset.always_exposed_names() {
585        insert(&mut tools, name, false);
586    }
587    tools
588}
589
590#[derive(Debug, thiserror::Error)]
591pub enum ToolServerError {
592    #[error("Failed to retrieve tool definitions: {0}")]
593    DefinitionError(CompletionError),
594}
595#[cfg(test)]
596mod tests {
597    use std::{
598        future::{Future, pending, poll_fn},
599        sync::{
600            Arc,
601            atomic::{AtomicBool, AtomicUsize, Ordering},
602        },
603        task::Poll,
604        time::Duration,
605    };
606
607    use crate::{
608        test_utils::{
609            BarrierMockToolIndex, MockAddTool, MockBarrierTool, MockControlledTool,
610            MockSubtractTool, MockToolIndex,
611        },
612        tool::{
613            Tool, ToolContext, ToolEmbedding, ToolExecutionError, ToolSet,
614            server::{ToolServer, ToolServerHandle},
615        },
616    };
617
618    async fn execute_tool(
619        handle: &ToolServerHandle,
620        name: &str,
621        args: &str,
622    ) -> Result<String, ToolExecutionError> {
623        execute_tool_with_context(handle, name, args, &mut ToolContext::new()).await
624    }
625
626    async fn execute_tool_with_context(
627        handle: &ToolServerHandle,
628        name: &str,
629        args: &str,
630        context: &mut ToolContext,
631    ) -> Result<String, ToolExecutionError> {
632        let result = handle.execute(name, args, context).await;
633        match result.error() {
634            Some(error) => Err(error.clone()),
635            None => Ok(result.output().render()),
636        }
637    }
638
639    struct NamedTool;
640
641    impl NamedTool {
642        fn new() -> Self {
643            Self
644        }
645    }
646
647    impl Tool for NamedTool {
648        const NAME: &'static str = "registered_named";
649        type Error = rig::tool::ToolExecutionError;
650        type Args = serde_json::Value;
651        type Output = String;
652
653        fn description(&self) -> String {
654            "uses its canonical name".to_string()
655        }
656
657        fn parameters(&self) -> serde_json::Value {
658            serde_json::json!({"type": "object", "properties": {}})
659        }
660
661        async fn call(
662            &self,
663            _context: &mut crate::tool::ToolContext,
664            _args: Self::Args,
665        ) -> Result<Self::Output, crate::tool::ToolExecutionError> {
666            Ok("ok".to_string())
667        }
668    }
669
670    struct ReplacementTool {
671        description: &'static str,
672        output: &'static str,
673    }
674
675    impl Tool for ReplacementTool {
676        const NAME: &'static str = "replacement";
677        type Error = rig::tool::ToolExecutionError;
678        type Args = serde_json::Value;
679        type Output = String;
680
681        fn description(&self) -> String {
682            self.description.to_string()
683        }
684
685        fn parameters(&self) -> serde_json::Value {
686            serde_json::json!({"type": "object", "properties": {}})
687        }
688
689        async fn call(
690            &self,
691            _context: &mut ToolContext,
692            _args: Self::Args,
693        ) -> Result<Self::Output, ToolExecutionError> {
694            Ok(self.output.to_string())
695        }
696    }
697
698    #[derive(Debug, thiserror::Error)]
699    #[error("init error")]
700    struct InitError;
701
702    impl ToolEmbedding for NamedTool {
703        type InitError = InitError;
704        type Context = ();
705        type State = ();
706
707        fn embedding_docs(&self) -> Vec<String> {
708            vec!["named retrieved tool".to_string()]
709        }
710
711        fn context(&self) -> Self::Context {}
712
713        fn init(_state: Self::State, _context: Self::Context) -> Result<Self, Self::InitError> {
714            Ok(Self::new())
715        }
716    }
717
718    #[tokio::test]
719    pub async fn test_toolserver() {
720        let server = ToolServer::new();
721
722        let handle = server.run();
723
724        handle.add_tool(MockAddTool).await;
725        let res = handle.get_tool_defs(None).await.unwrap();
726
727        assert_eq!(res.len(), 1);
728
729        let json_args_as_string =
730            serde_json::to_string(&serde_json::json!({"x": 2, "y": 5})).unwrap();
731        let res = execute_tool(&handle, "add", &json_args_as_string)
732            .await
733            .unwrap();
734        assert_eq!(res, "7");
735
736        handle.remove_tool("add").await;
737        let res = handle.get_tool_defs(None).await.unwrap();
738
739        assert_eq!(res.len(), 0);
740    }
741
742    #[tokio::test]
743    async fn definition_snapshot_pins_the_exact_tool_registration() {
744        let handle = ToolServer::new()
745            .tool(ReplacementTool {
746                description: "first schema",
747                output: "first implementation",
748            })
749            .run();
750        let snapshot = handle.snapshot_tool_defs(None).await.unwrap();
751
752        handle
753            .add_tool(ReplacementTool {
754                description: "second schema",
755                output: "second implementation",
756            })
757            .await;
758
759        assert_eq!(snapshot.definitions()[0].description, "first schema");
760        let dispatch = snapshot
761            .dispatch(ReplacementTool::NAME, "{}", &ToolContext::new())
762            .await;
763        assert_eq!(dispatch.result.output().render(), "first implementation");
764
765        let live = handle
766            .dispatch(ReplacementTool::NAME, "{}", &ToolContext::new())
767            .await;
768        assert_eq!(live.result.output().render(), "second implementation");
769
770        let next_snapshot = handle.snapshot_tool_defs(None).await.unwrap();
771        assert_eq!(next_snapshot.definitions()[0].description, "second schema");
772        let dispatch = next_snapshot
773            .dispatch(ReplacementTool::NAME, "{}", &ToolContext::new())
774            .await;
775        assert_eq!(dispatch.result.output().render(), "second implementation");
776    }
777
778    #[tokio::test]
779    pub async fn test_toolserver_append_toolset_matches_add_tool() {
780        let mut via_add_tool = {
781            let handle = ToolServer::new().run();
782            handle.add_tool(MockAddTool).await;
783            handle.add_tool(MockSubtractTool).await;
784            handle.get_tool_defs(None).await.unwrap()
785        };
786        via_add_tool.sort_by(|a, b| a.name.cmp(&b.name));
787
788        let mut via_append_toolset = {
789            let handle = ToolServer::new().run();
790            let mut toolset = ToolSet::default();
791            toolset.add_tool(MockAddTool);
792            toolset.add_tool(MockSubtractTool);
793            handle.append_toolset(toolset).await;
794            handle.get_tool_defs(None).await.unwrap()
795        };
796        via_append_toolset.sort_by(|a, b| a.name.cmp(&b.name));
797
798        assert_eq!(via_add_tool.len(), via_append_toolset.len());
799        assert!(
800            via_add_tool
801                .iter()
802                .zip(via_append_toolset.iter())
803                .all(|(a, b)| a.name == b.name),
804            "append_toolset must surface the same LLM-visible tools as add_tool",
805        );
806    }
807
808    #[tokio::test]
809    pub async fn builder_tool_uses_canonical_static_name() {
810        let handle = ToolServer::new().tool(NamedTool::new()).run();
811
812        let defs = handle.get_tool_defs(None).await.unwrap();
813        assert_eq!(defs.len(), 1);
814        assert_eq!(defs[0].name, NamedTool::NAME);
815    }
816
817    #[tokio::test]
818    pub async fn handle_add_tool_uses_canonical_static_name() {
819        let handle = ToolServer::new().run();
820        handle.add_tool(NamedTool::new()).await;
821
822        let defs = handle.get_tool_defs(None).await.unwrap();
823        assert_eq!(defs.len(), 1);
824        assert_eq!(defs[0].name, NamedTool::NAME);
825    }
826
827    #[tokio::test]
828    pub async fn retrieval_resolves_canonical_key() {
829        let mut toolset = ToolSet::default();
830        toolset.add_retrieved_tool(NamedTool::new());
831        let handle = ToolServer::new()
832            .retrieved_tools(1, MockToolIndex::new([NamedTool::NAME]), toolset)
833            .run();
834
835        let defs = handle
836            .get_tool_defs(Some("use the changing tool".to_string()))
837            .await
838            .unwrap();
839        assert_eq!(defs.len(), 1);
840        assert_eq!(defs[0].name, NamedTool::NAME);
841    }
842
843    #[tokio::test]
844    pub async fn get_tool_defs_preserves_static_registration_order() {
845        let handle = ToolServer::new().run();
846        handle.add_tool(MockSubtractTool).await;
847        handle.add_tool(MockAddTool).await;
848
849        let defs = handle.get_tool_defs(None).await.unwrap();
850        assert_eq!(
851            defs.iter().map(|def| def.name.as_str()).collect::<Vec<_>>(),
852            vec!["subtract", "add"]
853        );
854    }
855
856    #[tokio::test]
857    pub async fn get_tool_defs_dedupes_dynamic_and_static_overlap() {
858        // One shared toolset backs both lists, so a dynamically retrieved
859        // name that is also static must yield a single definition.
860        let handle = ToolServer::new()
861            .tool(MockAddTool)
862            .retrieved_tools(1, MockToolIndex::new(["add"]), ToolSet::default())
863            .run();
864
865        let defs = handle
866            .get_tool_defs(Some("add two numbers".to_string()))
867            .await
868            .unwrap();
869        assert_eq!(
870            defs.len(),
871            1,
872            "dynamic/static name overlap must not produce duplicate declarations: {:?}",
873            defs.iter().map(|def| def.name.as_str()).collect::<Vec<_>>()
874        );
875        assert_eq!(defs[0].name, "add");
876    }
877
878    #[tokio::test]
879    async fn retrieval_registration_preserves_existing_always_exposure() {
880        let handle = ToolServer::new()
881            .tool(MockAddTool)
882            .retrieved_tools(
883                1,
884                MockToolIndex::new(["add"]),
885                ToolSet::from_tools(vec![MockAddTool]),
886            )
887            .run();
888
889        let defs = handle.get_tool_defs(None).await.unwrap();
890        assert_eq!(
891            defs.iter()
892                .map(|definition| definition.name.as_str())
893                .collect::<Vec<_>>(),
894            vec!["add"],
895            "merging a retrieval implementation must not demote an always-exposed registration"
896        );
897    }
898
899    #[tokio::test]
900    pub async fn duplicate_registration_advertises_one_definition() {
901        let handle = ToolServer::new().tool(MockAddTool).run();
902        handle.add_tool(MockAddTool).await;
903
904        let mut toolset = ToolSet::default();
905        toolset.add_tool(MockAddTool);
906        handle.append_toolset(toolset).await;
907
908        let defs = handle.get_tool_defs(None).await.unwrap();
909        assert_eq!(
910            defs.len(),
911            1,
912            "re-registering a name must not advertise duplicate declarations"
913        );
914        assert_eq!(defs[0].name, "add");
915    }
916
917    #[tokio::test]
918    pub async fn test_toolserver_retrieved_tools() {
919        // Create a toolset with both tools
920        let mut toolset = ToolSet::default();
921        toolset.add_tool(MockAddTool);
922        toolset.add_tool(MockSubtractTool);
923
924        // Create a mock index that will return "subtract" as the dynamic tool
925        let mock_index = MockToolIndex::new(["subtract"]);
926
927        // Build server with static tool "add" and dynamic tools from the mock index
928        let server = ToolServer::new().tool(MockAddTool).retrieved_tools(
929            1,
930            mock_index,
931            ToolSet::from_tools(vec![MockSubtractTool]),
932        );
933
934        let handle = server.run();
935
936        // Test with None prompt - should only return static tools
937        let res = handle.get_tool_defs(None).await.unwrap();
938        assert_eq!(res.len(), 1);
939        assert_eq!(res[0].name, "add");
940
941        // Test with Some prompt - should return both static and dynamic tools
942        let res = handle
943            .get_tool_defs(Some("calculate difference".to_string()))
944            .await
945            .unwrap();
946        assert_eq!(res.len(), 2);
947
948        // Check that both tools are present (order may vary)
949        let tool_names: Vec<&str> = res.iter().map(|t| t.name.as_str()).collect();
950        assert!(tool_names.contains(&"add"));
951        assert!(tool_names.contains(&"subtract"));
952    }
953
954    #[tokio::test]
955    pub async fn test_toolserver_retrieved_tools_missing_implementation() {
956        // Create a mock index that returns a tool ID that doesn't exist in the toolset
957        let mock_index = MockToolIndex::new(["nonexistent_tool"]);
958
959        // Build server with only static tool, but dynamic index references missing tool
960        let server =
961            ToolServer::new()
962                .tool(MockAddTool)
963                .retrieved_tools(1, mock_index, ToolSet::default());
964
965        let handle = server.run();
966
967        // Test with Some prompt - should only return static tool since dynamic tool is missing
968        let res = handle
969            .get_tool_defs(Some("some query".to_string()))
970            .await
971            .unwrap();
972        assert_eq!(res.len(), 1);
973        assert_eq!(res[0].name, "add");
974    }
975
976    #[tokio::test]
977    pub async fn test_toolserver_concurrent_tool_execution() {
978        let num_calls = 3;
979        let barrier = Arc::new(tokio::sync::Barrier::new(num_calls));
980
981        let server = ToolServer::new().tool(MockBarrierTool::new(barrier.clone()));
982        let handle = server.run();
983
984        // Make concurrent calls
985        let futures: Vec<_> = (0..num_calls)
986            .map(|_| execute_tool(&handle, "barrier_tool", "{}"))
987            .collect();
988
989        // If execution is sequential, the first call will block at the barrier forever.
990        // We use a 1-second timeout to fail fast instead of hanging the test runner.
991        let result =
992            tokio::time::timeout(Duration::from_secs(1), futures::future::join_all(futures)).await;
993
994        assert!(
995            result.is_ok(),
996            "Tool execution deadlocked! Tools are executing sequentially instead of concurrently."
997        );
998
999        // All calls should succeed
1000        for res in result.unwrap() {
1001            assert!(res.is_ok(), "Tool call failed: {:?}", res);
1002            assert_eq!(res.unwrap(), "done");
1003        }
1004    }
1005
1006    #[tokio::test]
1007    pub async fn test_toolserver_write_while_tool_running() {
1008        let started = Arc::new(tokio::sync::Notify::new());
1009        let allow_finish = Arc::new(tokio::sync::Notify::new());
1010
1011        // Build server with the controlled tool that waits at a barrier during execution
1012        let tool = MockControlledTool::new(started.clone(), allow_finish.clone());
1013
1014        let server = ToolServer::new().tool(tool);
1015        let handle = server.run();
1016
1017        // Start tool call in background
1018        let handle_clone = handle.clone();
1019        let call_task =
1020            tokio::spawn(async move { execute_tool(&handle_clone, "controlled", "{}").await });
1021
1022        // Wait until we are strictly inside `call()`
1023        started.notified().await;
1024
1025        // Try to write to the state (add a tool) while the tool call is mid-execution.
1026        // If the read lock is incorrectly held across tool execution, this will deadlock.
1027        let add_result =
1028            tokio::time::timeout(Duration::from_secs(1), handle.add_tool(MockAddTool)).await;
1029
1030        assert!(
1031            add_result.is_ok(),
1032            "Writing to ToolServer deadlocked! The read lock is being held across tool execution."
1033        );
1034
1035        // Allow the background tool to finish and clean up
1036        allow_finish.notify_one();
1037        let call_result = call_task.await.unwrap();
1038        assert_eq!(call_result.unwrap(), "42");
1039    }
1040
1041    #[tokio::test]
1042    pub async fn test_toolserver_parallel_retrieval() {
1043        // We expect exactly 2 parallel searches to hit the barrier at the same time
1044        let barrier = Arc::new(tokio::sync::Barrier::new(2));
1045
1046        let index1 = BarrierMockToolIndex::new(barrier.clone(), "add");
1047        let index2 = BarrierMockToolIndex::new(barrier.clone(), "subtract");
1048
1049        // Put both tools in the toolset so they resolve correctly
1050        let mut toolset = ToolSet::default();
1051        toolset.add_tool(MockAddTool);
1052        toolset.add_tool(MockSubtractTool);
1053
1054        let server = ToolServer::new()
1055            .retrieved_tools(1, index1, ToolSet::default())
1056            .retrieved_tools(1, index2, toolset);
1057
1058        let handle = server.run();
1059
1060        // This will trigger a search across both indices.
1061        // If fetched sequentially, the first index will wait at the barrier forever.
1062        let get_defs = tokio::time::timeout(
1063            std::time::Duration::from_secs(1),
1064            handle.get_tool_defs(Some("do math".to_string())),
1065        )
1066        .await;
1067
1068        assert!(
1069            get_defs.is_ok(),
1070            "Dynamic tools were fetched sequentially! The first query deadlocked waiting for the second query to start."
1071        );
1072
1073        let defs = get_defs.unwrap().unwrap();
1074        assert_eq!(defs.len(), 2);
1075
1076        let tool_names: Vec<&str> = defs.iter().map(|t| t.name.as_str()).collect();
1077        assert!(tool_names.contains(&"add"));
1078        assert!(tool_names.contains(&"subtract"));
1079    }
1080
1081    #[derive(Clone)]
1082    struct SessionId(String);
1083
1084    struct CloneTrackedContext {
1085        clones: Arc<AtomicUsize>,
1086        value: usize,
1087    }
1088
1089    impl Clone for CloneTrackedContext {
1090        fn clone(&self) -> Self {
1091            self.clones.fetch_add(1, Ordering::SeqCst);
1092            Self {
1093                clones: self.clones.clone(),
1094                value: self.value,
1095            }
1096        }
1097    }
1098
1099    #[derive(serde::Deserialize, serde::Serialize)]
1100    struct ContextReader;
1101
1102    impl crate::tool::Tool for ContextReader {
1103        const NAME: &'static str = "context_reader";
1104        type Error = rig::tool::ToolExecutionError;
1105        type Args = serde_json::Value;
1106        type Output = String;
1107
1108        fn description(&self) -> String {
1109            "Reads SessionId from context".to_string()
1110        }
1111
1112        fn parameters(&self) -> serde_json::Value {
1113            serde_json::json!({"type": "object", "properties": {}})
1114        }
1115
1116        async fn call(
1117            &self,
1118            context: &mut ToolContext,
1119            _args: Self::Args,
1120        ) -> Result<Self::Output, ToolExecutionError> {
1121            if let Some(value) = context.get_mut::<CloneTrackedContext>() {
1122                value.value += 1;
1123                let result_value = value.value;
1124                context.insert_result(result_value);
1125            }
1126            Ok(context
1127                .get::<SessionId>()
1128                .map(|session| format!("session:{}", session.0))
1129                .unwrap_or_else(|| "no session".to_string()))
1130        }
1131    }
1132
1133    #[tokio::test]
1134    async fn context_reaches_the_single_execute_path() {
1135        let handle = ToolServer::new().tool(ContextReader).run();
1136        let mut context = ToolContext::new();
1137        context.insert(SessionId("abc-123".to_string()));
1138        let result = execute_tool_with_context(&handle, "context_reader", "{}", &mut context)
1139            .await
1140            .unwrap();
1141        assert_eq!(result, "session:abc-123");
1142    }
1143
1144    #[tokio::test]
1145    async fn server_dispatch_snapshot_clones_once_and_only_publishes_result_metadata() {
1146        let handle = ToolServer::new().tool(ContextReader).run();
1147        let clones = Arc::new(AtomicUsize::new(0));
1148        let mut context = ToolContext::new();
1149        context.insert(CloneTrackedContext {
1150            clones: clones.clone(),
1151            value: 0,
1152        });
1153
1154        let result = execute_tool_with_context(&handle, "context_reader", "{}", &mut context)
1155            .await
1156            .unwrap();
1157
1158        assert_eq!(result, "no session");
1159        assert_eq!(clones.load(Ordering::SeqCst), 1);
1160        assert_eq!(
1161            context
1162                .get::<CloneTrackedContext>()
1163                .map(|value| value.value),
1164            Some(0),
1165            "tool-local inbound mutations must not change the caller's context"
1166        );
1167        assert_eq!(context.result::<usize>(), Some(&1));
1168    }
1169
1170    struct PendingTool(Arc<AtomicBool>);
1171
1172    impl Tool for PendingTool {
1173        const NAME: &'static str = "pending";
1174        type Error = rig::tool::ToolExecutionError;
1175        type Args = ();
1176        type Output = ();
1177
1178        fn description(&self) -> String {
1179            "never completes".into()
1180        }
1181
1182        fn parameters(&self) -> serde_json::Value {
1183            serde_json::json!({"type": "object"})
1184        }
1185
1186        async fn call(
1187            &self,
1188            context: &mut ToolContext,
1189            _args: Self::Args,
1190        ) -> Result<Self::Output, ToolExecutionError> {
1191            context.insert_result("unpublished".to_string());
1192            self.0.store(true, Ordering::SeqCst);
1193            pending().await
1194        }
1195    }
1196
1197    #[tokio::test]
1198    async fn cancelled_server_dispatch_does_not_retain_stale_result_metadata() {
1199        let started = Arc::new(AtomicBool::new(false));
1200        let handle = ToolServer::new().tool(PendingTool(started.clone())).run();
1201        let mut context = ToolContext::new();
1202        context.insert_result("stale".to_string());
1203
1204        let mut execution = Box::pin(handle.execute(PendingTool::NAME, "null", &mut context));
1205        tokio::time::timeout(
1206            Duration::from_secs(1),
1207            poll_fn(|cx| {
1208                assert!(execution.as_mut().poll(cx).is_pending());
1209                started.load(Ordering::SeqCst).then_some(()).map_or_else(
1210                    || {
1211                        cx.waker().wake_by_ref();
1212                        Poll::Pending
1213                    },
1214                    Poll::Ready,
1215                )
1216            }),
1217        )
1218        .await
1219        .expect("pending tool did not start");
1220        drop(execution);
1221
1222        assert!(context.result::<String>().is_none());
1223    }
1224
1225    #[tokio::test]
1226    async fn empty_tool_context_uses_default() {
1227        let handle = ToolServer::new().tool(ContextReader).run();
1228        let result = execute_tool(&handle, "context_reader", "{}").await.unwrap();
1229
1230        assert_eq!(result, "no session");
1231    }
1232
1233    #[tokio::test]
1234    async fn tool_ignoring_context_still_works() {
1235        let handle = ToolServer::new().tool(MockAddTool).run();
1236        let mut context = ToolContext::new();
1237        context.insert(SessionId("ignored".to_string()));
1238        let args = serde_json::to_string(&serde_json::json!({"x": 3, "y": 7})).unwrap();
1239        let result = execute_tool_with_context(&handle, "add", &args, &mut context)
1240            .await
1241            .unwrap();
1242
1243        assert_eq!(result, "10");
1244    }
1245
1246    #[tokio::test]
1247    async fn execute_classifies_a_missing_tool_as_not_found() {
1248        let handle = ToolServer::new().tool(MockAddTool).run();
1249        let error = execute_tool(&handle, "does_not_exist", "{}")
1250            .await
1251            .unwrap_err();
1252        assert_eq!(error.kind(), crate::tool::ToolErrorKind::NotFound);
1253        assert!(
1254            error
1255                .model_feedback()
1256                .is_some_and(|feedback| feedback.contains("does_not_exist"))
1257        );
1258    }
1259}