Skip to main content

protocol_droid/domain/acp/
tool_call.rs

1//! `domain::acp::tool_call` — the tool call shapes the
2//! `session/update` notification's `tool_call` and
3//! `tool_call_update` variants carry.
4//!
5//! Source of truth:
6//! `crates/protocol-droid-schema/spec/acp/v1.25.0/schema.json`
7//! under `$defs.ToolCall`, `$defs.ToolCallUpdate`,
8//! `$defs.ToolKind`, `$defs.ToolCallStatus`,
9//! `$defs.ToolCallContent`, `$defs.ToolCallLocation`,
10//! `$defs.Content`, `$defs.Diff`, `$defs.Terminal`, and
11//! the related id types.
12//!
13//! # The create / update split
14//!
15//! ACP models tool calls as a two-phase protocol: the
16//! agent first sends a `ToolCall` (the full shape), and
17//! then sends a stream of `ToolCallUpdate` notifications
18//! to report progress. The update shape is "all fields
19//! except `toolCallId` are optional" — the consumer
20//! merges the update onto the existing call by id. The
21//! typed form is two distinct structs (rather than a
22//! single struct with an `Option<Option<T>>` per field)
23//! because the create and update shapes differ in their
24//! optionality: a `ToolCall`'s `title` is required; a
25//! `ToolCallUpdate`'s `title` is patch-style
26//! (`Some(value)` to set, `None` to leave alone,
27//! explicit `null` to clear).
28//!
29//! # The content union
30//!
31//! A tool call's `content` is a `Vec<ToolCallContent>`.
32//! `ToolCallContent` is a discriminated union of three
33//! shapes: a content block (the standard text / image /
34//! resource block), a file diff, and a reference to a
35//! `terminal/create`d terminal the agent embedded.
36
37use std::collections::BTreeMap;
38
39use serde::{Deserialize, Serialize};
40
41use super::content::ContentBlock;
42
43/// A unique identifier for a tool call within a
44/// session. The schema's `ToolCallId` is `type: "string"`
45/// — opaque to the protocol. The newtype gives the API
46/// a self-documenting handle.
47#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
48#[serde(transparent)]
49pub struct ToolCallId(pub String);
50
51impl ToolCallId {
52    /// A `ToolCallId` from any string the schema accepts.
53    pub fn new(value: impl Into<String>) -> Self {
54        Self(value.into())
55    }
56}
57
58impl core::fmt::Display for ToolCallId {
59    fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
60        core::fmt::Display::fmt(&self.0, formatter)
61    }
62}
63
64/// A unique identifier for a terminal instance. The
65/// schema's `TerminalId` is `type: "string"`; the
66/// newtype is a typed handle.
67#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
68#[serde(transparent)]
69pub struct TerminalId(pub String);
70
71impl TerminalId {
72    /// A `TerminalId` from any string the schema accepts.
73    pub fn new(value: impl Into<String>) -> Self {
74        Self(value.into())
75    }
76}
77
78impl core::fmt::Display for TerminalId {
79    fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
80        core::fmt::Display::fmt(&self.0, formatter)
81    }
82}
83
84/// Categories of tools the agent can invoke. The
85/// schema names ten values; the typed form is the
86/// same ten-value space. `Copy` because the wire
87/// form is a single string.
88#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
89#[serde(rename_all = "snake_case")]
90pub enum ToolKind {
91    /// Reading files or data.
92    Read,
93    /// Modifying files or content.
94    Edit,
95    /// Removing files or data.
96    Delete,
97    /// Moving or renaming files.
98    Move,
99    /// Searching for information.
100    Search,
101    /// Running commands or code.
102    Execute,
103    /// Internal reasoning or planning.
104    Think,
105    /// Retrieving external data.
106    Fetch,
107    /// Switching the current session mode.
108    SwitchMode,
109    /// Other tool types. The default.
110    Other,
111}
112
113/// Execution status of a tool call. The schema names
114/// four values.
115#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
116#[serde(rename_all = "snake_case")]
117pub enum ToolCallStatus {
118    /// The tool call hasn't started running yet
119    /// because the input is streaming or the call
120    /// is awaiting approval.
121    Pending,
122    /// The tool call is currently running.
123    InProgress,
124    /// The tool call completed successfully.
125    Completed,
126    /// The tool call failed with an error.
127    Failed,
128}
129
130/// A complete tool call. The agent sends this in
131/// the `tool_call` variant of `SessionUpdate` when
132/// the tool call starts.
133#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
134#[serde(rename_all = "camelCase")]
135pub struct ToolCall {
136    /// Unique identifier for this tool call within
137    /// the session. The agent picks it; the client
138    /// uses it to correlate `ToolCallUpdate`s.
139    pub tool_call_id: ToolCallId,
140    /// Human-readable title describing what the
141    /// tool is doing.
142    pub title: String,
143    /// Programmatic name of the tool being invoked.
144    /// Omitting it or sending `null` both mean that
145    /// no tool name is available.
146    #[serde(default, skip_serializing_if = "Option::is_none")]
147    pub name: Option<String>,
148    /// The category of tool. Helps clients choose
149    /// appropriate icons and UI treatment.
150    #[serde(default, skip_serializing_if = "Option::is_none")]
151    pub kind: Option<ToolKind>,
152    /// Current execution status.
153    #[serde(default, skip_serializing_if = "Option::is_none")]
154    pub status: Option<ToolCallStatus>,
155    /// Content produced by the tool call. The
156    /// schema's `x-deserialize-skip-invalid-items`
157    /// means a malformed item is silently dropped.
158    #[serde(default, skip_serializing_if = "Option::is_none")]
159    pub content: Option<Vec<ToolCallContent>>,
160    /// File locations affected by this tool call.
161    /// Enables "follow-along" features in clients.
162    #[serde(default, skip_serializing_if = "Option::is_none")]
163    pub locations: Option<Vec<ToolCallLocation>>,
164    /// Raw input parameters sent to the tool. The
165    /// agent's own shape; the typed form is the
166    /// untyped `Value` so the schema's open
167    /// `additionalProperties: true` survives.
168    #[serde(default, skip_serializing_if = "Option::is_none")]
169    pub raw_input: Option<serde_json::Value>,
170    /// Raw output returned by the tool.
171    #[serde(default, skip_serializing_if = "Option::is_none")]
172    pub raw_output: Option<serde_json::Value>,
173}
174
175/// An update to an existing tool call. All fields
176/// except `toolCallId` are optional — only changed
177/// fields need to be included. The consumer merges
178/// the update onto the existing call by id.
179#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
180#[serde(rename_all = "camelCase")]
181pub struct ToolCallUpdate {
182    /// The id of the tool call being updated.
183    pub tool_call_id: ToolCallId,
184    /// Update the tool kind. `Some(None)` means
185    /// "clear"; `None` means "leave alone".
186    #[serde(default, skip_serializing_if = "Option::is_none")]
187    pub kind: Option<Option<ToolKind>>,
188    /// Update the execution status. `Some(None)`
189    /// means "clear".
190    #[serde(default, skip_serializing_if = "Option::is_none")]
191    pub status: Option<Option<ToolCallStatus>>,
192    /// Update the human-readable title.
193    #[serde(default, skip_serializing_if = "Option::is_none")]
194    pub title: Option<Option<String>>,
195    /// Update the programmatic name.
196    #[serde(default, skip_serializing_if = "Option::is_none")]
197    pub name: Option<Option<String>>,
198    /// Replace the content collection. The
199    /// `Option<Vec<ToolCallContent>>` is the
200    /// wire-level "replace" semantics: `None`
201    /// leaves the old content alone; `Some(vec)`
202    /// replaces it.
203    #[serde(default, skip_serializing_if = "Option::is_none")]
204    pub content: Option<Vec<ToolCallContent>>,
205    /// Replace the locations collection.
206    #[serde(default, skip_serializing_if = "Option::is_none")]
207    pub locations: Option<Vec<ToolCallLocation>>,
208    /// Update the raw input.
209    #[serde(default, skip_serializing_if = "Option::is_none")]
210    pub raw_input: Option<serde_json::Value>,
211    /// Update the raw output.
212    #[serde(default, skip_serializing_if = "Option::is_none")]
213    pub raw_output: Option<serde_json::Value>,
214}
215
216/// Content produced by a tool call. A discriminated
217/// union of three shapes: a content block, a file
218/// diff, and a reference to an embedded terminal.
219#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
220#[serde(tag = "type", rename_all = "snake_case")]
221pub enum ToolCallContent {
222    /// A standard content block (text, image, or
223    /// embedded resource).
224    Content(ContentBlockWrapper),
225    /// A file modification shown as a diff.
226    Diff(Diff),
227    /// A reference to a `terminal/create`d terminal
228    /// the agent embedded in the content stream.
229    Terminal(TerminalRef),
230}
231
232/// A wrapper around a `ContentBlock` for the
233/// `content` variant of `ToolCallContent`.
234///
235/// The schema's `Content` type is a single-key
236/// object `{ "content": <ContentBlock> }`; the
237/// wrapper keeps the typed form aligned with that
238/// shape.
239#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
240#[serde(rename_all = "camelCase")]
241pub struct ContentBlockWrapper {
242    /// The actual content block.
243    pub content: ContentBlock,
244}
245
246/// A diff representing a file modification. The
247/// schema's `Diff` carries the absolute path, the
248/// old text (omitted for new files), and the new
249/// text.
250#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
251#[serde(rename_all = "camelCase")]
252pub struct Diff {
253    /// The absolute file path being modified.
254    pub path: String,
255    /// The original content. `None` for new files.
256    #[serde(default, skip_serializing_if = "Option::is_none")]
257    pub old_text: Option<String>,
258    /// The new content after modification.
259    pub new_text: String,
260}
261
262/// A reference to a terminal instance the agent
263/// created via `terminal/create` and is embedding
264/// in the tool call's content stream.
265#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
266#[serde(rename_all = "camelCase")]
267pub struct TerminalRef {
268    /// The terminal instance's id. The client uses
269    /// this id in `terminal/release` and
270    /// `terminal/wait_for_exit`.
271    pub terminal_id: TerminalId,
272}
273
274/// A file location being accessed or modified by
275/// a tool. Enables "follow-along" features that
276/// track which files the agent is working with.
277#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
278#[serde(rename_all = "camelCase")]
279pub struct ToolCallLocation {
280    /// The absolute file path being accessed or
281    /// modified.
282    pub path: String,
283    /// Optional line number within the file.
284    #[serde(default, skip_serializing_if = "Option::is_none")]
285    pub line: Option<u32>,
286    /// Per-implementation metadata.
287    #[serde(default, rename = "_meta", skip_serializing_if = "BTreeMap::is_empty")]
288    pub meta: BTreeMap<String, serde_json::Value>,
289}
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294    use serde_json::json;
295
296    /// The `ToolKind` enum deserialises every wire
297    /// value the schema names. The wire form is a
298    /// string; the typed form is a `Copy` enum.
299    #[test]
300    fn tool_kind_string_round_trips() -> Result<(), serde_json::Error> {
301        for (variant, wire) in [
302            (ToolKind::Read, "read"),
303            (ToolKind::Edit, "edit"),
304            (ToolKind::Delete, "delete"),
305            (ToolKind::Move, "move"),
306            (ToolKind::Search, "search"),
307            (ToolKind::Execute, "execute"),
308            (ToolKind::Think, "think"),
309            (ToolKind::Fetch, "fetch"),
310            (ToolKind::SwitchMode, "switch_mode"),
311            (ToolKind::Other, "other"),
312        ] {
313            let value = serde_json::to_value(variant)?;
314            assert_eq!(value, json!(wire));
315            let back: ToolKind = serde_json::from_value(value)?;
316            assert_eq!(back, variant);
317        }
318        Ok(())
319    }
320
321    /// The `ToolCallStatus` enum deserialises every
322    /// wire value the schema names.
323    #[test]
324    fn tool_call_status_string_round_trips() -> Result<(), serde_json::Error> {
325        for (variant, wire) in [
326            (ToolCallStatus::Pending, "pending"),
327            (ToolCallStatus::InProgress, "in_progress"),
328            (ToolCallStatus::Completed, "completed"),
329            (ToolCallStatus::Failed, "failed"),
330        ] {
331            let value = serde_json::to_value(variant)?;
332            assert_eq!(value, json!(wire));
333            let back: ToolCallStatus = serde_json::from_value(value)?;
334            assert_eq!(back, variant);
335        }
336        Ok(())
337    }
338
339    /// A `ToolCall` with a `read` kind round-trips.
340    /// The wire form carries the `toolCallId`, the
341    /// `title`, and the optional fields.
342    #[test]
343    fn a_tool_call_round_trips() -> Result<(), serde_json::Error> {
344        let call = ToolCall {
345            tool_call_id: ToolCallId::new("call-1"),
346            title: "Reading config".to_owned(),
347            name: Some("read_file".to_owned()),
348            kind: Some(ToolKind::Read),
349            status: Some(ToolCallStatus::InProgress),
350            content: None,
351            locations: Some(vec![ToolCallLocation {
352                path: "/etc/hosts".to_owned(),
353                line: Some(1),
354                meta: BTreeMap::new(),
355            }]),
356            raw_input: None,
357            raw_output: None,
358        };
359        let value = serde_json::to_value(&call)?;
360        assert_eq!(value.get("toolCallId"), Some(&json!("call-1")));
361        assert_eq!(value.get("title"), Some(&json!("Reading config")));
362        assert_eq!(value.get("kind"), Some(&json!("read")));
363        let back: ToolCall = serde_json::from_value(value)?;
364        assert_eq!(back, call);
365        Ok(())
366    }
367
368    /// A `ToolCallUpdate` with a `Some(None)` field
369    /// round-trips as an explicit `null` on the
370    /// wire. The `Option<Option<T>>` shape is the
371    /// typed form's encoding of patch-style
372    /// semantics.
373    #[test]
374    fn a_tool_call_update_round_trips() -> Result<(), serde_json::Error> {
375        let update = ToolCallUpdate {
376            tool_call_id: ToolCallId::new("call-1"),
377            kind: Some(Some(ToolKind::Execute)),
378            status: Some(Some(ToolCallStatus::Completed)),
379            title: None,
380            name: None,
381            content: None,
382            locations: None,
383            raw_input: None,
384            raw_output: None,
385        };
386        let value = serde_json::to_value(&update)?;
387        assert_eq!(value.get("toolCallId"), Some(&json!("call-1")));
388        assert_eq!(value.get("kind"), Some(&json!("execute")));
389        assert_eq!(value.get("status"), Some(&json!("completed")));
390        Ok(())
391    }
392
393    /// The `ToolCallContent::Diff` variant carries
394    /// the `path`, the optional `oldText`, and the
395    /// `newText`. The `type` discriminator is `diff`.
396    #[test]
397    fn a_tool_call_content_diff_round_trips() -> Result<(), serde_json::Error> {
398        let content = ToolCallContent::Diff(Diff {
399            path: "/tmp/example.txt".to_owned(),
400            old_text: Some("old".to_owned()),
401            new_text: "new".to_owned(),
402        });
403        let value = serde_json::to_value(&content)?;
404        assert_eq!(value.get("type"), Some(&json!("diff")));
405        assert_eq!(value.get("path"), Some(&json!("/tmp/example.txt")));
406        assert_eq!(value.get("oldText"), Some(&json!("old")));
407        assert_eq!(value.get("newText"), Some(&json!("new")));
408        Ok(())
409    }
410
411    /// The `ToolCallContent::Terminal` variant
412    /// references a `TerminalId`. The wire form is
413    /// `{ "type": "terminal", "terminalId": "..." }`.
414    #[test]
415    fn a_tool_call_content_terminal_round_trips() -> Result<(), serde_json::Error> {
416        let content = ToolCallContent::Terminal(TerminalRef {
417            terminal_id: TerminalId::new("term-1"),
418        });
419        let value = serde_json::to_value(&content)?;
420        assert_eq!(value.get("type"), Some(&json!("terminal")));
421        assert_eq!(value.get("terminalId"), Some(&json!("term-1")));
422        Ok(())
423    }
424}