Skip to main content

somatize_core/
tool.rs

1//! How a tool describes itself.
2//!
3//! Soma sits on both sides of this: it *publishes* tools over MCP
4//! (`soma-mcp`), and it *calls* tools on a model's behalf (`soma-llm`).
5//! Those are the same description, so they are the same type — describe a
6//! tool once and it works in either direction.
7//!
8//! The wire form is MCP's (`inputSchema`, camelCase), because that is the
9//! one with a specification. Providers that want a different envelope build
10//! it at their own edge, which is where provider shape belongs.
11
12use serde::{Deserialize, Serialize};
13
14/// A tool's name, purpose, and argument schema.
15#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
16pub struct ToolSpec {
17    /// The name a model calls it by — what [`crate::effect::Effect::Tool`]
18    /// and a [`crate::message::ContentBlock::ToolUse`] carry.
19    pub name: String,
20
21    /// What it does — this is the text a model reads to decide whether to
22    /// call it, so it is prompt, not documentation. Say *when* to use the
23    /// tool, not only what it does: trigger conditions measurably raise the
24    /// rate at which a model reaches for the right one.
25    pub description: String,
26
27    /// JSON Schema for the arguments.
28    ///
29    /// `inputSchema` on the wire (MCP's spelling), `input_schema` also
30    /// accepted so hand-written JSON in either convention loads.
31    #[serde(rename = "inputSchema", alias = "input_schema")]
32    pub input_schema: serde_json::Value,
33}
34
35impl ToolSpec {
36    /// A fully described tool. For one with nothing to configure, see
37    /// [`Self::no_args`].
38    pub fn new(
39        name: impl Into<String>,
40        description: impl Into<String>,
41        input_schema: serde_json::Value,
42    ) -> Self {
43        Self {
44            name: name.into(),
45            description: description.into(),
46            input_schema,
47        }
48    }
49
50    /// A tool taking no arguments.
51    pub fn no_args(name: impl Into<String>, description: impl Into<String>) -> Self {
52        Self::new(
53            name,
54            description,
55            serde_json::json!({"type": "object", "properties": {}}),
56        )
57    }
58
59    /// The argument names the schema marks required.
60    pub fn required_args(&self) -> Vec<&str> {
61        self.input_schema
62            .get("required")
63            .and_then(|r| r.as_array())
64            .map(|a| a.iter().filter_map(|v| v.as_str()).collect())
65            .unwrap_or_default()
66    }
67}
68
69#[cfg(test)]
70mod tests {
71    use super::*;
72
73    /// MCP's spelling is the one that goes out; both come in.
74    #[test]
75    fn the_wire_form_is_mcp_camel_case() {
76        let spec = ToolSpec::new(
77            "search",
78            "Search the web. Call this when the answer depends on current information.",
79            serde_json::json!({"type": "object", "required": ["q"]}),
80        );
81
82        let json = serde_json::to_value(&spec).unwrap();
83        assert!(json.get("inputSchema").is_some(), "{json}");
84        assert!(json.get("input_schema").is_none(), "{json}");
85
86        // And a hand-written snake_case definition still loads.
87        let snake: ToolSpec = serde_json::from_value(serde_json::json!({
88            "name": "search",
89            "description": "d",
90            "input_schema": {"type": "object"}
91        }))
92        .unwrap();
93        assert_eq!(snake.name, "search");
94
95        assert_eq!(
96            serde_json::from_value::<ToolSpec>(json).unwrap(),
97            spec,
98            "the wire form should round-trip"
99        );
100    }
101
102    #[test]
103    fn required_arguments_are_readable() {
104        let spec = ToolSpec::new(
105            "f",
106            "d",
107            serde_json::json!({"type": "object", "required": ["a", "b"]}),
108        );
109        assert_eq!(spec.required_args(), vec!["a", "b"]);
110        assert!(ToolSpec::no_args("g", "d").required_args().is_empty());
111    }
112}