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}