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(¶ms).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}