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}