Skip to main content

agent_framework_core/
tools.rs

1//! Tools: executable functions and hosted-tool markers.
2//!
3//! Rust equivalent of `agent_framework._tools`. An [`FunctionTool`] is a locally
4//! executable tool; hosted [`ToolKind`] variants are markers handed to the service.
5//! Both are represented uniformly to a chat client as a [`ToolDefinition`].
6//!
7//! Prefer [`FunctionTool::typed`] over [`FunctionTool::new`] when the arguments can
8//! be expressed as a `#[derive(Deserialize, JsonSchema)]` struct: it derives the
9//! parameters JSON Schema via `schemars` instead of requiring a hand-written
10//! [`serde_json::Value`].
11
12use std::collections::HashMap;
13use std::future::Future;
14use std::pin::Pin;
15use std::sync::atomic::{AtomicUsize, Ordering};
16use std::sync::Arc;
17
18use async_trait::async_trait;
19use serde::de::DeserializeOwned;
20use serde::Serialize;
21use serde_json::{Map, Value};
22
23use crate::error::{Error, Result};
24
25/// A boxed, owned future returned by a tool invocation.
26pub type BoxFuture<T> = Pin<Box<dyn Future<Output = T> + Send>>;
27
28/// An executable tool the framework can invoke locally.
29#[async_trait]
30pub trait Tool: Send + Sync {
31    /// The tool name exposed to the model.
32    fn name(&self) -> &str;
33
34    /// A human/model-readable description.
35    fn description(&self) -> &str;
36
37    /// The JSON Schema describing the tool's parameters.
38    fn parameters_schema(&self) -> Value;
39
40    /// Execute the tool with the given JSON arguments.
41    async fn invoke(&self, arguments: Value) -> Result<Value>;
42
43    /// Execute the tool with access to the surrounding
44    /// [`FunctionInvocationContext`](crate::middleware::FunctionInvocationContext)
45    /// (the agent session, middleware metadata, …).
46    ///
47    /// The function-invocation loop calls **this** method; the default
48    /// implementation ignores the context and delegates to [`Tool::invoke`],
49    /// so ordinary tools need not care. Override it for tools that read the
50    /// invocation context — the Rust analogue of an upstream `FunctionTool`
51    /// whose function declares a `ctx: FunctionInvocationContext` parameter
52    /// (e.g. the `Agent::as_tool` wrapper, which forwards `ctx.session` to
53    /// the sub-agent when `propagate_session` is enabled).
54    async fn invoke_in_context(
55        &self,
56        arguments: Value,
57        _ctx: &crate::middleware::FunctionInvocationContext,
58    ) -> Result<Value> {
59        self.invoke(arguments).await
60    }
61}
62
63/// A dynamic source of tools, resolved fresh on every agent run instead of
64/// being frozen into the agent's tool list at build time.
65///
66/// The motivating case is an MCP server: without this trait, wiring one into
67/// a [`crate::agent::Agent`] means calling `mcp.tool_definitions().await`
68/// once, up front, and handing the (now-frozen) result to
69/// [`crate::agent::AgentBuilder::tools`] — the agent never notices a
70/// server-side tool-catalog change (`notifications/tools/list_changed`)
71/// afterward. Registering a `ToolSource` via
72/// [`crate::agent::AgentBuilder::tool_source`] instead defers resolution
73/// to every [`crate::agent::SupportsAgentRun::run`] / [`crate::agent::SupportsAgentRun::run_with_options`]
74/// / [`crate::agent::SupportsAgentRun::run_stream`] call (see `Agent::prepare_request`),
75/// so a source that caches internally — invalidating that cache when the
76/// server signals a change — can serve an up-to-date catalog on every run
77/// without a live round trip each time.
78///
79/// See `agent-framework-mcp`'s `McpStdioTool` / `McpStreamableHttpTool` /
80/// `McpWebsocketTool`, which all implement this trait.
81#[async_trait]
82pub trait ToolSource: Send + Sync {
83    /// Resolve this source's current tools.
84    ///
85    /// Called once per agent run. Implementations that connect to a remote
86    /// server should connect lazily (on first call) and are encouraged to
87    /// cache their result until something invalidates it, rather than
88    /// performing a live round trip on every call.
89    ///
90    /// An `Err` returned here propagates out of the whole run rather than
91    /// being swallowed — this mirrors the upstream Python reference, whose
92    /// `Agent.run`/`run_stream` do not catch a failure raised while
93    /// connecting to an `MCPTool` at run time (`_agents.py:855-865,
94    /// 970-980`: `await self._async_exit_stack.enter_async_context(tool)`
95    /// is not wrapped in a `try`/`except`).
96    async fn resolve_tools(&self) -> Result<Vec<ToolDefinition>>;
97
98    /// A short, human-readable name for this source, used in diagnostics
99    /// (e.g. a `tracing::warn!` when one of its tools collides by name with
100    /// a tool that already exists).
101    fn source_name(&self) -> &str;
102}
103
104/// The category of a tool as advertised to the service.
105#[derive(Debug, Clone, PartialEq)]
106pub enum ToolKind {
107    /// A callable function (executed locally, unless declaration-only).
108    Function,
109    /// Service-side code interpreter.
110    HostedCodeInterpreter,
111    /// Service-side image generation.
112    HostedImageGeneration,
113    /// Service-side web search.
114    HostedWebSearch,
115    /// Service-side file search over hosted vector stores.
116    HostedFileSearch { max_results: Option<u32> },
117    /// Service-side MCP tool.
118    HostedMcp {
119        url: String,
120        allowed_tools: Option<Vec<String>>,
121    },
122}
123
124/// Whether a call to a tool must be approved by a human before it runs.
125#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
126pub enum ApprovalMode {
127    /// Never require approval (default).
128    #[default]
129    NeverRequire,
130    /// Always require approval before executing.
131    AlwaysRequire,
132}
133
134/// The approval gate configured on a *hosted* MCP connector -- i.e. how the
135/// service itself decides whether a call to one of its MCP tools needs human
136/// sign-off before it runs. Set via [`ToolDefinition::mcp_approval_mode`].
137///
138/// Distinct from [`ApprovalMode`], which gates *local* function-tool
139/// execution in this framework's own invocation loop; a hosted MCP tool call
140/// happens entirely on the service side, so `ApprovalMode` does not apply to
141/// it.
142#[derive(Debug, Clone, PartialEq, Eq)]
143pub enum McpApprovalMode {
144    /// Every tool call on the MCP server requires approval.
145    Always,
146    /// No tool call on the MCP server requires approval.
147    Never,
148    /// Approval is required or waived per tool name.
149    PerTool {
150        /// Tool names that always require approval.
151        always: Vec<String>,
152        /// Tool names that never require approval.
153        never: Vec<String>,
154    },
155}
156
157impl McpApprovalMode {
158    /// The `parameters["approval_mode"]` wire value: the strings
159    /// `"always_require"`/`"never_require"` for [`McpApprovalMode::Always`]/
160    /// [`McpApprovalMode::Never`], or (for [`McpApprovalMode::PerTool`]) an
161    /// object with `"always"`/`"never"` tool-name-array keys, each included
162    /// only when non-empty.
163    fn into_value(self) -> Value {
164        match self {
165            McpApprovalMode::Always => Value::String("always_require".to_string()),
166            McpApprovalMode::Never => Value::String("never_require".to_string()),
167            McpApprovalMode::PerTool { always, never } => {
168                let mut map = Map::new();
169                if !always.is_empty() {
170                    map.insert("always".to_string(), serde_json::json!(always));
171                }
172                if !never.is_empty() {
173                    map.insert("never".to_string(), serde_json::json!(never));
174                }
175                Value::Object(map)
176            }
177        }
178    }
179}
180
181/// A uniform, cloneable descriptor of a tool passed via [`ChatOptions::tools`].
182///
183/// For function tools it carries an executor (`Arc<dyn Tool>`); hosted tools and
184/// declaration-only tools carry `None`.
185///
186/// [`ChatOptions::tools`]: crate::types::ChatOptions::tools
187#[derive(Clone)]
188pub struct ToolDefinition {
189    pub name: String,
190    pub description: String,
191    /// JSON Schema for the parameters (empty object for tools with no params).
192    pub parameters: Value,
193    pub kind: ToolKind,
194    pub approval_mode: ApprovalMode,
195    /// The local executor, if this is an invokable function tool.
196    pub executor: Option<Arc<dyn Tool>>,
197}
198
199impl std::fmt::Debug for ToolDefinition {
200    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
201        f.debug_struct("ToolDefinition")
202            .field("name", &self.name)
203            .field("description", &self.description)
204            .field("kind", &self.kind)
205            .field("approval_mode", &self.approval_mode)
206            .field("executable", &self.executor.is_some())
207            .finish()
208    }
209}
210
211impl ToolDefinition {
212    /// Whether this tool has a local implementation to execute.
213    pub fn is_executable(&self) -> bool {
214        self.executor.is_some() && self.kind == ToolKind::Function
215    }
216
217    /// Whether a human must approve a call to this tool before it executes.
218    pub fn requires_approval(&self) -> bool {
219        self.approval_mode == ApprovalMode::AlwaysRequire
220    }
221
222    /// Builder: set the human-in-the-loop approval mode (default
223    /// [`ApprovalMode::NeverRequire`]). When set to
224    /// [`ApprovalMode::AlwaysRequire`], the function-invocation loop returns a
225    /// [`FunctionApprovalRequestContent`] instead of executing the call.
226    ///
227    /// [`FunctionApprovalRequestContent`]: crate::types::FunctionApprovalRequestContent
228    pub fn with_approval_mode(mut self, mode: ApprovalMode) -> Self {
229        self.approval_mode = mode;
230        self
231    }
232
233    /// Builder: require human approval before every call to this tool.
234    pub fn require_approval(self) -> Self {
235        self.with_approval_mode(ApprovalMode::AlwaysRequire)
236    }
237
238    /// Builder: set the tool's description.
239    ///
240    /// Works on any [`ToolDefinition`], but is primarily useful right after
241    /// a hosted constructor ([`hosted_web_search`], [`hosted_file_search`],
242    /// [`hosted_code_interpreter`], [`hosted_mcp`]), none of which take a
243    /// description argument. For a [`hosted_mcp`] tool specifically, the
244    /// OpenAI Responses API forwards a non-empty description as the hosted
245    /// MCP server's `server_description`.
246    pub fn description(mut self, description: impl Into<String>) -> Self {
247        self.description = description.into();
248        self
249    }
250
251    /// Builder: the web-search tool's approximate user location.
252    ///
253    /// Read by the OpenAI Chat Completions and Responses APIs (as
254    /// `web_search_options.user_location.approximate` /
255    /// `web_search.user_location`) and by Anthropic's web-search tool
256    /// (`user_location`). Ignored by Azure AI Foundry's Bing-backed web
257    /// search. Writes `parameters["user_location"]`; the value's shape is
258    /// provider-specific (e.g. `{"city": "Seattle", "country": "US"}`). Use
259    /// immediately after [`hosted_web_search`].
260    pub fn user_location(self, location: Value) -> Self {
261        self.set_param("user_location", location)
262    }
263
264    /// Builder: cap the number of searches a hosted web-search tool may
265    /// perform while answering a single request.
266    ///
267    /// Read by Anthropic only (`max_uses`); OpenAI and Azure AI Foundry
268    /// ignore it. Writes `parameters["max_uses"]`. Use immediately after
269    /// [`hosted_web_search`].
270    pub fn max_uses(self, max_uses: u32) -> Self {
271        self.set_param("max_uses", serde_json::json!(max_uses))
272    }
273
274    /// Builder: an Azure AI Foundry Bing Grounding connection id.
275    ///
276    /// Read by Azure AI Foundry only, to build a `bing_grounding` tool.
277    /// Mutually exclusive with [`ToolDefinition::custom_connection`]: a
278    /// fully-specified custom pair takes precedence over this plain id, and
279    /// a *partial* custom pair (only one of the two custom fields) still
280    /// disqualifies this plain id -- Azure AI Foundry then rejects the tool
281    /// outright for having no usable connection. Writes
282    /// `parameters["connection_id"]`. Use immediately after
283    /// [`hosted_web_search`].
284    pub fn connection_id(self, connection_id: impl Into<String>) -> Self {
285        self.set_param("connection_id", Value::String(connection_id.into()))
286    }
287
288    /// Builder: an Azure AI Foundry Bing Custom Search connection: a
289    /// connection id plus the custom-search instance name.
290    ///
291    /// Read by Azure AI Foundry only, to build a `bing_custom_search` tool;
292    /// takes precedence over a plain [`ToolDefinition::connection_id`] when
293    /// both are set. Writes `parameters["custom_connection_id"]` and
294    /// `parameters["instance_name"]`. Use immediately after
295    /// [`hosted_web_search`].
296    pub fn custom_connection(
297        self,
298        connection_id: impl Into<String>,
299        instance_name: impl Into<String>,
300    ) -> Self {
301        self.set_param("custom_connection_id", Value::String(connection_id.into()))
302            .set_param("instance_name", Value::String(instance_name.into()))
303    }
304
305    /// Builder: the vector store ids a hosted file-search tool should
306    /// search.
307    ///
308    /// Read by the OpenAI Responses API and Azure AI Foundry. Ignored by
309    /// Anthropic, which has no file-search tool (unsupported by the
310    /// Anthropic Messages API). Writes `parameters["vector_store_ids"]`.
311    /// Use immediately after [`hosted_file_search`].
312    pub fn vector_store_ids(self, ids: Vec<String>) -> Self {
313        self.set_param("vector_store_ids", serde_json::json!(ids))
314    }
315
316    /// Builder: cap the number of results a hosted file-search tool returns.
317    ///
318    /// Read by the OpenAI Responses API only, and only as a fallback: pass
319    /// `max_results` directly to [`hosted_file_search`] where possible,
320    /// which takes precedence over this parameter when both are set (and is
321    /// the only option Azure AI Foundry honors, since it does not read this
322    /// key). Writes `parameters["max_results"]`.
323    pub fn max_results(self, max_results: u32) -> Self {
324        self.set_param("max_results", serde_json::json!(max_results))
325    }
326
327    /// Builder: file ids attached to a hosted code-interpreter tool's
328    /// container.
329    ///
330    /// Read by the OpenAI Responses API, which folds them into a default
331    /// `{"type": "auto"}` container unless [`ToolDefinition::container`]
332    /// supplies an explicit override (which then wins outright and this key
333    /// is ignored). Writes `parameters["file_ids"]`. Use immediately after
334    /// [`hosted_code_interpreter`].
335    pub fn file_ids(self, file_ids: Vec<String>) -> Self {
336        self.set_param("file_ids", serde_json::json!(file_ids))
337    }
338
339    /// Builder: an explicit container object for a hosted code-interpreter
340    /// tool, overriding the default `{"type": "auto"}` container (and any
341    /// [`ToolDefinition::file_ids`]).
342    ///
343    /// Read by the OpenAI Responses API only. Writes
344    /// `parameters["container"]`. Use immediately after
345    /// [`hosted_code_interpreter`].
346    pub fn container(self, container: Value) -> Self {
347        self.set_param("container", container)
348    }
349
350    /// Builder: HTTP headers sent with requests to a hosted MCP server.
351    ///
352    /// Read by the OpenAI Responses API (forwarded verbatim as `headers`),
353    /// Anthropic (only the lower-case `"authorization"` entry, mapped to
354    /// `authorization_token`), and Azure AI Foundry (forwarded verbatim,
355    /// when non-empty). Writes `parameters["headers"]`. Use immediately
356    /// after [`hosted_mcp`].
357    pub fn headers(self, headers: HashMap<String, String>) -> Self {
358        self.set_param("headers", serde_json::json!(headers))
359    }
360
361    /// Builder: the hosted MCP server's own approval gate for its tool
362    /// calls -- see [`McpApprovalMode`].
363    ///
364    /// Read by the OpenAI Responses API and Azure AI Foundry as
365    /// `parameters["approval_mode"]`; not read by the Anthropic converter in
366    /// this port (Anthropic's MCP connector has no per-tool approval concept
367    /// here). Use immediately after [`hosted_mcp`].
368    pub fn mcp_approval_mode(self, mode: McpApprovalMode) -> Self {
369        self.set_param("approval_mode", mode.into_value())
370    }
371
372    /// Insert `value` at `key` in [`ToolDefinition::parameters`], coercing
373    /// `parameters` to an empty object first if it is not already one (the
374    /// hosted constructors always start it as one via [`empty_schema`], so
375    /// this is purely defensive).
376    fn set_param(mut self, key: &str, value: Value) -> Self {
377        if !self.parameters.is_object() {
378            self.parameters = Value::Object(Map::new());
379        }
380        if let Value::Object(map) = &mut self.parameters {
381            map.insert(key.to_string(), value);
382        }
383        self
384    }
385
386    /// The OpenAI-style function spec: `{"type":"function","function":{...}}`.
387    pub fn to_openai_spec(&self) -> Value {
388        serde_json::json!({
389            "type": "function",
390            "function": {
391                "name": self.name,
392                "description": self.description,
393                "parameters": self.parameters,
394            }
395        })
396    }
397
398    /// Build a tool definition from any [`Tool`] implementation.
399    pub fn from_tool(tool: Arc<dyn Tool>) -> Self {
400        Self {
401            name: tool.name().to_string(),
402            description: tool.description().to_string(),
403            parameters: tool.parameters_schema(),
404            kind: ToolKind::Function,
405            approval_mode: ApprovalMode::NeverRequire,
406            executor: Some(tool),
407        }
408    }
409}
410
411impl<T: Tool + 'static> From<Arc<T>> for ToolDefinition {
412    fn from(tool: Arc<T>) -> Self {
413        ToolDefinition::from_tool(tool)
414    }
415}
416
417type ToolClosure = Arc<dyn Fn(Value) -> BoxFuture<Result<Value>> + Send + Sync>;
418
419/// A concrete, locally executable tool built from a closure.
420///
421/// This is the Rust analogue of upstream's `FunctionTool` / the `@tool`
422/// decorator (formerly `AIFunction` / `@ai_function`).
423#[derive(Clone)]
424pub struct FunctionTool {
425    name: String,
426    description: String,
427    parameters: Value,
428    approval_mode: ApprovalMode,
429    func: ToolClosure,
430    max_invocations: Option<usize>,
431    max_invocation_exceptions: Option<usize>,
432    // `Arc` so every `Clone` of an `FunctionTool` shares one pair of counters
433    // with its source rather than silently resetting the limits; mirrors
434    // Python's `invocation_count`/`invocation_exception_count` being mutable
435    // state on the (singular) `AIFunction` instance itself.
436    invocation_count: Arc<AtomicUsize>,
437    invocation_exception_count: Arc<AtomicUsize>,
438}
439
440impl FunctionTool {
441    /// Create a function tool from a hand-written JSON Schema.
442    ///
443    /// * `parameters` is the JSON Schema for the arguments object.
444    /// * `func` receives the parsed JSON arguments and returns a JSON result.
445    ///
446    /// Prefer [`FunctionTool::typed`] when the arguments can be expressed as a
447    /// `#[derive(Deserialize, JsonSchema)]` struct.
448    pub fn new<F, Fut>(
449        name: impl Into<String>,
450        description: impl Into<String>,
451        parameters: Value,
452        func: F,
453    ) -> Self
454    where
455        F: Fn(Value) -> Fut + Send + Sync + 'static,
456        Fut: Future<Output = Result<Value>> + Send + 'static,
457    {
458        Self {
459            name: name.into(),
460            description: description.into(),
461            parameters,
462            approval_mode: ApprovalMode::NeverRequire,
463            func: Arc::new(move |args| Box::pin(func(args))),
464            max_invocations: None,
465            max_invocation_exceptions: None,
466            invocation_count: Arc::new(AtomicUsize::new(0)),
467            invocation_exception_count: Arc::new(AtomicUsize::new(0)),
468        }
469    }
470
471    /// Create a function tool whose parameters schema and argument
472    /// deserialization are derived from a Rust type, instead of a
473    /// hand-written [`serde_json::Value`] schema.
474    ///
475    /// `Args` must implement [`schemars::JsonSchema`] (to derive the
476    /// parameters schema) and [`serde::de::DeserializeOwned`] (to parse the
477    /// model-supplied arguments); `Ret` need only implement
478    /// [`serde::Serialize`] -- return `serde_json::Value` directly (as in
479    /// the example below), or any other serializable type.
480    ///
481    /// # Parameters schema
482    ///
483    /// The schema is generated once, at construction, via `schemars`'
484    /// `SchemaGenerator` (the machinery behind its `schema_for!` macro, which
485    /// cannot itself target a type parameter), then lightly post-processed
486    /// for OpenAI-style function parameters: the top-level `$schema` and
487    /// `title` keys are stripped. For a "simple" struct (only
488    /// primitive/string/number/bool/`Vec`/`Option` fields) this leaves
489    /// exactly `{"type": "object", "properties": {...}, "required": [...]}`
490    /// -- a field is listed in `required` unless it is an `Option<_>` or
491    /// carries `#[serde(default)]`. Nested structs and enums keep
492    /// `schemars`' own representation: a top-level `definitions` map with
493    /// `$ref`s into it (schemars 0.8's convention for referenceable types).
494    /// This is *not* inlined -- every provider converter in this workspace
495    /// forwards [`ToolDefinition::parameters`] to the wire unmodified, so a
496    /// `$ref`/`definitions` pair round-trips exactly like any other
497    /// JSON-Schema keyword this crate doesn't otherwise interpret.
498    ///
499    /// # Argument errors
500    ///
501    /// If the model-supplied JSON arguments don't deserialize into `Args`
502    /// (e.g. a required field is missing or mistyped), [`Tool::invoke`]
503    /// returns `Err(`[`Error::Tool`]`)` rather than panicking or silently
504    /// substituting a default -- the same `Result`-propagation shape used
505    /// for every other tool-execution failure (a closure error from
506    /// [`FunctionTool::new`], an [`FunctionTool::max_invocations`] limit, ...),
507    /// which the function-invocation loop turns into an error
508    /// [`crate::types::FunctionResultContent`] exactly as it would for any
509    /// of those.
510    ///
511    /// # Example
512    ///
513    /// ```
514    /// use agent_framework_core::tools::FunctionTool;
515    ///
516    /// #[derive(serde::Deserialize, schemars::JsonSchema)]
517    /// struct WeatherArgs {
518    ///     city: String,
519    ///     #[serde(default)]
520    ///     units: Option<String>,
521    /// }
522    ///
523    /// let _tool = FunctionTool::typed(
524    ///     "get_weather",
525    ///     "Get the weather.",
526    ///     |args: WeatherArgs| async move {
527    ///         Ok(serde_json::json!({ "city": args.city, "temp": 21 }))
528    ///     },
529    /// );
530    /// ```
531    pub fn typed<Args, Ret, F, Fut>(
532        name: impl Into<String>,
533        description: impl Into<String>,
534        f: F,
535    ) -> Self
536    where
537        Args: DeserializeOwned + schemars::JsonSchema + Send + 'static,
538        Ret: Serialize,
539        F: Fn(Args) -> Fut + Send + Sync + 'static,
540        Fut: Future<Output = Result<Ret>> + Send + 'static,
541    {
542        let name = name.into();
543        let parameters = typed_parameters_schema::<Args>();
544        let err_name = name.clone();
545        let f = Arc::new(f);
546        let func: ToolClosure = Arc::new(move |value: Value| {
547            let f = Arc::clone(&f);
548            let err_name = err_name.clone();
549            Box::pin(async move {
550                let args: Args = serde_json::from_value(value).map_err(|e| {
551                    Error::tool(format!("invalid arguments for tool '{err_name}': {e}"))
552                })?;
553                let ret = f(args).await?;
554                serde_json::to_value(ret).map_err(|e| {
555                    Error::tool(format!(
556                        "failed to serialize result of tool '{err_name}': {e}"
557                    ))
558                })
559            })
560        });
561        Self {
562            name,
563            description: description.into(),
564            parameters,
565            approval_mode: ApprovalMode::NeverRequire,
566            func,
567            max_invocations: None,
568            max_invocation_exceptions: None,
569            invocation_count: Arc::new(AtomicUsize::new(0)),
570            invocation_exception_count: Arc::new(AtomicUsize::new(0)),
571        }
572    }
573
574    /// Builder: set the human-in-the-loop approval mode (default
575    /// [`ApprovalMode::NeverRequire`]). Carried through to the
576    /// [`ToolDefinition`] produced by [`FunctionTool::into_definition`].
577    pub fn with_approval_mode(mut self, mode: ApprovalMode) -> Self {
578        self.approval_mode = mode;
579        self
580    }
581
582    /// Builder: cap the number of times this function may be invoked.
583    ///
584    /// Once [`FunctionTool::invocation_count`] reaches `max`, further calls to
585    /// [`Tool::invoke`] return `Err(`[`Error::Tool`]`)` instead of running
586    /// the function again -- mirrors Python's
587    /// `AIFunction(max_invocations=...)` (`_tools.py:599-600, 687-690`).
588    /// `None` (the default) means no limit.
589    ///
590    /// Unlike Python, which raises `ValueError` at construction for a value
591    /// less than 1, a value of `0` is accepted here: it simply means the
592    /// limit is already reached, so every invocation errors immediately
593    /// (the same terminal state Python's validation exists to prevent
594    /// constructing in the first place).
595    ///
596    /// The counter is shared by every `Clone` of this `FunctionTool` (see the
597    /// note on [`FunctionTool`]'s fields), not reset per clone.
598    pub fn max_invocations(mut self, max: usize) -> Self {
599        self.max_invocations = Some(max);
600        self
601    }
602
603    /// Builder: cap the number of invocation failures this function
604    /// tolerates.
605    ///
606    /// Every [`Tool::invoke`] call that returns `Err` -- whether from
607    /// argument deserialization (see [`FunctionTool::typed`]), the wrapped
608    /// closure itself, or result serialization -- increments
609    /// [`FunctionTool::invocation_exception_count`]. Once that count reaches
610    /// `max`, further calls return `Err(`[`Error::Tool`]`)` immediately
611    /// without re-attempting the function. `None` (the default) means no
612    /// limit. Mirrors Python's `AIFunction(max_invocation_exceptions=...)`
613    /// (`_tools.py:601-602, 691-698`); see [`FunctionTool::max_invocations`]
614    /// for how the `0` case differs from Python's constructor-time
615    /// validation.
616    pub fn max_invocation_exceptions(mut self, max: usize) -> Self {
617        self.max_invocation_exceptions = Some(max);
618        self
619    }
620
621    /// The number of times [`Tool::invoke`] has run the wrapped function
622    /// (i.e. got past any [`FunctionTool::max_invocations`]/
623    /// [`FunctionTool::max_invocation_exceptions`] gate). Mirrors Python's
624    /// public `invocation_count` attribute.
625    pub fn invocation_count(&self) -> usize {
626        self.invocation_count.load(Ordering::SeqCst)
627    }
628
629    /// The number of those invocations that returned `Err`. Mirrors
630    /// Python's public `invocation_exception_count` attribute.
631    pub fn invocation_exception_count(&self) -> usize {
632        self.invocation_exception_count.load(Ordering::SeqCst)
633    }
634
635    /// Convert into a [`ToolDefinition`] for use in chat options.
636    pub fn into_definition(self) -> ToolDefinition {
637        let approval_mode = self.approval_mode;
638        ToolDefinition::from_tool(Arc::new(self)).with_approval_mode(approval_mode)
639    }
640}
641
642#[async_trait]
643impl Tool for FunctionTool {
644    fn name(&self) -> &str {
645        &self.name
646    }
647    fn description(&self) -> &str {
648        &self.description
649    }
650    fn parameters_schema(&self) -> Value {
651        self.parameters.clone()
652    }
653
654    /// Run the wrapped function, first enforcing
655    /// [`FunctionTool::max_invocations`] and
656    /// [`FunctionTool::max_invocation_exceptions`] (mirrors Python's
657    /// `AIFunction.__call__`, `_tools.py:683-707`): a limit that has already
658    /// been reached errors *before* the function runs and *before*
659    /// [`FunctionTool::invocation_count`] is bumped again, so calling an
660    /// already-exhausted function any number of further times does not
661    /// drift its counters.
662    ///
663    /// The invocation slot is *reserved atomically* (`fetch_update`), because
664    /// the function-invocation loop executes a model's parallel calls to the
665    /// same tool concurrently — a plain check-then-increment would let two
666    /// racing calls both slip under `max_invocations`.
667    async fn invoke(&self, arguments: Value) -> Result<Value> {
668        let invocation_limit_error = || {
669            Error::tool(format!(
670                "Function '{}' has reached its maximum invocation limit, \
671                 you can no longer use this tool.",
672                self.name
673            ))
674        };
675        // Fast-path check first so the error precedence between the two
676        // limits matches Python's sequential check order.
677        if let Some(max) = self.max_invocations {
678            if self.invocation_count.load(Ordering::SeqCst) >= max {
679                return Err(invocation_limit_error());
680            }
681        }
682        if let Some(max) = self.max_invocation_exceptions {
683            if self.invocation_exception_count.load(Ordering::SeqCst) >= max {
684                return Err(Error::tool(format!(
685                    "Function '{}' has reached its maximum exception limit, \
686                     you tried to use this tool too many times and it kept failing.",
687                    self.name
688                )));
689            }
690        }
691        match self.max_invocations {
692            Some(max) => {
693                // Reserve or bail: a concurrent call may have consumed the
694                // last slot since the fast-path check above.
695                if self
696                    .invocation_count
697                    .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |c| {
698                        (c < max).then(|| c + 1)
699                    })
700                    .is_err()
701                {
702                    return Err(invocation_limit_error());
703                }
704            }
705            None => {
706                self.invocation_count.fetch_add(1, Ordering::SeqCst);
707            }
708        }
709        let result = (self.func)(arguments).await;
710        if result.is_err() {
711            self.invocation_exception_count
712                .fetch_add(1, Ordering::SeqCst);
713        }
714        result
715    }
716}
717
718/// Construct a hosted code-interpreter tool marker.
719pub fn hosted_code_interpreter() -> ToolDefinition {
720    ToolDefinition {
721        name: "code_interpreter".into(),
722        description: String::new(),
723        parameters: empty_schema(),
724        kind: ToolKind::HostedCodeInterpreter,
725        approval_mode: ApprovalMode::NeverRequire,
726        executor: None,
727    }
728}
729
730/// Construct a hosted image-generation tool marker.
731///
732/// Supported by services that expose server-side image generation as a tool
733/// (e.g. the OpenAI Responses API's `image_generation` tool). Its results
734/// surface as [`Content::ImageGenerationToolResult`](crate::types::Content).
735pub fn hosted_image_generation() -> ToolDefinition {
736    ToolDefinition {
737        name: "image_generation".into(),
738        description: String::new(),
739        parameters: empty_schema(),
740        kind: ToolKind::HostedImageGeneration,
741        approval_mode: ApprovalMode::NeverRequire,
742        executor: None,
743    }
744}
745
746/// Construct a hosted web-search tool marker.
747pub fn hosted_web_search() -> ToolDefinition {
748    ToolDefinition {
749        name: "web_search".into(),
750        description: String::new(),
751        parameters: empty_schema(),
752        kind: ToolKind::HostedWebSearch,
753        approval_mode: ApprovalMode::NeverRequire,
754        executor: None,
755    }
756}
757
758/// Construct a hosted file-search tool marker.
759pub fn hosted_file_search(max_results: Option<u32>) -> ToolDefinition {
760    ToolDefinition {
761        name: "file_search".into(),
762        description: String::new(),
763        parameters: empty_schema(),
764        kind: ToolKind::HostedFileSearch { max_results },
765        approval_mode: ApprovalMode::NeverRequire,
766        executor: None,
767    }
768}
769
770/// Construct a hosted MCP tool marker.
771pub fn hosted_mcp(
772    name: impl Into<String>,
773    url: impl Into<String>,
774    allowed_tools: Option<Vec<String>>,
775) -> ToolDefinition {
776    ToolDefinition {
777        name: name.into(),
778        description: String::new(),
779        parameters: empty_schema(),
780        kind: ToolKind::HostedMcp {
781            url: url.into(),
782            allowed_tools,
783        },
784        approval_mode: ApprovalMode::NeverRequire,
785        executor: None,
786    }
787}
788
789/// An empty JSON-Schema object (no parameters).
790pub fn empty_schema() -> Value {
791    serde_json::json!({ "type": "object", "properties": {} })
792}
793
794/// Derive an OpenAI-style parameters JSON Schema for `Args` via `schemars`,
795/// stripping the top-level `$schema`/`title` keys. See [`FunctionTool::typed`]
796/// for exactly what this does and does not normalize.
797fn typed_parameters_schema<Args: schemars::JsonSchema>() -> Value {
798    let root = schemars::gen::SchemaGenerator::default().into_root_schema_for::<Args>();
799    let mut value = serde_json::to_value(root).unwrap_or_else(|_| empty_schema());
800    if let Value::Object(map) = &mut value {
801        map.remove("$schema");
802        map.remove("title");
803    }
804    value
805}
806
807/// Configuration for the automatic function-invocation loop.
808///
809/// Mirrors `FunctionInvocationConfiguration`.
810#[derive(Debug, Clone)]
811pub struct FunctionInvocationConfig {
812    pub enabled: bool,
813    pub max_iterations: usize,
814    pub max_consecutive_errors_per_request: usize,
815    pub terminate_on_unknown_calls: bool,
816    pub include_detailed_errors: bool,
817}
818
819impl Default for FunctionInvocationConfig {
820    fn default() -> Self {
821        Self {
822            enabled: true,
823            max_iterations: 40,
824            max_consecutive_errors_per_request: 3,
825            terminate_on_unknown_calls: false,
826            include_detailed_errors: false,
827        }
828    }
829}
830
831impl FunctionInvocationConfig {
832    pub fn validate(&self) -> Result<()> {
833        if self.max_iterations < 1 {
834            return Err(Error::Configuration("max_iterations must be >= 1".into()));
835        }
836        Ok(())
837    }
838}
839
840#[cfg(test)]
841mod tests {
842    use super::*;
843
844    // region: typed() schema derivation
845
846    #[derive(serde::Deserialize, schemars::JsonSchema)]
847    struct WeatherArgs {
848        city: String,
849        #[serde(default)]
850        units: Option<String>,
851    }
852
853    #[test]
854    fn typed_schema_required_and_optional_fields_exact_json() {
855        let schema = typed_parameters_schema::<WeatherArgs>();
856        assert_eq!(
857            schema,
858            serde_json::json!({
859                "type": "object",
860                "properties": {
861                    "city": { "type": "string" },
862                    "units": { "type": ["string", "null"], "default": null },
863                },
864                "required": ["city"],
865            })
866        );
867    }
868
869    #[test]
870    fn typed_schema_strips_schema_and_title_keys() {
871        let schema = typed_parameters_schema::<WeatherArgs>();
872        let obj = schema.as_object().expect("object schema");
873        assert!(!obj.contains_key("$schema"));
874        assert!(!obj.contains_key("title"));
875    }
876
877    #[derive(serde::Deserialize, schemars::JsonSchema)]
878    #[allow(dead_code)]
879    enum Priority {
880        Low,
881        High,
882    }
883
884    #[derive(serde::Deserialize, schemars::JsonSchema)]
885    #[allow(dead_code)]
886    struct Address {
887        city: String,
888        zip: Option<String>,
889    }
890
891    #[derive(serde::Deserialize, schemars::JsonSchema)]
892    #[allow(dead_code)]
893    struct TaskArgs {
894        title: String,
895        address: Address,
896        priority: Priority,
897    }
898
899    #[test]
900    fn typed_schema_nested_struct_and_enum_keep_schemars_ref_definitions() {
901        // Nested/enum fields are not inlined: they keep schemars' own
902        // `$ref`/`definitions` representation, per `FunctionTool::typed`'s
903        // documented contract (every provider converter forwards
904        // `parameters` to the wire unmodified, so this round-trips fine).
905        let schema = typed_parameters_schema::<TaskArgs>();
906        assert_eq!(
907            schema,
908            serde_json::json!({
909                "type": "object",
910                "definitions": {
911                    "Address": {
912                        "type": "object",
913                        "properties": {
914                            "city": { "type": "string" },
915                            "zip": { "type": ["string", "null"] },
916                        },
917                        "required": ["city"],
918                    },
919                    "Priority": {
920                        "type": "string",
921                        "enum": ["Low", "High"],
922                    },
923                },
924                "properties": {
925                    "title": { "type": "string" },
926                    "address": { "$ref": "#/definitions/Address" },
927                    "priority": { "$ref": "#/definitions/Priority" },
928                },
929                "required": ["address", "priority", "title"],
930            })
931        );
932    }
933
934    // endregion
935
936    // region: typed() argument deserialization + result serialization
937
938    #[tokio::test]
939    async fn typed_invoke_deserializes_valid_arguments_and_serializes_result() {
940        let tool = FunctionTool::typed(
941            "get_weather",
942            "Get the weather.",
943            |args: WeatherArgs| async move {
944                Ok(serde_json::json!({ "city": args.city, "units": args.units }))
945            },
946        );
947        let result = tool
948            .invoke(serde_json::json!({ "city": "Seattle" }))
949            .await
950            .unwrap();
951        assert_eq!(
952            result,
953            serde_json::json!({ "city": "Seattle", "units": null })
954        );
955    }
956
957    #[tokio::test]
958    async fn typed_invoke_missing_required_field_errors_like_a_tool_error() {
959        let tool = FunctionTool::typed(
960            "get_weather",
961            "Get the weather.",
962            |_args: WeatherArgs| async move { Ok(serde_json::Value::Null) },
963        );
964        let err = tool.invoke(serde_json::json!({})).await.unwrap_err();
965        assert!(matches!(err, Error::Tool(_)));
966        assert!(err.to_string().contains("get_weather"));
967    }
968
969    #[tokio::test]
970    async fn typed_invoke_wrong_field_type_errors_like_a_tool_error() {
971        let tool = FunctionTool::typed(
972            "get_weather",
973            "Get the weather.",
974            |_args: WeatherArgs| async move { Ok(serde_json::Value::Null) },
975        );
976        let err = tool
977            .invoke(serde_json::json!({ "city": 5 }))
978            .await
979            .unwrap_err();
980        assert!(matches!(err, Error::Tool(_)));
981    }
982
983    #[tokio::test]
984    async fn typed_invoke_error_shape_matches_a_new_style_closure_error() {
985        // `new` and `typed` funnel every failure through the same
986        // `Result<Value>`-returning `Tool::invoke`; a bad-argument failure
987        // from `typed` and a closure failure from `new` are indistinguishable
988        // in shape to the caller (both `Err(Error::Tool(_))`), which is what
989        // lets `execute_tool_call` in `client.rs` treat them identically.
990        let untyped = FunctionTool::new("f", "d", empty_schema(), |_args| async move {
991            Err(Error::tool("boom"))
992        });
993        let untyped_err = untyped.invoke(serde_json::json!({})).await.unwrap_err();
994
995        let typed = FunctionTool::typed("f", "d", |_args: WeatherArgs| async move {
996            Ok(serde_json::Value::Null)
997        });
998        let typed_err = typed.invoke(serde_json::json!({})).await.unwrap_err();
999
1000        assert!(matches!(untyped_err, Error::Tool(_)));
1001        assert!(matches!(typed_err, Error::Tool(_)));
1002    }
1003
1004    #[derive(serde::Serialize)]
1005    struct WeatherResult {
1006        city: String,
1007        temp_c: i32,
1008    }
1009
1010    #[tokio::test]
1011    async fn typed_invoke_serializes_a_generic_serializable_return_type() {
1012        // `Ret` need not be `serde_json::Value`; any `Serialize` type works.
1013        let tool = FunctionTool::typed(
1014            "get_weather",
1015            "Get the weather.",
1016            |args: WeatherArgs| async move {
1017                Ok(WeatherResult {
1018                    city: args.city,
1019                    temp_c: 21,
1020                })
1021            },
1022        );
1023        let result = tool
1024            .invoke(serde_json::json!({ "city": "Portland" }))
1025            .await
1026            .unwrap();
1027        assert_eq!(
1028            result,
1029            serde_json::json!({ "city": "Portland", "temp_c": 21 })
1030        );
1031    }
1032
1033    // endregion
1034
1035    // region: invocation limits
1036
1037    #[tokio::test]
1038    async fn max_invocations_blocks_calls_past_the_limit() {
1039        let calls = Arc::new(AtomicUsize::new(0));
1040        let calls_clone = Arc::clone(&calls);
1041        let tool = FunctionTool::new("f", "d", empty_schema(), move |_args| {
1042            let calls = Arc::clone(&calls_clone);
1043            async move {
1044                calls.fetch_add(1, Ordering::SeqCst);
1045                Ok(serde_json::Value::Null)
1046            }
1047        })
1048        .max_invocations(2);
1049
1050        tool.invoke(serde_json::json!({})).await.unwrap();
1051        tool.invoke(serde_json::json!({})).await.unwrap();
1052        let err = tool.invoke(serde_json::json!({})).await.unwrap_err();
1053
1054        // The third call was blocked before the closure ran.
1055        assert_eq!(calls.load(Ordering::SeqCst), 2);
1056        assert_eq!(tool.invocation_count(), 2);
1057        assert!(matches!(err, Error::Tool(_)));
1058        assert_eq!(
1059            err.to_string(),
1060            "tool error: Function 'f' has reached its maximum invocation limit, \
1061             you can no longer use this tool."
1062        );
1063    }
1064
1065    #[tokio::test]
1066    async fn max_invocations_holds_under_concurrent_calls() {
1067        // Two parallel calls race for a single invocation slot: exactly one
1068        // may execute. The closure parks on a Notify so both tasks are
1069        // genuinely in-flight together — with a check-then-add limit both
1070        // would slip through; the atomic reservation admits only one.
1071        let gate = Arc::new(tokio::sync::Notify::new());
1072        let calls = Arc::new(AtomicUsize::new(0));
1073        let (gate_c, calls_c) = (Arc::clone(&gate), Arc::clone(&calls));
1074        let tool = Arc::new(
1075            FunctionTool::new("f", "d", empty_schema(), move |_args| {
1076                let (gate, calls) = (Arc::clone(&gate_c), Arc::clone(&calls_c));
1077                async move {
1078                    calls.fetch_add(1, Ordering::SeqCst);
1079                    gate.notified().await;
1080                    Ok(serde_json::Value::Null)
1081                }
1082            })
1083            .max_invocations(1),
1084        );
1085
1086        let (t1, t2) = (Arc::clone(&tool), Arc::clone(&tool));
1087        let h1 = tokio::spawn(async move { t1.invoke(serde_json::json!({})).await });
1088        let h2 = tokio::spawn(async move { t2.invoke(serde_json::json!({})).await });
1089        // Let both tasks reach the limit check before releasing the gate.
1090        tokio::task::yield_now().await;
1091        tokio::task::yield_now().await;
1092        gate.notify_waiters();
1093        gate.notify_waiters();
1094        let (r1, r2) = (h1.await.unwrap(), h2.await.unwrap());
1095
1096        let successes = [&r1, &r2].iter().filter(|r| r.is_ok()).count();
1097        assert_eq!(successes, 1, "exactly one call may claim the single slot");
1098        assert_eq!(calls.load(Ordering::SeqCst), 1, "the closure ran once");
1099        assert_eq!(tool.invocation_count(), 1);
1100        let err = [r1, r2].into_iter().find_map(|r| r.err()).unwrap();
1101        assert!(err.to_string().contains("maximum invocation limit"));
1102    }
1103
1104    #[tokio::test]
1105    async fn max_invocation_exceptions_blocks_calls_past_the_limit() {
1106        let tool = FunctionTool::new("f", "d", empty_schema(), |_args| async move {
1107            Err(Error::tool("boom"))
1108        })
1109        .max_invocation_exceptions(2);
1110
1111        let first = tool.invoke(serde_json::json!({})).await.unwrap_err();
1112        let second = tool.invoke(serde_json::json!({})).await.unwrap_err();
1113        assert_eq!(first.to_string(), "tool error: boom");
1114        assert_eq!(second.to_string(), "tool error: boom");
1115        assert_eq!(tool.invocation_exception_count(), 2);
1116
1117        // The third call is blocked by the exception cap rather than
1118        // re-running (and failing) the closure again.
1119        let third = tool.invoke(serde_json::json!({})).await.unwrap_err();
1120        assert_eq!(
1121            third.to_string(),
1122            "tool error: Function 'f' has reached its maximum exception limit, \
1123             you tried to use this tool too many times and it kept failing."
1124        );
1125        assert_eq!(tool.invocation_exception_count(), 2);
1126    }
1127
1128    #[tokio::test]
1129    async fn invocation_counters_are_shared_across_clones() {
1130        let tool = FunctionTool::new("f", "d", empty_schema(), |_args| async move {
1131            Ok(serde_json::Value::Null)
1132        })
1133        .max_invocations(1);
1134        let clone = tool.clone();
1135
1136        clone.invoke(serde_json::json!({})).await.unwrap();
1137        let err = tool.invoke(serde_json::json!({})).await.unwrap_err();
1138        assert!(matches!(err, Error::Tool(_)));
1139        assert_eq!(tool.invocation_count(), 1);
1140    }
1141
1142    // endregion
1143
1144    // region: hosted-tool builder setters (parameter-key contract)
1145
1146    #[test]
1147    fn description_setter_sets_tool_definition_description() {
1148        let tool = hosted_mcp("docs", "https://mcp.example.com", None).description("My MCP");
1149        assert_eq!(tool.description, "My MCP");
1150    }
1151
1152    #[test]
1153    fn user_location_setter_sets_parameter_key() {
1154        let loc = serde_json::json!({ "city": "Seattle", "country": "US" });
1155        let tool = hosted_web_search().user_location(loc.clone());
1156        assert_eq!(tool.parameters["user_location"], loc);
1157    }
1158
1159    #[test]
1160    fn max_uses_setter_sets_parameter_key() {
1161        let tool = hosted_web_search().max_uses(5);
1162        assert_eq!(tool.parameters["max_uses"], serde_json::json!(5));
1163    }
1164
1165    #[test]
1166    fn connection_id_setter_sets_parameter_key() {
1167        let tool = hosted_web_search().connection_id("conn-1");
1168        assert_eq!(
1169            tool.parameters["connection_id"],
1170            serde_json::json!("conn-1")
1171        );
1172    }
1173
1174    #[test]
1175    fn custom_connection_setter_sets_both_parameter_keys() {
1176        let tool = hosted_web_search().custom_connection("custom-conn", "my-instance");
1177        assert_eq!(
1178            tool.parameters["custom_connection_id"],
1179            serde_json::json!("custom-conn")
1180        );
1181        assert_eq!(
1182            tool.parameters["instance_name"],
1183            serde_json::json!("my-instance")
1184        );
1185    }
1186
1187    #[test]
1188    fn vector_store_ids_setter_sets_parameter_key() {
1189        let tool = hosted_file_search(None).vector_store_ids(vec!["vs_1".into(), "vs_2".into()]);
1190        assert_eq!(
1191            tool.parameters["vector_store_ids"],
1192            serde_json::json!(["vs_1", "vs_2"])
1193        );
1194    }
1195
1196    #[test]
1197    fn max_results_setter_sets_parameter_key() {
1198        let tool = hosted_file_search(None).max_results(7);
1199        assert_eq!(tool.parameters["max_results"], serde_json::json!(7));
1200    }
1201
1202    #[test]
1203    fn file_ids_setter_sets_parameter_key() {
1204        let tool = hosted_code_interpreter().file_ids(vec!["file-1".into()]);
1205        assert_eq!(tool.parameters["file_ids"], serde_json::json!(["file-1"]));
1206    }
1207
1208    #[test]
1209    fn container_setter_sets_parameter_key() {
1210        let container = serde_json::json!({ "type": "secure", "id": "c1" });
1211        let tool = hosted_code_interpreter().container(container.clone());
1212        assert_eq!(tool.parameters["container"], container);
1213    }
1214
1215    #[test]
1216    fn headers_setter_sets_parameter_key() {
1217        let mut headers = HashMap::new();
1218        headers.insert("authorization".to_string(), "Bearer x".to_string());
1219        let tool = hosted_mcp("docs", "https://mcp.example.com", None).headers(headers);
1220        assert_eq!(
1221            tool.parameters["headers"],
1222            serde_json::json!({ "authorization": "Bearer x" })
1223        );
1224    }
1225
1226    #[test]
1227    fn mcp_approval_mode_always_sets_string_parameter() {
1228        let tool = hosted_mcp("docs", "https://mcp.example.com", None)
1229            .mcp_approval_mode(McpApprovalMode::Always);
1230        assert_eq!(
1231            tool.parameters["approval_mode"],
1232            serde_json::json!("always_require")
1233        );
1234    }
1235
1236    #[test]
1237    fn mcp_approval_mode_never_sets_string_parameter() {
1238        let tool = hosted_mcp("docs", "https://mcp.example.com", None)
1239            .mcp_approval_mode(McpApprovalMode::Never);
1240        assert_eq!(
1241            tool.parameters["approval_mode"],
1242            serde_json::json!("never_require")
1243        );
1244    }
1245
1246    #[test]
1247    fn mcp_approval_mode_per_tool_sets_object_parameter_with_both_sides() {
1248        let tool = hosted_mcp("docs", "https://mcp.example.com", None).mcp_approval_mode(
1249            McpApprovalMode::PerTool {
1250                always: vec!["delete".to_string()],
1251                never: vec!["read".to_string()],
1252            },
1253        );
1254        assert_eq!(
1255            tool.parameters["approval_mode"],
1256            serde_json::json!({ "always": ["delete"], "never": ["read"] })
1257        );
1258    }
1259
1260    #[test]
1261    fn mcp_approval_mode_per_tool_omits_the_empty_side() {
1262        // Regression guard for the Azure AI Foundry converter, which treats
1263        // *presence* of the "always" key as the whole `require_approval`
1264        // decision (`if let Some(always) = ... else if let Some(never) =
1265        // ...`): an unconditionally-emitted empty `"always": []` would
1266        // silently defeat a never-only `PerTool` config there.
1267        let never_only = hosted_mcp("docs", "https://mcp.example.com", None).mcp_approval_mode(
1268            McpApprovalMode::PerTool {
1269                always: vec![],
1270                never: vec!["read".to_string()],
1271            },
1272        );
1273        assert_eq!(
1274            never_only.parameters["approval_mode"],
1275            serde_json::json!({ "never": ["read"] })
1276        );
1277
1278        let always_only = hosted_mcp("docs", "https://mcp.example.com", None).mcp_approval_mode(
1279            McpApprovalMode::PerTool {
1280                always: vec!["delete".to_string()],
1281                never: vec![],
1282            },
1283        );
1284        assert_eq!(
1285            always_only.parameters["approval_mode"],
1286            serde_json::json!({ "always": ["delete"] })
1287        );
1288    }
1289
1290    #[test]
1291    fn setters_chain_together_on_a_single_hosted_mcp_tool() {
1292        let mut headers = HashMap::new();
1293        headers.insert("authorization".to_string(), "Bearer x".to_string());
1294        let tool = hosted_mcp("docs", "https://mcp.example.com", None)
1295            .description("Docs server")
1296            .headers(headers.clone())
1297            .mcp_approval_mode(McpApprovalMode::Always);
1298        assert_eq!(tool.description, "Docs server");
1299        assert_eq!(tool.parameters["headers"], serde_json::json!(headers));
1300        assert_eq!(
1301            tool.parameters["approval_mode"],
1302            serde_json::json!("always_require")
1303        );
1304    }
1305
1306    // endregion
1307}