Skip to main content

kernel/capabilities/
tools.rs

1//! Tool-calling wire types: a `ToolSpec` a model may call and a `ToolCall` it
2//! emits. The `ChatMessage`-facing parsing layer (request parsing, transcript
3//! inlining) lands with the chat-wire unit; this is the core the runtime
4//! adapters and the tool-call chunk need.
5
6use serde::{Deserialize, Serialize};
7
8use crate::records::JsonValue;
9
10/// A tool a model may be offered: a name, a description, and a JSON-Schema
11/// parameter object.
12#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
13pub struct ToolSpec {
14    pub name: String,
15    pub description: String,
16    pub parameters: JsonValue,
17}
18
19impl ToolSpec {
20    /// A tool spec.
21    pub fn new(
22        name: impl Into<String>,
23        description: impl Into<String>,
24        parameters: JsonValue,
25    ) -> Self {
26        Self {
27            name: name.into(),
28            description: description.into(),
29            parameters,
30        }
31    }
32
33    /// The `{name, description, parameters}` payload form.
34    pub fn payload_value(&self) -> JsonValue {
35        object([
36            ("name", JsonValue::String(self.name.clone())),
37            ("description", JsonValue::String(self.description.clone())),
38            ("parameters", self.parameters.clone()),
39        ])
40    }
41
42    /// Parse a spec from its payload form, or `None` if it has no name.
43    pub fn from_payload(value: &JsonValue) -> Option<Self> {
44        let fields = value.as_object()?;
45        let name = fields.get("name").and_then(JsonValue::as_str)?;
46        Some(Self {
47            name: name.to_owned(),
48            description: fields
49                .get("description")
50                .and_then(JsonValue::as_str)
51                .unwrap_or("")
52                .to_owned(),
53            parameters: fields
54                .get("parameters")
55                .cloned()
56                .unwrap_or_else(empty_object),
57        })
58    }
59
60    /// Parse an array of specs, dropping any that don't parse.
61    pub fn from_payload_array(value: Option<&JsonValue>) -> Vec<Self> {
62        value
63            .and_then(JsonValue::as_array)
64            .map(|entries| entries.iter().filter_map(Self::from_payload).collect())
65            .unwrap_or_default()
66    }
67}
68
69/// A tool invocation a model emitted: a call id, a tool name, and a JSON
70/// arguments object.
71#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
72pub struct ToolCall {
73    pub id: String,
74    pub name: String,
75    pub arguments: JsonValue,
76}
77
78impl ToolCall {
79    /// A call with a freshly generated id.
80    pub fn new(name: impl Into<String>, arguments: JsonValue) -> Self {
81        Self::with_id(new_call_id(), name, arguments)
82    }
83
84    /// A call with an explicit id.
85    pub fn with_id(id: impl Into<String>, name: impl Into<String>, arguments: JsonValue) -> Self {
86        Self {
87            id: id.into(),
88            name: name.into(),
89            arguments,
90        }
91    }
92
93    /// The `{id, name, arguments}` payload form.
94    pub fn payload_value(&self) -> JsonValue {
95        object([
96            ("id", JsonValue::String(self.id.clone())),
97            ("name", JsonValue::String(self.name.clone())),
98            ("arguments", self.arguments.clone()),
99        ])
100    }
101
102    /// Parse a call from its payload form. Requires a name and an object
103    /// `arguments` (defaulting to `{}`); a fresh id is minted if none is given.
104    pub fn from_payload(value: &JsonValue) -> Option<Self> {
105        let fields = value.as_object()?;
106        let name = fields.get("name").and_then(JsonValue::as_str)?;
107        let arguments = fields
108            .get("arguments")
109            .cloned()
110            .unwrap_or_else(empty_object);
111        if !matches!(arguments, JsonValue::Object(_)) {
112            return None;
113        }
114        let id = fields
115            .get("id")
116            .and_then(JsonValue::as_str)
117            .filter(|id| !id.is_empty());
118        Some(match id {
119            Some(id) => Self::with_id(id, name, arguments),
120            None => Self::new(name, arguments),
121        })
122    }
123}
124
125fn object<const N: usize>(pairs: [(&str, JsonValue); N]) -> JsonValue {
126    JsonValue::Object(pairs.into_iter().map(|(k, v)| (k.to_owned(), v)).collect())
127}
128
129fn empty_object() -> JsonValue {
130    JsonValue::Object(std::collections::BTreeMap::new())
131}
132
133fn new_call_id() -> String {
134    use std::hash::{BuildHasher, Hasher};
135    let entropy = std::collections::hash_map::RandomState::new()
136        .build_hasher()
137        .finish();
138    format!("call_{}", hex::encode(entropy.to_le_bytes()))
139}