Skip to main content

net_sdk/
tool.rs

1//! AI tool-calling surface — SDK-side helpers built atop the
2//! substrate's `cortex::tool` module.
3//!
4//! Gated by the `tool` Cargo feature. Three things land here:
5//!
6//! 1. Re-exports of every public wire / type primitive from
7//!    [`net::adapter::net::cortex::tool`]. Downstream consumers
8//!    write `use net_sdk::tool::ToolDescriptor` rather than reaching
9//!    deep into the substrate-side module path.
10//! 2. [`metadata_for`] — builds a [`ToolDescriptor`] from any pair
11//!    of Rust request / response types implementing
12//!    [`schemars::JsonSchema`]. The schemas land on the descriptor
13//!    as JSON-encoded strings (matching `ToolCapability::input_schema`'s
14//!    existing shape).
15//! 3. [`ToolMetadataBuilder`] — a fluent builder for the fields
16//!    that aren't derivable from the type signature: description,
17//!    version, streaming flag, tags, stateless / latency hints.
18//!    Callers chain `metadata_for::<Req, Resp>(name).description(...)`
19//!    to build the descriptor in one expression.
20//!
21//! The actual `serve_tool` / `list_tools` / `watch_tools` /
22//! `call_tool` SDK methods land in subsequent A-2..A-6 slices; this
23//! one just establishes the type re-exports + the schema-derivation
24//! helper every later slice composes against.
25//!
26//! Plan: see `docs/plans/NRPC_AI_TOOL_CALLING_AND_AGENT_DX.md`,
27//! slice A-1.
28
29#[cfg(feature = "cortex")]
30pub use net::adapter::net::behavior::fold::capability_aggregation::{TagMatcher, TagMatcherError};
31#[cfg(feature = "cortex")]
32pub use net::adapter::net::cortex::tool::{
33    description_metadata_key, streaming_metadata_key, tags_metadata_key, ToolDescriptor, ToolEvent,
34    ToolListChange, ToolListWatch, ToolMetadataRegistry, ToolMetadataRequest, ToolMetadataResponse,
35    TOOL_METADATA_FETCH_SERVICE,
36};
37
38#[cfg(feature = "cortex")]
39use std::sync::Arc;
40
41#[cfg(feature = "cortex")]
42use crate::mesh::Mesh;
43#[cfg(feature = "cortex")]
44use crate::mesh_rpc::{Codec, ServeError, ServeHandle};
45#[cfg(feature = "cortex")]
46use serde::{de::DeserializeOwned, Serialize};
47
48/// Builder for a [`ToolDescriptor`] that derives its JSON Schema
49/// from Rust type parameters. Construct via [`metadata_for`], then
50/// chain setters for the fields that aren't derivable from the
51/// type signature (description, version, streaming, etc.).
52///
53/// Example:
54///
55/// ```ignore
56/// use net_sdk::tool::metadata_for;
57///
58/// #[derive(schemars::JsonSchema, serde::Deserialize)]
59/// struct WebSearchReq { query: String, max_results: u32 }
60///
61/// #[derive(schemars::JsonSchema, serde::Serialize)]
62/// struct WebSearchResp { results: Vec<String> }
63///
64/// let descriptor = metadata_for::<WebSearchReq, WebSearchResp>("web_search")
65///     .description("Search the web for relevant pages.")
66///     .stateless(true)
67///     .estimated_time_ms(500)
68///     .tag("web")
69///     .tag("research")
70///     .build();
71/// // hand the descriptor to `serve_tool` (lands in A-2).
72/// ```
73#[must_use = "ToolMetadataBuilder does nothing until `.build()` is called"]
74pub struct ToolMetadataBuilder {
75    descriptor: ToolDescriptor,
76}
77
78impl ToolMetadataBuilder {
79    /// Replace the default human-readable description. Mandatory for
80    /// any tool an LLM should reason about — the model reads this
81    /// field to decide when to call.
82    pub fn description(mut self, description: impl Into<String>) -> Self {
83        self.descriptor.description = Some(description.into());
84        self
85    }
86
87    /// Override the version (defaults to `"1.0.0"` from
88    /// `ToolCapability::new`). Two registrations of the same `name`
89    /// at different versions surface as separate descriptors in
90    /// `list_tools`.
91    pub fn version(mut self, version: impl Into<String>) -> Self {
92        self.descriptor.version = version.into();
93        self
94    }
95
96    /// Mark the tool as server-streaming (lowers into the future
97    /// `serve_tool_streaming` rather than the unary `serve_tool`).
98    /// Adapters use this flag to decide whether to render progress
99    /// + delta envelopes vs. one terminal result.
100    pub fn streaming(mut self, streaming: bool) -> Self {
101        self.descriptor.streaming = streaming;
102        self
103    }
104
105    /// Set the `stateless` flag. Pure-function tools (same input →
106    /// same output, no session state) get cached, retried in
107    /// parallel, etc. Stateful tools opt out.
108    pub fn stateless(mut self, stateless: bool) -> Self {
109        self.descriptor.stateless = stateless;
110        self
111    }
112
113    /// Soft latency hint for the model scheduler / UI spinner.
114    /// `0` means "no estimate" (the default).
115    pub fn estimated_time_ms(mut self, ms: u32) -> Self {
116        self.descriptor.estimated_time_ms = ms;
117        self
118    }
119
120    /// Append one tag. Free-form; adapters surface tags as
121    /// provider-specific metadata (e.g. Anthropic `cache_control`
122    /// hints).
123    pub fn tag(mut self, tag: impl Into<String>) -> Self {
124        self.descriptor.tags.push(tag.into());
125        self
126    }
127
128    /// Replace the tag list wholesale. Useful when the caller has
129    /// the tags as a `Vec` already.
130    pub fn tags(mut self, tags: Vec<String>) -> Self {
131        self.descriptor.tags = tags;
132        self
133    }
134
135    /// Append a required capability / dependency. Mirrors
136    /// `ToolCapability::requires`. Adapters can use this to surface
137    /// "tool needs X" dependencies (e.g. a `web_search` tool that
138    /// depends on a configured API key).
139    pub fn requires(mut self, dep: impl Into<String>) -> Self {
140        self.descriptor.requires.push(dep.into());
141        self
142    }
143
144    /// Consume the builder and return the finished [`ToolDescriptor`].
145    /// Pass this into the future `serve_tool` / `serve_tool_streaming`
146    /// methods (A-2 / A-3).
147    pub fn build(self) -> ToolDescriptor {
148        self.descriptor
149    }
150}
151
152/// Build a [`ToolMetadataBuilder`] for the given `(Req, Resp)` pair.
153/// Both types must implement [`schemars::JsonSchema`]; the helper
154/// derives JSON Schema (draft 2020-12) for each and stores them on
155/// the descriptor as JSON-encoded strings.
156///
157/// The `name` parameter is the nRPC service name; same string the
158/// caller will pass to `serve_tool` and the agent will see in
159/// `list_tools`.
160///
161/// Description defaults to an empty string; callers should chain
162/// [`ToolMetadataBuilder::description`] to set it before `.build()`.
163/// `version` defaults to `"1.0.0"`; `stateless` defaults to `true`
164/// (matching `ToolCapability::new`'s defaults); `streaming`
165/// defaults to `false`.
166pub fn metadata_for<Req, Resp>(name: impl Into<String>) -> ToolMetadataBuilder
167where
168    Req: schemars::JsonSchema,
169    Resp: schemars::JsonSchema,
170{
171    let name = name.into();
172    let input_schema = schemars::schema_for!(Req);
173    let output_schema = schemars::schema_for!(Resp);
174    let input_schema_json =
175        serde_json::to_string(&input_schema).expect("schemars output is always valid JSON");
176    let output_schema_json =
177        serde_json::to_string(&output_schema).expect("schemars output is always valid JSON");
178    ToolMetadataBuilder {
179        descriptor: ToolDescriptor {
180            tool_id: name.clone(),
181            name,
182            version: "1.0.0".to_string(),
183            description: None,
184            input_schema: Some(input_schema_json),
185            output_schema: Some(output_schema_json),
186            requires: Vec::new(),
187            estimated_time_ms: 0,
188            stateless: true,
189            streaming: false,
190            tags: Vec::new(),
191            node_count: 0,
192        },
193    }
194}
195
196// ============================================================================
197// ToolServeHandle — owns the typed-RPC `ServeHandle` and reverses the
198// tool_registry insert when dropped.
199// ============================================================================
200
201/// Returned by [`Mesh::serve_tool`]. Holds the underlying typed-RPC
202/// `ServeHandle` (which unregisters the handler on Drop) plus a
203/// clone of the `MeshNode`'s `tool_registry` so Drop can paired-
204/// remove the descriptor.
205///
206/// Lifecycle:
207/// - Construct via `Mesh::serve_tool(...)` — atomically registers
208///   the handler, inserts the descriptor, and (on the first
209///   `serve_tool` call) auto-installs the `tool.metadata.fetch`
210///   service handler.
211/// - On Drop:
212///     1. Remove the descriptor from `tool_registry` — the next
213///        `announce_capabilities` no longer emits the
214///        `ai-tool:<name>` tag.
215///     2. The inner `ServeHandle` drops, unregistering the nRPC
216///        handler.
217///
218/// The auto-installed `tool.metadata.fetch` service stays
219/// registered for the lifetime of the `Mesh`; it's harmless when
220/// the registry is empty (returns `NotFound` for every request).
221#[cfg(feature = "cortex")]
222pub struct ToolServeHandle {
223    /// Inner handle from `serve_rpc_typed`. Dropping it
224    /// unregisters the nRPC handler.
225    #[allow(dead_code)] // Held for Drop side effect.
226    inner: ServeHandle,
227    /// Tool registry the descriptor was inserted into. Drop's
228    /// remove path uses this — keeping the `Arc` clone ensures
229    /// the registry outlives the handle (otherwise the registry
230    /// could vanish if the `Mesh` was dropped between handle
231    /// construction and handle Drop).
232    registry: Arc<ToolMetadataRegistry>,
233    /// Name to remove on Drop. Stored separately because `inner`
234    /// keeps its own `service` field private to the substrate
235    /// crate.
236    tool_id: String,
237}
238
239#[cfg(feature = "cortex")]
240impl Drop for ToolServeHandle {
241    fn drop(&mut self) {
242        self.registry.remove(&self.tool_id);
243        // `inner` drops on its own and reverses the nRPC handler
244        // registration; we don't need to do anything else here.
245    }
246}
247
248#[cfg(feature = "cortex")]
249impl Mesh {
250    /// Atomically register `handler` as an AI tool:
251    ///
252    /// 1. The descriptor is inserted into the local
253    ///    `tool_registry` — subsequent `announce_capabilities`
254    ///    calls auto-emit the `ai-tool:<name>` tag, the typed
255    ///    `ToolCapability`, and the description / streaming /
256    ///    tags metadata keys (see A-2a).
257    /// 2. The handler is registered as an nRPC service at
258    ///    `descriptor.tool_id` via `serve_rpc_typed` — the
259    ///    substrate also tracks the service in `rpc_local_services`
260    ///    so subsequent announces include the `nrpc:<name>` tag.
261    /// 3. The first `serve_tool` call on this `Mesh` lazily
262    ///    installs the `tool.metadata.fetch` server handler so
263    ///    agents can pull the full descriptor for tools whose
264    ///    schemas were too large for the capability-fold payload
265    ///    budget. The install handle lives for the lifetime of
266    ///    the `Mesh`; subsequent `serve_tool` calls skip it.
267    ///
268    /// If step 2 fails, step 1 is rolled back — the registry
269    /// insert is paired-removed before the error returns, and the
270    /// auto-install (if it happened in this call) stays in place
271    /// (low cost; cleaning it up would race with concurrent
272    /// `serve_tool` calls).
273    ///
274    /// The returned [`ToolServeHandle`] reverses both registry
275    /// insert (step 1) and handler registration (step 2) on Drop.
276    ///
277    /// JSON codec is used unconditionally for AI tools — every
278    /// provider (OpenAI, Anthropic, Gemini, MCP) consumes JSON
279    /// for tool input/output. Wire-format consistency lets the
280    /// adapter packages in M-* lower descriptors and dispatched
281    /// tool-calls without per-tool codec negotiation.
282    pub fn serve_tool<Req, Resp, F, Fut>(
283        &self,
284        descriptor: ToolDescriptor,
285        handler: F,
286    ) -> std::result::Result<ToolServeHandle, ServeError>
287    where
288        Req: DeserializeOwned + Send + Sync + 'static,
289        Resp: Serialize + Send + Sync + 'static,
290        F: Fn(Req) -> Fut + Send + Sync + 'static,
291        Fut: std::future::Future<Output = std::result::Result<Resp, String>> + Send + 'static,
292    {
293        let tool_id = descriptor.tool_id.clone();
294        let registry = self.inner().tool_registry().clone();
295
296        // Step 1: registry insert. Done before the handler so the
297        // descriptor is observable to `tool.metadata.fetch` the
298        // moment the handler responds to its first call.
299        let prior = registry.insert(descriptor);
300        if let Some(prior) = prior {
301            // Reject duplicate registrations rather than silently
302            // overwriting — the prior handler still lives in
303            // `rpc_local_services` from its own `serve_rpc_typed`
304            // call; overwriting would leak that handler's
305            // `ServeHandle` Drop and surface confusing behavior
306            // (registry says X, handler answers Y).
307            registry.insert(prior);
308            return Err(ServeError::AlreadyServing(tool_id));
309        }
310
311        // Step 2: handler register. If this fails, paired-remove
312        // the descriptor we just inserted so the registry doesn't
313        // hold a phantom entry.
314        let inner = match self.serve_rpc_typed::<Req, Resp, _, _>(&tool_id, Codec::Json, handler) {
315            Ok(h) => h,
316            Err(e) => {
317                registry.remove(&tool_id);
318                return Err(e);
319            }
320        };
321
322        // Step 3: lazy auto-install of `tool.metadata.fetch`. The
323        // handler answers `{ name } -> ToolMetadataResponse` for
324        // any caller that wants the full descriptor (for schemas
325        // too large to fit in the capability-fold payload).
326        self.ensure_tool_metadata_fetch_installed();
327
328        Ok(ToolServeHandle {
329            inner,
330            registry,
331            tool_id,
332        })
333    }
334
335    /// Streaming variant of [`Self::serve_tool`]. The handler
336    /// returns a [`futures::Stream`] of [`ToolEvent`]s; the SDK
337    /// serializes each item as one JSON-encoded chunk on the
338    /// underlying `serve_rpc_streaming_typed` path.
339    ///
340    /// Contract for handlers:
341    ///
342    /// - Emit one terminal event ([`ToolEvent::Result`] or
343    ///   [`ToolEvent::Error`]) to close the stream cleanly. The SDK
344    ///   stops driving the user's stream the moment a terminal
345    ///   event is emitted — any items the handler tries to yield
346    ///   after a terminal are not transmitted.
347    /// - If the stream ends without a terminal event, the SDK
348    ///   synthesizes [`ToolEvent::Error`] with
349    ///   `code = "missing_terminal"` so callers can rely on every
350    ///   stream ending with a terminal envelope.
351    ///
352    /// `descriptor.streaming` is forced to `true` on registration —
353    /// the `tool::<id>::streaming` metadata key emitted by the
354    /// announce merge (A-2a) reflects the actual register path the
355    /// host took, not the value the caller built into the
356    /// descriptor.
357    ///
358    /// Atomicity, Drop-reverses, and lazy `tool.metadata.fetch`
359    /// install all behave the same as [`Self::serve_tool`].
360    pub fn serve_tool_streaming<Req, F, Fut, St>(
361        &self,
362        mut descriptor: ToolDescriptor,
363        handler: F,
364    ) -> std::result::Result<ToolServeHandle, ServeError>
365    where
366        Req: DeserializeOwned + Send + Sync + 'static,
367        F: Fn(Req) -> Fut + Send + Sync + 'static,
368        Fut: std::future::Future<Output = St> + Send + 'static,
369        St: futures::Stream<Item = ToolEvent> + Send + 'static,
370    {
371        // Force the streaming flag on so announces reflect reality
372        // even if the caller forgot `.streaming(true)` on the builder.
373        descriptor.streaming = true;
374        let tool_id = descriptor.tool_id.clone();
375        let registry = self.inner().tool_registry().clone();
376
377        // Step 1: registry insert (same paired-remove rollback on
378        // failure as `serve_tool`).
379        let prior = registry.insert(descriptor);
380        if let Some(prior) = prior {
381            registry.insert(prior);
382            return Err(ServeError::AlreadyServing(tool_id));
383        }
384
385        // Step 2: typed-streaming handler register. We drive the
386        // user's stream and emit each `ToolEvent` as one chunk via
387        // the typed sink. Terminal events stop the loop; if the
388        // stream ends without one, synthesize a `missing_terminal`
389        // `Error`.
390        let handler = Arc::new(handler);
391        let inner = match self
392            .serve_rpc_streaming_typed::<Req, ToolEvent, _, _>(&tool_id, Codec::Json, move |req, sink| {
393                let handler = handler.clone();
394                async move {
395                    use futures::StreamExt;
396                    let stream = handler(req).await;
397                    futures::pin_mut!(stream);
398                    let mut seen_terminal = false;
399                    while let Some(event) = stream.next().await {
400                        let terminal = event.is_terminal();
401                        sink.send(&event)
402                            .map_err(|e| format!("tool event send: {e}"))?;
403                        if terminal {
404                            seen_terminal = true;
405                            break;
406                        }
407                    }
408                    if !seen_terminal {
409                        let synthesized = ToolEvent::Error {
410                            code: "missing_terminal".to_string(),
411                            message:
412                                "tool handler ended its stream without emitting a terminal Result or Error event"
413                                    .to_string(),
414                            details: None,
415                        };
416                        sink.send(&synthesized)
417                            .map_err(|e| format!("synthesized terminal send: {e}"))?;
418                    }
419                    Ok(())
420                }
421            }) {
422            Ok(h) => h,
423            Err(e) => {
424                registry.remove(&tool_id);
425                return Err(e);
426            }
427        };
428
429        // Step 3: lazy auto-install of `tool.metadata.fetch`.
430        self.ensure_tool_metadata_fetch_installed();
431
432        Ok(ToolServeHandle {
433            inner,
434            registry,
435            tool_id,
436        })
437    }
438
439    /// Capability-routed unary tool call. Encodes `request` as JSON,
440    /// resolves a target node from `nrpc:<tool_id>` in the local
441    /// capability fold (via [`net::adapter::net::MeshNode::call_service`]),
442    /// awaits the typed `Resp`.
443    ///
444    /// Codec is JSON unconditionally — every AI provider (OpenAI,
445    /// Anthropic, Gemini, MCP) consumes JSON for tool input/output,
446    /// so the substrate enforces one codec for the whole tool surface.
447    /// Adapters can lower descriptors and dispatched calls without
448    /// per-tool codec negotiation.
449    ///
450    /// Returns `RpcError::NoRoute` if no host currently serves the
451    /// tool. Bubbles handler errors as `RpcError::ServerError` with
452    /// status `NRPC_TYPED_HANDLER_ERROR` carrying the handler's
453    /// error message.
454    pub async fn call_tool<Req, Resp>(
455        &self,
456        tool_id: &str,
457        request: &Req,
458    ) -> std::result::Result<Resp, crate::mesh_rpc::RpcError>
459    where
460        Req: serde::Serialize,
461        Resp: serde::de::DeserializeOwned,
462    {
463        self.call_service_typed::<Req, Resp>(
464            tool_id,
465            request,
466            crate::mesh_rpc::CallOptionsTyped {
467                raw: Default::default(),
468                codec: Codec::Json,
469            },
470        )
471        .await
472    }
473
474    /// Capability-routed streaming tool call. Encodes `request` as
475    /// JSON, opens a streaming call against `nrpc:<tool_id>` via
476    /// the substrate's `call_service_streaming` (S-1), returns an
477    /// [`crate::mesh_rpc::RpcStreamTyped<ToolEvent>`] that decodes
478    /// each chunk as a [`ToolEvent`].
479    ///
480    /// Stream lifecycle:
481    /// - Server emits zero or more `Start` / `Progress` / `Delta`
482    ///   envelopes, then exactly one terminal `Result` or `Error`.
483    ///   The SDK does NOT enforce this contract on the caller side
484    ///   — it surfaces the wire events verbatim. Adapters
485    ///   (`formats/anthropic`, `formats/openai`, etc.) own the
486    ///   contract enforcement.
487    /// - If the handler ends without a terminal event, the server-
488    ///   side wrapper synthesizes
489    ///   `ToolEvent::Error { code: "missing_terminal", ... }` — see
490    ///   [`Self::serve_tool_streaming`].
491    /// - Dropping the returned stream emits CANCEL to the server
492    ///   (substrate cancel-token contract).
493    pub async fn call_tool_streaming<Req>(
494        &self,
495        tool_id: &str,
496        request: &Req,
497    ) -> std::result::Result<crate::mesh_rpc::RpcStreamTyped<ToolEvent>, crate::mesh_rpc::RpcError>
498    where
499        Req: serde::Serialize,
500    {
501        self.call_service_streaming_typed::<Req, ToolEvent>(
502            tool_id,
503            request,
504            crate::mesh_rpc::CallOptionsTyped {
505                raw: Default::default(),
506                codec: Codec::Json,
507            },
508        )
509        .await
510    }
511
512    /// Walk the capability fold for every published AI tool and
513    /// return one [`ToolDescriptor`] per (tool_id, version) with
514    /// `node_count` filled in. One in-memory pass; no network.
515    ///
516    /// `matcher` is the standard substrate [`TagMatcher`] — an entry
517    /// is included if ANY of its tags match. Common shapes:
518    ///
519    /// - `None` — every tool the local fold has seen.
520    /// - `Some(TagMatcher::Prefix { value: "ai-tool:".into() })` —
521    ///   "every node advertising AT LEAST ONE AI tool" (filters out
522    ///   peers that don't publish any tool but otherwise pass the
523    ///   fold).
524    /// - `Some(TagMatcher::Prefix { value: "region.eu".into() })` —
525    ///   tools served by EU-region hosts.
526    ///
527    /// Delegates to
528    /// [`net::adapter::net::MeshNode::list_tools`](net::adapter::net::MeshNode::list_tools).
529    pub fn list_tools(&self, matcher: Option<&TagMatcher>) -> Vec<ToolDescriptor> {
530        self.inner().list_tools(matcher)
531    }
532
533    /// Subscribe to a stream of [`ToolListChange`] events for every
534    /// dynamic addition / removal / publisher-count change in the
535    /// local capability fold's tool view, filtered by `matcher`.
536    ///
537    /// Event-driven: a change is delivered the moment the capability
538    /// fold mutates (latency is bounded by fold-apply, not a timer),
539    /// and an idle fold does zero periodic work.
540    ///
541    /// `interval` is a *debounce ceiling*, not a poll cadence:
542    /// - `None` — pure event-driven; the watch only wakes on a real
543    ///   mutation.
544    /// - `Some(d)` — additionally guarantees a re-diff at least every
545    ///   `d` as a safety net, independent of the change signal.
546    ///
547    /// The returned [`ToolListWatch`] implements
548    /// `futures::Stream<Item = ToolListChange>`. Dropping it — or
549    /// calling [`ToolListWatch::cancel`] — ends the stream and stops
550    /// the underlying substrate task.
551    ///
552    /// First event fires AFTER the initial baseline snapshot — call
553    /// [`Self::list_tools`] first if you need the starting shape.
554    ///
555    /// Delegates to
556    /// [`net::adapter::net::MeshNode::watch_tools`](net::adapter::net::MeshNode::watch_tools).
557    pub fn watch_tools(
558        &self,
559        matcher: Option<TagMatcher>,
560        interval: Option<std::time::Duration>,
561    ) -> ToolListWatch {
562        self.node_arc().watch_tools(matcher, interval)
563    }
564
565    /// Idempotent — installs the `tool.metadata.fetch` nRPC
566    /// service handler if not yet present. Holds a `parking_lot`
567    /// mutex; the first caller through wins, the rest see
568    /// `Some(_)` and return immediately.
569    fn ensure_tool_metadata_fetch_installed(&self) {
570        let mut slot = self.tool_metadata_fetch.lock();
571        if slot.is_some() {
572            return;
573        }
574        let registry = self.node().tool_registry().clone();
575        let handler = move |req: ToolMetadataRequest| {
576            let registry = registry.clone();
577            async move {
578                Ok(match registry.get(&req.name) {
579                    Some(descriptor) => ToolMetadataResponse::Found { descriptor },
580                    None => ToolMetadataResponse::NotFound { name: req.name },
581                })
582            }
583        };
584        // If install fails (e.g. the service name's already taken
585        // by some manual `serve_rpc_typed` call — unlikely but
586        // possible), leave `slot` as `None`; subsequent
587        // `serve_tool` calls retry. The failure is silent here
588        // because (a) it's recoverable on retry, (b) it's surfaceable
589        // via `tool.metadata.fetch` returning NotFound (or transport
590        // errors) at the agent side, and (c) `ensure_*` is called
591        // from inside an infallible-returning `serve_tool` path —
592        // surfacing the error would require a fallible signature
593        // and complicate the happy path. The conflict is
594        // operator-misconfiguration, not transient failure.
595        if let Ok(handle) = self.serve_rpc_typed::<ToolMetadataRequest, ToolMetadataResponse, _, _>(
596            TOOL_METADATA_FETCH_SERVICE,
597            Codec::Json,
598            handler,
599        ) {
600            *slot = Some(handle);
601        }
602    }
603}
604
605// ============================================================================
606// Format translators
607// ============================================================================
608//
609// Lower a `ToolDescriptor` into a provider-native tool definition
610// shape (OpenAI, Anthropic, MCP). Pure functions, no transitive deps
611// beyond serde_json. Rust agent code that hits a provider's HTTP API
612// uses these to populate the `tools` array in its request payload.
613//
614// The plan's M-1/M-2/M-3 ship the same functionality in Python and
615// TypeScript packages; the Rust version here is the canonical
616// reference implementation. Cross-language tests (T-1) pin the JSON
617// shape across all three.
618
619#[cfg(feature = "cortex")]
620pub mod formats {
621    //! Provider-native tool-definition translators.
622    //!
623    //! Each submodule exports two directions of conversion:
624    //!
625    //! 1. `to_<provider>_tool(&ToolDescriptor) -> Value` —
626    //!    descriptor → provider's tool-definition shape, used to
627    //!    populate the `tools` array on a request to the provider's
628    //!    HTTP API.
629    //! 2. `lower_<provider>_tool_call(&Value) -> Result<ToolCallSpec, _>`
630    //!    — parse the provider's tool-call reply (OpenAI's
631    //!    `tool_calls[]`, Anthropic's `tool_use` content block, etc.)
632    //!    into a [`ToolCallSpec`] the agent can hand to
633    //!    `Mesh::call_tool(spec.name, &spec.arguments)`.
634    //!
635    //! All translators short-circuit on a missing `input_schema` by
636    //! emitting an empty-object schema (`{"type": "object",
637    //! "properties": {}}`). Providers reject a `null` parameter
638    //! schema in their strict-mode validators, but they all accept
639    //! the empty-properties object as "no arguments."
640    //!
641    //! The plan (M-1..M-4) defines parallel Python and TypeScript
642    //! packages with the same lowering; this Rust module is the
643    //! canonical reference. Cross-language tests (T-1) pin byte
644    //! equality.
645
646    use super::ToolDescriptor;
647    use serde_json::{json, Value};
648
649    /// One tool invocation parsed out of a provider's reply. The
650    /// canonical hand-off shape between an LLM-provider adapter and
651    /// `Mesh::call_tool` / `Mesh::call_tool_streaming`.
652    ///
653    /// `provider_call_id` round-trips the provider's own identifier
654    /// (OpenAI's `tool_calls[].id`, Anthropic's `tool_use.id`, MCP's
655    /// optional `id`). Adapters use it to correlate the tool-call
656    /// result back into the provider's expected reply shape (e.g.
657    /// `{"role": "tool", "tool_call_id": "<id>", "content": "..."}`).
658    /// `None` when the provider didn't supply one.
659    #[derive(Debug, Clone, PartialEq, Eq)]
660    pub struct ToolCallSpec {
661        /// nRPC tool_id to invoke. Matches `ToolDescriptor::tool_id` /
662        /// the `name` field every provider uses for its tool slot.
663        pub name: String,
664        /// JSON-encoded arguments to hand to `Mesh::call_tool`.
665        /// Stored as a string so the caller can either feed it
666        /// straight to a raw byte API or parse it with `serde_json`
667        /// — the parse vs. forward decision is provider-agnostic.
668        pub arguments_json: String,
669        /// Provider-supplied call id, when present. Adapters carry
670        /// this back into the tool-result reply so the LLM can
671        /// correlate the response.
672        pub provider_call_id: Option<String>,
673    }
674
675    /// Error returned when a provider's tool-call reply doesn't
676    /// match the expected shape (missing `name`, malformed
677    /// arguments, etc.). Each variant carries the field that was
678    /// missing or malformed so adapters can produce a tight
679    /// diagnostic.
680    #[derive(Debug, Clone, PartialEq, Eq)]
681    pub enum ToolCallParseError {
682        /// The provider's reply was missing a required field.
683        MissingField(&'static str),
684        /// A field's type didn't match what the provider's spec
685        /// promises (e.g. `name` was not a string).
686        WrongType {
687            /// Field name in the provider's reply shape.
688            field: &'static str,
689            /// What the spec requires.
690            expected: &'static str,
691        },
692        /// The provider sent a JSON-encoded arguments string that
693        /// failed to parse. Carried verbatim so the caller can
694        /// log the offender. (OpenAI's `function.arguments` is a
695        /// string of JSON, not a parsed object — adapters
696        /// double-encode/decode the boundary.)
697        InvalidArgumentsJson(String),
698    }
699
700    impl std::fmt::Display for ToolCallParseError {
701        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
702            match self {
703                Self::MissingField(name) => write!(f, "tool-call reply missing field `{name}`"),
704                Self::WrongType { field, expected } => write!(
705                    f,
706                    "tool-call reply field `{field}` had wrong type (expected {expected})"
707                ),
708                Self::InvalidArgumentsJson(detail) => {
709                    write!(f, "tool-call arguments were not valid JSON: {detail}")
710                }
711            }
712        }
713    }
714
715    impl std::error::Error for ToolCallParseError {}
716
717    /// Parse the descriptor's stored input schema (a JSON-encoded
718    /// string). Falls back to an empty-object schema if missing or
719    /// malformed — provider strict-mode validators require a
720    /// non-null `parameters` / `input_schema` field.
721    fn input_schema_value(desc: &ToolDescriptor) -> Value {
722        desc.input_schema
723            .as_deref()
724            .and_then(|s| serde_json::from_str::<Value>(s).ok())
725            .unwrap_or_else(|| json!({"type": "object", "properties": {}}))
726    }
727
728    /// Translators for the OpenAI Chat Completions / Responses API
729    /// `tools` array shape.
730    pub mod openai {
731        use super::*;
732
733        /// Lower a [`ToolDescriptor`] into an OpenAI tool definition.
734        ///
735        /// Wire shape:
736        /// ```json
737        /// {
738        ///   "type": "function",
739        ///   "function": {
740        ///     "name": "<tool_id>",
741        ///     "description": "<description>",
742        ///     "parameters": <input_schema>,
743        ///     "strict": <bool>
744        ///   }
745        /// }
746        /// ```
747        ///
748        /// `strict` is set to `true` when `descriptor.input_schema` was
749        /// publishable on the fold (i.e. not dropped due to size).
750        /// OpenAI's strict-mode tool calling requires the schema to be
751        /// present and conform to a subset of JSON Schema; we surface
752        /// it as a hint, not a guarantee — callers that explicitly
753        /// need non-strict can post-process the returned `Value`.
754        pub fn to_openai_tool(desc: &ToolDescriptor) -> Value {
755            let parameters = input_schema_value(desc);
756            let strict = desc.input_schema.is_some();
757            json!({
758                "type": "function",
759                "function": {
760                    "name": desc.tool_id,
761                    "description": desc.description.clone().unwrap_or_default(),
762                    "parameters": parameters,
763                    "strict": strict,
764                }
765            })
766        }
767
768        /// Parse one OpenAI `tool_calls[]` entry into a [`ToolCallSpec`].
769        /// OpenAI's reply shape is:
770        /// ```json
771        /// {
772        ///   "id": "<call_id>",
773        ///   "type": "function",
774        ///   "function": {
775        ///     "name": "<tool_id>",
776        ///     "arguments": "<JSON-encoded string>"
777        ///   }
778        /// }
779        /// ```
780        ///
781        /// `function.arguments` is a STRING containing JSON — the
782        /// OpenAI API doesn't parse it. The spec carries the string
783        /// verbatim so the caller can either forward to a raw byte
784        /// API or `serde_json::from_str` it. This sidesteps double
785        /// re-serialization on the happy path.
786        pub fn lower_openai_tool_call(call: &Value) -> Result<ToolCallSpec, ToolCallParseError> {
787            let function = call
788                .get("function")
789                .ok_or(ToolCallParseError::MissingField("function"))?;
790            let name = function
791                .get("name")
792                .ok_or(ToolCallParseError::MissingField("function.name"))?
793                .as_str()
794                .ok_or(ToolCallParseError::WrongType {
795                    field: "function.name",
796                    expected: "string",
797                })?
798                .to_string();
799            let arguments_json = function
800                .get("arguments")
801                .ok_or(ToolCallParseError::MissingField("function.arguments"))?
802                .as_str()
803                .ok_or(ToolCallParseError::WrongType {
804                    field: "function.arguments",
805                    expected: "string (JSON-encoded)",
806                })?
807                .to_string();
808            // Validate it parses; fail fast with a tight diagnostic
809            // rather than letting the malformed string ride through
810            // `call_tool` and surface as a server-side decode error.
811            if let Err(e) = serde_json::from_str::<Value>(&arguments_json) {
812                return Err(ToolCallParseError::InvalidArgumentsJson(format!("{e}")));
813            }
814            let provider_call_id = call.get("id").and_then(|v| v.as_str()).map(String::from);
815            Ok(ToolCallSpec {
816                name,
817                arguments_json,
818                provider_call_id,
819            })
820        }
821    }
822
823    /// Translators for the Anthropic Messages API `tools` array shape.
824    pub mod anthropic {
825        use super::*;
826
827        /// Lower a [`ToolDescriptor`] into an Anthropic tool
828        /// definition.
829        ///
830        /// Wire shape:
831        /// ```json
832        /// {
833        ///   "name": "<tool_id>",
834        ///   "description": "<description>",
835        ///   "input_schema": <input_schema>
836        /// }
837        /// ```
838        ///
839        /// Anthropic does not have a strict-mode flag at the tool
840        /// level (it relies on schema-validated tool inputs as the
841        /// default). `description` defaults to an empty string when
842        /// the descriptor omits one — Anthropic accepts it but a
843        /// real description materially affects the model's
844        /// tool-selection behavior, so callers should always set one.
845        pub fn to_anthropic_tool(desc: &ToolDescriptor) -> Value {
846            json!({
847                "name": desc.tool_id,
848                "description": desc.description.clone().unwrap_or_default(),
849                "input_schema": input_schema_value(desc),
850            })
851        }
852
853        /// Parse one Anthropic `tool_use` content block into a
854        /// [`ToolCallSpec`]. Block shape:
855        /// ```json
856        /// {
857        ///   "type": "tool_use",
858        ///   "id": "toolu_<id>",
859        ///   "name": "<tool_id>",
860        ///   "input": { … }
861        /// }
862        /// ```
863        ///
864        /// Anthropic's `input` is already a parsed object (unlike
865        /// OpenAI's string-encoded arguments), so the spec
866        /// re-serializes it once to preserve the
867        /// `arguments_json: String` contract on `ToolCallSpec`.
868        pub fn lower_anthropic_tool_use(block: &Value) -> Result<ToolCallSpec, ToolCallParseError> {
869            let name = block
870                .get("name")
871                .ok_or(ToolCallParseError::MissingField("name"))?
872                .as_str()
873                .ok_or(ToolCallParseError::WrongType {
874                    field: "name",
875                    expected: "string",
876                })?
877                .to_string();
878            let input = block
879                .get("input")
880                .ok_or(ToolCallParseError::MissingField("input"))?;
881            let arguments_json = serde_json::to_string(input)
882                .map_err(|e| ToolCallParseError::InvalidArgumentsJson(format!("{e}")))?;
883            let provider_call_id = block.get("id").and_then(|v| v.as_str()).map(String::from);
884            Ok(ToolCallSpec {
885                name,
886                arguments_json,
887                provider_call_id,
888            })
889        }
890    }
891
892    /// Translators for the Model Context Protocol (MCP) `tools/list`
893    /// response shape.
894    pub mod mcp {
895        use super::*;
896
897        /// Lower a [`ToolDescriptor`] into an MCP tool definition.
898        ///
899        /// Wire shape:
900        /// ```json
901        /// {
902        ///   "name": "<tool_id>",
903        ///   "description": "<description>",
904        ///   "inputSchema": <input_schema>
905        /// }
906        /// ```
907        ///
908        /// MCP's tool shape is the closest to our native
909        /// `ToolDescriptor` — same `name` field, same
910        /// JSON-Schema-shaped input descriptor, just camelCase
911        /// `inputSchema` (vs Anthropic's `input_schema`).
912        pub fn to_mcp_tool(desc: &ToolDescriptor) -> Value {
913            json!({
914                "name": desc.tool_id,
915                "description": desc.description.clone().unwrap_or_default(),
916                "inputSchema": input_schema_value(desc),
917            })
918        }
919
920        /// Parse an MCP `tools/call` request into a [`ToolCallSpec`].
921        /// Request params shape:
922        /// ```json
923        /// { "name": "<tool_id>", "arguments": { … } }
924        /// ```
925        ///
926        /// MCP requests don't carry a call_id at this layer (the
927        /// JSON-RPC envelope's `id` lives one level up). The spec
928        /// leaves `provider_call_id` as `None` — the caller is
929        /// expected to thread the JSON-RPC `id` separately.
930        pub fn lower_mcp_tools_call(params: &Value) -> Result<ToolCallSpec, ToolCallParseError> {
931            let name = params
932                .get("name")
933                .ok_or(ToolCallParseError::MissingField("name"))?
934                .as_str()
935                .ok_or(ToolCallParseError::WrongType {
936                    field: "name",
937                    expected: "string",
938                })?
939                .to_string();
940            let arguments = params
941                .get("arguments")
942                .ok_or(ToolCallParseError::MissingField("arguments"))?;
943            let arguments_json = serde_json::to_string(arguments)
944                .map_err(|e| ToolCallParseError::InvalidArgumentsJson(format!("{e}")))?;
945            Ok(ToolCallSpec {
946                name,
947                arguments_json,
948                provider_call_id: None,
949            })
950        }
951    }
952
953    /// Translators for the Google Gemini `generateContent` API
954    /// function-calling shape.
955    pub mod gemini {
956        use super::*;
957
958        /// Lower a [`ToolDescriptor`] into a Gemini
959        /// `FunctionDeclaration`.
960        ///
961        /// Wire shape (one entry in a
962        /// `tools[0].function_declarations[]` array):
963        /// ```json
964        /// {
965        ///   "name": "<tool_id>",
966        ///   "description": "<description>",
967        ///   "parameters": <input_schema>
968        /// }
969        /// ```
970        ///
971        /// Gemini wraps function declarations under
972        /// `tools: [{ function_declarations: [ … ] }]`; this helper
973        /// returns ONE declaration. The caller is responsible for
974        /// the outer wrapping — keeps the API symmetric with the
975        /// other provider translators.
976        pub fn to_gemini_function_declaration(desc: &ToolDescriptor) -> Value {
977            json!({
978                "name": desc.tool_id,
979                "description": desc.description.clone().unwrap_or_default(),
980                "parameters": input_schema_value(desc),
981            })
982        }
983
984        /// Parse one Gemini `functionCall` part into a
985        /// [`ToolCallSpec`]. Part shape:
986        /// ```json
987        /// { "name": "<tool_id>", "args": { … } }
988        /// ```
989        ///
990        /// Gemini doesn't supply a call id — the spec's
991        /// `provider_call_id` is `None`. Multi-call sequences are
992        /// identified positionally by their index in the model's
993        /// reply.
994        pub fn lower_gemini_function_call(
995            call: &Value,
996        ) -> Result<ToolCallSpec, ToolCallParseError> {
997            let name = call
998                .get("name")
999                .ok_or(ToolCallParseError::MissingField("name"))?
1000                .as_str()
1001                .ok_or(ToolCallParseError::WrongType {
1002                    field: "name",
1003                    expected: "string",
1004                })?
1005                .to_string();
1006            let args = call
1007                .get("args")
1008                .ok_or(ToolCallParseError::MissingField("args"))?;
1009            let arguments_json = serde_json::to_string(args)
1010                .map_err(|e| ToolCallParseError::InvalidArgumentsJson(format!("{e}")))?;
1011            Ok(ToolCallSpec {
1012                name,
1013                arguments_json,
1014                provider_call_id: None,
1015            })
1016        }
1017    }
1018}
1019
1020// ============================================================================
1021// Tests
1022// ============================================================================
1023
1024#[cfg(test)]
1025mod tests {
1026    use super::*;
1027    use schemars::JsonSchema;
1028    use serde::{Deserialize, Serialize};
1029
1030    #[derive(JsonSchema, Deserialize, Serialize)]
1031    #[allow(dead_code)]
1032    struct WebSearchReq {
1033        /// The query string.
1034        query: String,
1035        /// Maximum results to return.
1036        max_results: u32,
1037    }
1038
1039    #[derive(JsonSchema, Deserialize, Serialize)]
1040    #[allow(dead_code)]
1041    struct WebSearchResp {
1042        results: Vec<String>,
1043    }
1044
1045    #[test]
1046    fn metadata_for_derives_schemas_and_sets_defaults() {
1047        let descriptor = metadata_for::<WebSearchReq, WebSearchResp>("web_search").build();
1048        assert_eq!(descriptor.tool_id, "web_search");
1049        assert_eq!(descriptor.name, "web_search");
1050        assert_eq!(descriptor.version, "1.0.0");
1051        assert!(descriptor.description.is_none());
1052        assert!(descriptor.stateless);
1053        assert!(!descriptor.streaming);
1054        assert_eq!(descriptor.estimated_time_ms, 0);
1055        assert_eq!(descriptor.node_count, 0);
1056        assert!(descriptor.tags.is_empty());
1057        assert!(descriptor.requires.is_empty());
1058
1059        // Schemas must be present + parse as valid JSON.
1060        let input = descriptor
1061            .input_schema
1062            .as_ref()
1063            .expect("input schema present");
1064        let parsed: serde_json::Value =
1065            serde_json::from_str(input).expect("input schema must be valid JSON");
1066        // Object with `query` + `max_results` properties.
1067        let props = parsed
1068            .get("properties")
1069            .expect("object schema has properties");
1070        assert!(props.get("query").is_some());
1071        assert!(props.get("max_results").is_some());
1072
1073        let output = descriptor
1074            .output_schema
1075            .as_ref()
1076            .expect("output schema present");
1077        let _: serde_json::Value =
1078            serde_json::from_str(output).expect("output schema must be valid JSON");
1079    }
1080
1081    #[test]
1082    fn builder_setters_apply_in_chain() {
1083        let descriptor = metadata_for::<WebSearchReq, WebSearchResp>("web_search")
1084            .description("Search the web.")
1085            .version("2.1.0")
1086            .streaming(true)
1087            .stateless(false)
1088            .estimated_time_ms(500)
1089            .tag("web")
1090            .tag("research")
1091            .requires("api_key:tavily")
1092            .build();
1093        assert_eq!(descriptor.description.as_deref(), Some("Search the web."));
1094        assert_eq!(descriptor.version, "2.1.0");
1095        assert!(descriptor.streaming);
1096        assert!(!descriptor.stateless);
1097        assert_eq!(descriptor.estimated_time_ms, 500);
1098        assert_eq!(descriptor.tags, vec!["web", "research"]);
1099        assert_eq!(descriptor.requires, vec!["api_key:tavily"]);
1100    }
1101
1102    #[test]
1103    fn builder_tags_replaces_wholesale() {
1104        let descriptor = metadata_for::<WebSearchReq, WebSearchResp>("web_search")
1105            .tag("first")
1106            .tags(vec!["replaced".into(), "second".into()])
1107            .build();
1108        // `tags(...)` wholesale replaces — `first` is gone.
1109        assert_eq!(descriptor.tags, vec!["replaced", "second"]);
1110    }
1111
1112    fn sample_descriptor() -> ToolDescriptor {
1113        metadata_for::<WebSearchReq, WebSearchResp>("web_search")
1114            .description("Search the web.")
1115            .build()
1116    }
1117
1118    #[test]
1119    fn openai_tool_has_function_type_and_strict_when_schema_present() {
1120        let desc = sample_descriptor();
1121        let tool = formats::openai::to_openai_tool(&desc);
1122        assert_eq!(tool["type"], "function");
1123        let function = &tool["function"];
1124        assert_eq!(function["name"], "web_search");
1125        assert_eq!(function["description"], "Search the web.");
1126        assert_eq!(function["strict"], true);
1127        // Parameters carry the schema's `properties` block.
1128        let params = &function["parameters"];
1129        assert!(
1130            params["properties"]["query"].is_object(),
1131            "input_schema's `query` property must surface in parameters",
1132        );
1133    }
1134
1135    #[test]
1136    fn anthropic_tool_carries_input_schema_directly() {
1137        let desc = sample_descriptor();
1138        let tool = formats::anthropic::to_anthropic_tool(&desc);
1139        assert_eq!(tool["name"], "web_search");
1140        assert_eq!(tool["description"], "Search the web.");
1141        // Anthropic uses `input_schema` (snake_case).
1142        let schema = &tool["input_schema"];
1143        assert!(schema["properties"]["query"].is_object());
1144        assert!(
1145            tool.get("strict").is_none(),
1146            "Anthropic has no tool-level strict flag"
1147        );
1148    }
1149
1150    #[test]
1151    fn mcp_tool_uses_input_schema_camelcase() {
1152        let desc = sample_descriptor();
1153        let tool = formats::mcp::to_mcp_tool(&desc);
1154        assert_eq!(tool["name"], "web_search");
1155        assert_eq!(tool["description"], "Search the web.");
1156        // MCP uses `inputSchema` (camelCase) — pinned by the spec.
1157        let schema = &tool["inputSchema"];
1158        assert!(schema["properties"]["query"].is_object());
1159    }
1160
1161    #[test]
1162    fn gemini_function_declaration_uses_parameters_field() {
1163        let desc = sample_descriptor();
1164        let decl = formats::gemini::to_gemini_function_declaration(&desc);
1165        assert_eq!(decl["name"], "web_search");
1166        assert_eq!(decl["description"], "Search the web.");
1167        // Gemini uses `parameters` (same key as OpenAI, no wrapping
1168        // `function` envelope). The schema rides directly underneath.
1169        let params = &decl["parameters"];
1170        assert!(params["properties"]["query"].is_object());
1171    }
1172
1173    #[test]
1174    fn openai_lower_tool_call_extracts_name_and_arguments() {
1175        use formats::openai::lower_openai_tool_call;
1176        let call = serde_json::json!({
1177            "id": "call_abc123",
1178            "type": "function",
1179            "function": {
1180                "name": "web_search",
1181                "arguments": "{\"query\":\"mesh\"}"
1182            }
1183        });
1184        let spec = lower_openai_tool_call(&call).expect("valid call parses");
1185        assert_eq!(spec.name, "web_search");
1186        assert_eq!(spec.arguments_json, "{\"query\":\"mesh\"}");
1187        assert_eq!(spec.provider_call_id.as_deref(), Some("call_abc123"));
1188    }
1189
1190    #[test]
1191    fn openai_lower_tool_call_rejects_invalid_arguments_json() {
1192        use formats::openai::lower_openai_tool_call;
1193        use formats::ToolCallParseError;
1194        let call = serde_json::json!({
1195            "function": {
1196                "name": "x",
1197                "arguments": "not valid json {"
1198            }
1199        });
1200        match lower_openai_tool_call(&call) {
1201            Err(ToolCallParseError::InvalidArgumentsJson(_)) => {}
1202            other => panic!("expected InvalidArgumentsJson, got {other:?}"),
1203        }
1204    }
1205
1206    #[test]
1207    fn anthropic_lower_tool_use_serializes_input_object() {
1208        use formats::anthropic::lower_anthropic_tool_use;
1209        let block = serde_json::json!({
1210            "type": "tool_use",
1211            "id": "toolu_xyz",
1212            "name": "web_search",
1213            "input": { "query": "mesh", "max_results": 5 }
1214        });
1215        let spec = lower_anthropic_tool_use(&block).expect("valid block parses");
1216        assert_eq!(spec.name, "web_search");
1217        // Re-parse to verify shape — the exact key ordering in
1218        // serde_json output isn't guaranteed, so don't byte-compare.
1219        let parsed: serde_json::Value =
1220            serde_json::from_str(&spec.arguments_json).expect("arguments round-trip JSON");
1221        assert_eq!(parsed["query"], "mesh");
1222        assert_eq!(parsed["max_results"], 5);
1223        assert_eq!(spec.provider_call_id.as_deref(), Some("toolu_xyz"));
1224    }
1225
1226    #[test]
1227    fn mcp_lower_tools_call_threads_arguments_through() {
1228        use formats::mcp::lower_mcp_tools_call;
1229        let params = serde_json::json!({
1230            "name": "web_search",
1231            "arguments": { "query": "mesh" }
1232        });
1233        let spec = lower_mcp_tools_call(&params).expect("valid params parse");
1234        assert_eq!(spec.name, "web_search");
1235        let parsed: serde_json::Value =
1236            serde_json::from_str(&spec.arguments_json).expect("arguments round-trip JSON");
1237        assert_eq!(parsed["query"], "mesh");
1238        // MCP request params don't carry a call_id at this layer.
1239        assert!(spec.provider_call_id.is_none());
1240    }
1241
1242    #[test]
1243    fn gemini_lower_function_call_handles_args_field() {
1244        use formats::gemini::lower_gemini_function_call;
1245        let call = serde_json::json!({
1246            "name": "web_search",
1247            "args": { "query": "mesh" }
1248        });
1249        let spec = lower_gemini_function_call(&call).expect("valid call parses");
1250        assert_eq!(spec.name, "web_search");
1251        let parsed: serde_json::Value =
1252            serde_json::from_str(&spec.arguments_json).expect("arguments round-trip JSON");
1253        assert_eq!(parsed["query"], "mesh");
1254        assert!(spec.provider_call_id.is_none(), "Gemini has no call_id");
1255    }
1256
1257    #[test]
1258    fn formats_handle_missing_input_schema_with_empty_object() {
1259        // Build a descriptor with a None input schema (manual
1260        // construction since `metadata_for` always derives one).
1261        let desc = ToolDescriptor {
1262            tool_id: "no_schema_tool".into(),
1263            name: "no_schema_tool".into(),
1264            version: "1.0.0".into(),
1265            description: Some("Bare tool.".into()),
1266            input_schema: None,
1267            output_schema: None,
1268            requires: Vec::new(),
1269            estimated_time_ms: 0,
1270            stateless: true,
1271            streaming: false,
1272            tags: Vec::new(),
1273            node_count: 0,
1274        };
1275        // Empty-object fallback prevents provider validators from
1276        // rejecting a null schema.
1277        let openai = formats::openai::to_openai_tool(&desc);
1278        assert_eq!(openai["function"]["parameters"]["type"], "object");
1279        assert_eq!(openai["function"]["strict"], false);
1280        let anthropic = formats::anthropic::to_anthropic_tool(&desc);
1281        assert_eq!(anthropic["input_schema"]["type"], "object");
1282        let mcp = formats::mcp::to_mcp_tool(&desc);
1283        assert_eq!(mcp["inputSchema"]["type"], "object");
1284        let gemini = formats::gemini::to_gemini_function_declaration(&desc);
1285        assert_eq!(gemini["parameters"]["type"], "object");
1286    }
1287}