From 36e4d653f21ba34061cf0a95ca0b3589e1330063 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Tue, 21 Jul 2026 13:24:59 +0200 Subject: [PATCH] feat(unstable): add tool call name --- agent-client-protocol-schema/Cargo.toml | 2 + .../src/v1/tool_call.rs | 127 ++++++++++++ .../src/v2/conversion.rs | 39 ++++ .../src/v2/tool_call.rs | 66 +++++++ docs/docs.json | 3 +- docs/protocol/v1/draft/schema.mdx | 44 +++++ docs/protocol/v1/draft/tool-calls.mdx | 11 ++ docs/protocol/v2/draft/schema.mdx | 26 +++ docs/protocol/v2/draft/tool-calls.mdx | 9 + docs/rfds/tool-call-name.mdx | 187 ++++++++++++++++++ docs/rfds/updates.mdx | 7 + schema/v1/schema.unstable.json | 10 + schema/v2/schema.unstable.json | 5 + 13 files changed, 535 insertions(+), 1 deletion(-) create mode 100644 docs/rfds/tool-call-name.mdx diff --git a/agent-client-protocol-schema/Cargo.toml b/agent-client-protocol-schema/Cargo.toml index 59ba974c5..a1f00023a 100644 --- a/agent-client-protocol-schema/Cargo.toml +++ b/agent-client-protocol-schema/Cargo.toml @@ -31,6 +31,7 @@ unstable = [ "unstable_plan_operations", "unstable_session_fork", "unstable_end_turn_token_usage", + "unstable_tool_call_name", ] # Protocol v2 is intentionally NOT part of the `unstable` umbrella. # It introduces a parallel `v2` module and (eventually) a different wire @@ -44,6 +45,7 @@ unstable_nes = [] unstable_plan_operations = [] unstable_session_fork = [] unstable_end_turn_token_usage = [] +unstable_tool_call_name = [] # Emit `tracing::warn!` events when `VecSkipError` drops a malformed list # entry during deserialization. When disabled (the default), the inspector diff --git a/agent-client-protocol-schema/src/v1/tool_call.rs b/agent-client-protocol-schema/src/v1/tool_call.rs index ca1646717..fb764a850 100644 --- a/agent-client-protocol-schema/src/v1/tool_call.rs +++ b/agent-client-protocol-schema/src/v1/tool_call.rs @@ -31,6 +31,19 @@ pub struct ToolCall { pub tool_call_id: ToolCallId, /// Human-readable title describing what the tool is doing. pub title: String, + /// **UNSTABLE** + /// + /// This capability is not part of the spec yet, and may be removed or changed at any point. + /// + /// Programmatic name of the tool being invoked. + /// + /// This field is optional. Omitting it or sending `null` both mean that no + /// tool name is available. + #[cfg(feature = "unstable_tool_call_name")] + #[serde_as(deserialize_as = "DefaultOnError")] + #[schemars(extend("x-deserialize-default-on-error" = true))] + #[serde(default)] + pub name: Option, /// The category of tool being invoked. /// Helps clients choose appropriate icons and UI treatment. #[serde_as(deserialize_as = "DefaultOnError")] @@ -82,6 +95,8 @@ impl ToolCall { Self { tool_call_id: tool_call_id.into(), title: title.into(), + #[cfg(feature = "unstable_tool_call_name")] + name: None, kind: ToolKind::default(), status: ToolCallStatus::default(), content: Vec::default(), @@ -92,6 +107,18 @@ impl ToolCall { } } + /// **UNSTABLE** + /// + /// This capability is not part of the spec yet, and may be removed or changed at any point. + /// + /// Programmatic name of the tool being invoked. + #[cfg(feature = "unstable_tool_call_name")] + #[must_use] + pub fn name(mut self, name: impl IntoOption) -> Self { + self.name = name.into_option(); + self + } + /// The category of tool being invoked. /// Helps clients choose appropriate icons and UI treatment. #[must_use] @@ -153,6 +180,10 @@ impl ToolCall { if let Some(title) = fields.title { self.title = title; } + #[cfg(feature = "unstable_tool_call_name")] + if let Some(name) = fields.name { + self.name = Some(name); + } if let Some(kind) = fields.kind { self.kind = kind; } @@ -253,6 +284,19 @@ pub struct ToolCallUpdateFields { #[schemars(extend("x-deserialize-default-on-error" = true))] #[serde(default)] pub title: Option, + /// **UNSTABLE** + /// + /// This capability is not part of the spec yet, and may be removed or changed at any point. + /// + /// Update the programmatic name of the tool being invoked. + /// + /// This field is optional. Omitting it or sending `null` both mean that + /// the existing name is left unchanged. + #[cfg(feature = "unstable_tool_call_name")] + #[serde_as(deserialize_as = "DefaultOnError")] + #[schemars(extend("x-deserialize-default-on-error" = true))] + #[serde(default)] + pub name: Option, /// Replace the content collection. #[serde_as(deserialize_as = "DefaultOnError>>")] #[schemars(extend("x-deserialize-default-on-error" = true, "x-deserialize-skip-invalid-items" = true))] @@ -303,6 +347,18 @@ impl ToolCallUpdateFields { self } + /// **UNSTABLE** + /// + /// This capability is not part of the spec yet, and may be removed or changed at any point. + /// + /// Update the programmatic name of the tool being invoked. + #[cfg(feature = "unstable_tool_call_name")] + #[must_use] + pub fn name(mut self, name: impl IntoOption) -> Self { + self.name = name.into_option(); + self + } + /// Replace the content collection. #[must_use] pub fn content(mut self, content: impl IntoOption>) -> Self { @@ -345,6 +401,8 @@ impl TryFrom for ToolCall { kind, status, title, + #[cfg(feature = "unstable_tool_call_name")] + name, content, locations, raw_input, @@ -358,6 +416,8 @@ impl TryFrom for ToolCall { title: title.ok_or_else(|| { Error::invalid_params().data(serde_json::json!("title is required for a tool call")) })?, + #[cfg(feature = "unstable_tool_call_name")] + name, kind: kind.unwrap_or_default(), status: status.unwrap_or_default(), content: content.unwrap_or_default(), @@ -374,6 +434,8 @@ impl From for ToolCallUpdate { let ToolCall { tool_call_id, title, + #[cfg(feature = "unstable_tool_call_name")] + name, kind, status, content, @@ -388,6 +450,8 @@ impl From for ToolCallUpdate { kind: Some(kind), status: Some(status), title: Some(title), + #[cfg(feature = "unstable_tool_call_name")] + name, content: Some(content), locations: Some(locations), raw_input, @@ -737,3 +801,66 @@ impl ToolCallLocation { self } } + +#[cfg(all(test, feature = "unstable_tool_call_name"))] +mod tests { + use super::*; + + #[test] + fn tool_call_name_is_optional_and_null_is_equivalent_to_omission() { + let named = ToolCall::new("tc_1", "Reading configuration").name("read_file"); + assert_eq!( + serde_json::to_value(named).unwrap(), + serde_json::json!({ + "toolCallId": "tc_1", + "title": "Reading configuration", + "name": "read_file" + }) + ); + + let unnamed = ToolCall::new("tc_1", "Reading configuration"); + assert_eq!(unnamed.name, None); + assert_eq!( + serde_json::to_value(unnamed).unwrap(), + serde_json::json!({ + "toolCallId": "tc_1", + "title": "Reading configuration" + }) + ); + + let from_null: ToolCall = serde_json::from_value(serde_json::json!({ + "toolCallId": "tc_1", + "title": "Reading configuration", + "name": null + })) + .unwrap(); + assert_eq!(from_null.name, None); + } + + #[test] + fn tool_call_name_update_replaces_a_name_but_cannot_clear_it() { + let mut stored = ToolCall::new("tc_1", "Reading configuration").name("read_file"); + + stored.update(ToolCallUpdateFields::new()); + assert_eq!(stored.name.as_deref(), Some("read_file")); + + let null_update: ToolCallUpdateFields = + serde_json::from_value(serde_json::json!({"name": null})).unwrap(); + stored.update(null_update); + assert_eq!(stored.name.as_deref(), Some("read_file")); + + stored.update(ToolCallUpdateFields::new().name("read_many_files")); + assert_eq!(stored.name.as_deref(), Some("read_many_files")); + } + + #[test] + fn tool_call_name_survives_v1_upsert_conversion() { + let tool_call = ToolCall::new("tc_1", "Reading configuration").name("read_file"); + + let update = ToolCallUpdate::from(tool_call.clone()); + assert_eq!(update.fields.name.as_deref(), Some("read_file")); + + let rebuilt = ToolCall::try_from(update).unwrap(); + assert_eq!(rebuilt, tool_call); + } +} diff --git a/agent-client-protocol-schema/src/v2/conversion.rs b/agent-client-protocol-schema/src/v2/conversion.rs index a33a07b31..683292f9e 100644 --- a/agent-client-protocol-schema/src/v2/conversion.rs +++ b/agent-client-protocol-schema/src/v2/conversion.rs @@ -3025,6 +3025,8 @@ impl TryToV1 for super::ToolCallUpdate { fn try_to_v1(self) -> Result { let Self { tool_call_id, + #[cfg(feature = "unstable_tool_call_name")] + name, title, kind, status, @@ -3037,6 +3039,8 @@ impl TryToV1 for super::ToolCallUpdate { Ok(crate::v1::ToolCallUpdate { tool_call_id: tool_call_id.try_to_v1()?, fields: crate::v1::ToolCallUpdateFields { + #[cfg(feature = "unstable_tool_call_name")] + name: maybe_undefined_value_into_v1_option("ToolCallUpdate.name", name)?, kind: maybe_undefined_value_into_v1_option("ToolCallUpdate.kind", kind)?, status: maybe_undefined_value_into_v1_option("ToolCallUpdate.status", status)?, title: maybe_undefined_value_into_v1_option("ToolCallUpdate.title", title)?, @@ -3066,6 +3070,8 @@ impl TryToV2 for crate::v1::ToolCall { let Self { tool_call_id, title, + #[cfg(feature = "unstable_tool_call_name")] + name, kind, status, content, @@ -3076,6 +3082,8 @@ impl TryToV2 for crate::v1::ToolCall { } = self; Ok(super::ToolCallUpdate { tool_call_id: tool_call_id.try_to_v2()?, + #[cfg(feature = "unstable_tool_call_name")] + name: option_into_v2_maybe_undefined(name)?, title: crate::MaybeUndefined::Value(title.try_to_v2()?), kind: if matches!(kind, crate::v1::ToolKind::Other) { crate::MaybeUndefined::Undefined @@ -3109,6 +3117,8 @@ impl TryToV2 for crate::v1::ToolCallUpdate { kind, status, title, + #[cfg(feature = "unstable_tool_call_name")] + name, content, locations, raw_input, @@ -3116,6 +3126,8 @@ impl TryToV2 for crate::v1::ToolCallUpdate { } = fields; Ok(super::ToolCallUpdate { tool_call_id: tool_call_id.try_to_v2()?, + #[cfg(feature = "unstable_tool_call_name")] + name: option_into_v2_maybe_undefined(name)?, kind: option_into_v2_maybe_undefined(kind)?, status: option_into_v2_maybe_undefined(status)?, title: option_into_v2_maybe_undefined(title)?, @@ -10241,6 +10253,33 @@ mod tests { assert_json_eq_after_v1_to_v2::(update); } + #[cfg(feature = "unstable_tool_call_name")] + #[test] + fn tool_call_name_converts_between_v1_and_v2_without_losing_patch_semantics() { + let v1_tool_call = v1::ToolCall::new("tc", "Reading configuration").name("read_file"); + let v2_tool_call: v2::ToolCallUpdate = + try_v1_to_v2(v1_tool_call).expect("v1 tool call -> v2 upsert"); + assert_eq!( + v2_tool_call.name, + crate::MaybeUndefined::Value("read_file".to_string()) + ); + + let v1_update = + v1::ToolCallUpdate::new("tc", v1::ToolCallUpdateFields::new().name("write_file")); + assert_v1_round_trip::(v1_update.clone()); + assert_json_eq_after_v1_to_v2::(v1_update); + + let unnamed_v1_update = v1::ToolCallUpdate::new("tc", v1::ToolCallUpdateFields::new()); + let unnamed_v2_update: v2::ToolCallUpdate = + try_v1_to_v2(unnamed_v1_update).expect("v1 tool-call update -> v2 patch"); + assert_eq!(unnamed_v2_update.name, crate::MaybeUndefined::Undefined); + + assert_v2_to_v1_error( + v2::ToolCallUpdate::new("tc").name(None::), + "v2 ToolCallUpdate.name with null value cannot be represented in v1", + ); + } + #[test] fn v2_entity_meta_null_does_not_convert_to_v1() { assert_v2_to_v1_error( diff --git a/agent-client-protocol-schema/src/v2/tool_call.rs b/agent-client-protocol-schema/src/v2/tool_call.rs index 45dbf0fb6..bb341b47e 100644 --- a/agent-client-protocol-schema/src/v2/tool_call.rs +++ b/agent-client-protocol-schema/src/v2/tool_call.rs @@ -35,6 +35,21 @@ use crate::{IntoMaybeUndefined, IntoOption, MaybeUndefined, SkipListener}; pub struct ToolCallUpdate { /// Unique identifier for this tool call within the session. pub tool_call_id: ToolCallId, + /// **UNSTABLE** + /// + /// This capability is not part of the spec yet, and may be removed or changed at any point. + /// + /// Programmatic name of the tool being invoked. + /// + /// This field is optional and has patch semantics. Omission means no + /// change, `null` clears the name, and a string replaces it. For a tool + /// call ID the client has not seen before, omission or `null` means that no + /// tool name is available. + #[cfg(feature = "unstable_tool_call_name")] + #[serde_as(deserialize_as = "DefaultOnError")] + #[schemars(extend("x-deserialize-default-on-error" = true))] + #[serde(default, skip_serializing_if = "MaybeUndefined::is_undefined")] + pub name: MaybeUndefined, /// Human-readable title describing what the tool is doing. #[serde_as(deserialize_as = "DefaultOnError")] #[schemars(extend("x-deserialize-default-on-error" = true))] @@ -93,6 +108,8 @@ impl ToolCallUpdate { pub fn new(tool_call_id: impl Into) -> Self { Self { tool_call_id: tool_call_id.into(), + #[cfg(feature = "unstable_tool_call_name")] + name: MaybeUndefined::Undefined, title: MaybeUndefined::Undefined, kind: MaybeUndefined::Undefined, status: MaybeUndefined::Undefined, @@ -104,6 +121,18 @@ impl ToolCallUpdate { } } + /// **UNSTABLE** + /// + /// This capability is not part of the spec yet, and may be removed or changed at any point. + /// + /// Programmatic name of the tool being invoked. + #[cfg(feature = "unstable_tool_call_name")] + #[must_use] + pub fn name(mut self, name: impl IntoMaybeUndefined) -> Self { + self.name = name.into_maybe_undefined(); + self + } + /// Human-readable title describing what the tool is doing. #[must_use] pub fn title(mut self, title: impl IntoMaybeUndefined) -> Self { @@ -172,6 +201,10 @@ impl ToolCallUpdate { /// render an explicitly cleared value. pub fn apply_update(&mut self, update: ToolCallUpdate) { debug_assert_eq!(self.tool_call_id, update.tool_call_id); + #[cfg(feature = "unstable_tool_call_name")] + if !update.name.is_undefined() { + self.name = update.name; + } if !update.title.is_undefined() { self.title = update.title; } @@ -1000,6 +1033,39 @@ mod tests { assert_eq!(deserialized.locations, MaybeUndefined::Value(Vec::new())); } + #[cfg(feature = "unstable_tool_call_name")] + #[test] + fn tool_call_name_patch_distinguishes_omitted_null_and_value() { + let named = ToolCallUpdate::new("tc_1").name("read_file"); + assert_eq!( + serde_json::to_value(named).unwrap(), + serde_json::json!({ + "toolCallId": "tc_1", + "name": "read_file" + }) + ); + + let omitted = ToolCallUpdate::new("tc_1"); + assert_eq!(omitted.name, MaybeUndefined::Undefined); + + let from_null: ToolCallUpdate = serde_json::from_value(serde_json::json!({ + "toolCallId": "tc_1", + "name": null + })) + .unwrap(); + assert_eq!(from_null.name, MaybeUndefined::Null); + + let mut stored = ToolCallUpdate::new("tc_1").name("read_file"); + stored.apply_update(ToolCallUpdate::new("tc_1")); + assert_eq!(stored.name, MaybeUndefined::Value("read_file".to_string())); + + stored.apply_update(ToolCallUpdate::new("tc_1").name(None::)); + assert_eq!(stored.name, MaybeUndefined::Null); + + stored.apply_update(ToolCallUpdate::new("tc_1").name("write_file")); + assert_eq!(stored.name, MaybeUndefined::Value("write_file".to_string())); + } + #[test] fn tool_call_update_distinguishes_meta_omitted_null_and_value() { let mut meta = Meta::new(); diff --git a/docs/docs.json b/docs/docs.json index 7e47af663..e60ce68ca 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -190,7 +190,8 @@ "rfds/diff-delete", "rfds/next-edit-suggestions", "rfds/custom-llm-endpoint", - "rfds/plan-operations" + "rfds/plan-operations", + "rfds/tool-call-name" ] }, { diff --git a/docs/protocol/v1/draft/schema.mdx b/docs/protocol/v1/draft/schema.mdx index 2f2a015df..c442a371d 100644 --- a/docs/protocol/v1/draft/schema.mdx +++ b/docs/protocol/v1/draft/schema.mdx @@ -7703,6 +7703,17 @@ Helps clients choose appropriate icons and UI treatment. ToolCallLocation[]} > File locations affected by this tool call. Enables "follow-along" features in clients. + + + **UNSTABLE** + +This capability is not part of the spec yet, and may be removed or changed at any point. + +Programmatic name of the tool being invoked. + +This field is optional. Omitting it or sending `null` both mean that no +tool name is available. + Raw input parameters sent to the tool. @@ -7747,6 +7758,17 @@ See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/v1/d ToolCallLocation[] | null} > Replace the locations collection. + + + **UNSTABLE** + +This capability is not part of the spec yet, and may be removed or changed at any point. + +Update the programmatic name of the tool being invoked. + +This field is optional. Omitting it or sending `null` both mean that +the existing name is left unchanged. + Update the raw input. @@ -8312,6 +8334,17 @@ Helps clients choose appropriate icons and UI treatment. ToolCallLocation[]} > File locations affected by this tool call. Enables "follow-along" features in clients. + + + **UNSTABLE** + +This capability is not part of the spec yet, and may be removed or changed at any point. + +Programmatic name of the tool being invoked. + +This field is optional. Omitting it or sending `null` both mean that no +tool name is available. + Raw input parameters sent to the tool. @@ -8512,6 +8545,17 @@ See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/v1/d ToolCallLocation[] | null} > Replace the locations collection. + + + **UNSTABLE** + +This capability is not part of the spec yet, and may be removed or changed at any point. + +Update the programmatic name of the tool being invoked. + +This field is optional. Omitting it or sending `null` both mean that +the existing name is left unchanged. + Update the raw input. diff --git a/docs/protocol/v1/draft/tool-calls.mdx b/docs/protocol/v1/draft/tool-calls.mdx index a8fb1e434..bb5125940 100644 --- a/docs/protocol/v1/draft/tool-calls.mdx +++ b/docs/protocol/v1/draft/tool-calls.mdx @@ -23,6 +23,7 @@ When the language model requests a tool invocation, the Agent **SHOULD** report "sessionUpdate": "tool_call", "toolCallId": "call_001", "title": "Reading configuration file", + "name": "read_file", "kind": "read", "status": "pending" } @@ -38,6 +39,13 @@ When the language model requests a tool invocation, the Agent **SHOULD** report A human-readable title describing what the tool is doing + + The programmatic name of the invoked tool, such as `read_file`. This + identifies which tool is being used; `title` describes this invocation for + users, while `kind` provides a coarse presentation category. If omitted or + `null` on the initial tool call, no name is provided. + + The category of tool being invoked. @@ -105,6 +113,9 @@ Updates use the `session/update` notification with `tool_call_update`: All fields except `toolCallId` are optional in updates. Only the fields being changed need to be included. +For `name`, a string sets or replaces the tool name. Omission and `null` both +leave the existing name unchanged. + ## Requesting Permission The Agent **MAY** request permission from the user before executing a tool call by calling the `session/request_permission` method: diff --git a/docs/protocol/v2/draft/schema.mdx b/docs/protocol/v2/draft/schema.mdx index 64d440ec7..61e4fcaa0 100644 --- a/docs/protocol/v2/draft/schema.mdx +++ b/docs/protocol/v2/draft/schema.mdx @@ -8162,6 +8162,19 @@ Helps clients choose appropriate icons and UI treatment. ToolCallLocation[] | null} > File locations affected by this tool call. Enables "follow-along" features in clients. + + + **UNSTABLE** + +This capability is not part of the spec yet, and may be removed or changed at any point. + +Programmatic name of the tool being invoked. + +This field is optional and has patch semantics. Omission means no +change, `null` clears the name, and a string replaces it. For a tool +call ID the client has not seen before, omission or `null` means that no +tool name is available. + Raw input parameters sent to the tool. @@ -9280,6 +9293,19 @@ Helps clients choose appropriate icons and UI treatment. ToolCallLocation[] | null} > File locations affected by this tool call. Enables "follow-along" features in clients. + + + **UNSTABLE** + +This capability is not part of the spec yet, and may be removed or changed at any point. + +Programmatic name of the tool being invoked. + +This field is optional and has patch semantics. Omission means no +change, `null` clears the name, and a string replaces it. For a tool +call ID the client has not seen before, omission or `null` means that no +tool name is available. + Raw input parameters sent to the tool. diff --git a/docs/protocol/v2/draft/tool-calls.mdx b/docs/protocol/v2/draft/tool-calls.mdx index 86209370e..2b10f6ede 100644 --- a/docs/protocol/v2/draft/tool-calls.mdx +++ b/docs/protocol/v2/draft/tool-calls.mdx @@ -27,6 +27,7 @@ When the language model requests a tool invocation, the Agent **SHOULD** report "sessionUpdate": "tool_call_update", "toolCallId": "call_001", "title": "Reading configuration file", + "name": "read_file", "kind": "read", "status": "pending" } @@ -43,6 +44,14 @@ When the language model requests a tool invocation, the Agent **SHOULD** report include the title the first time they report a `toolCallId`. + + The programmatic name of the invoked tool, such as `read_file`. This + identifies which tool is being used; `title` describes this invocation for + users, while `kind` provides a coarse presentation category. Agents **SHOULD** + include the name the first time they report a `toolCallId` when it is + available. + + The category of tool being invoked. diff --git a/docs/rfds/tool-call-name.mdx b/docs/rfds/tool-call-name.mdx new file mode 100644 index 000000000..c3ebc8371 --- /dev/null +++ b/docs/rfds/tool-call-name.mdx @@ -0,0 +1,187 @@ +--- +title: "Tool Call Name" +--- + +Author(s): [@benbrandt](https://github.com/benbrandt) + +## Elevator pitch + +> What are you proposing to change? + +Add an optional `name` field to tool calls in ACP v1 and v2. The field carries +the programmatic name of the invoked tool so Clients can show users which tool +is being used, in addition to the human-readable description of what that +invocation is doing. + +## Status quo + +> How do things work today and what problems does this cause? Why would we change things? + +Tool calls currently provide a `title` and `kind`, but neither identifies the +invoked tool: + +- `title` is human-readable UI copy for a particular invocation, such as + `Reading configuration file`. +- `kind` is a coarse presentation category, such as `read` or `execute`, that + helps a Client choose an icon or other generic treatment. + +Many different tools can share the same kind, and titles may be localized or +generated from the tool input. Clients therefore cannot reliably show whether +an Agent used `read_file`, `grep`, `run_command`, or another tool. Inferring the +tool from `title` or `rawInput` would be brittle and implementation-specific. + +This is particularly helpful in an eval context as well, where you want to track +metrics of various tool uses. + +## What we propose to do about it + +> What are you proposing to improve the situation? + +Add `name` to the existing tool-call shapes. It is the programmatic identifier +by which the Agent or model addressed the tool. For example: + +```json +{ + "sessionUpdate": "tool_call", + "toolCallId": "call_001", + "name": "read_file", + "title": "Reading configuration file", + "kind": "read", + "status": "pending" +} +``` + +The corresponding v2 report uses the same field on its upsert shape: + +```json +{ + "sessionUpdate": "tool_call_update", + "toolCallId": "call_001", + "name": "read_file", + "title": "Reading configuration file", + "kind": "read", + "status": "pending" +} +``` + +`name` is distinct from the existing fields: + +- `toolCallId` identifies one invocation within a session. +- `name` identifies the tool being invoked and can be reused by many calls. +- `title` describes this invocation in human-readable UI copy. +- `kind` groups the tool into a coarse presentation category. + +Tool names are opaque strings. ACP does not define a naming or namespacing +scheme. An Agent reporting an MCP or function call should use the programmatic +name by which it exposes that tool; if the Agent qualifies or rewrites names, +it reports that resulting name. ACP assigns no behavioral or authorization +semantics to the spelling. Clients **MAY** show it as a secondary label or in +tool-call details while continuing to use `title` as the primary description. + +Agents **SHOULD** include `name` in the first report for a tool call when it is +available, and **SHOULD NOT** change it later except to correct previously +reported metadata. + +### Wire semantics + +The field is optional and nullable in every shape where it appears, but the +effect of `null` follows each protocol version's existing update model: + +| Carrier | Omitted | `null` | String value | +| ------------------- | ---------------------------------- | ---------------------------------------------------------- | ---------------------------------- | +| v1 `ToolCall` | No name is provided | Equivalent to omission | Records the tool name | +| v1 `ToolCallUpdate` | Leaves the existing name unchanged | Equivalent to omission; leaves the existing name unchanged | Sets or replaces the existing name | +| v2 `ToolCallUpdate` | Leaves the existing name unchanged | Explicitly clears the existing name | Sets or replaces the existing name | + +ACP v1 cannot explicitly clear a previously supplied tool name. For a new v2 +`toolCallId`, omission and `null` both result in a tool call with no name. + +Because v1 permission requests carry a `ToolCallUpdate`, and v2 tool-call +permission subjects carry the v2 `ToolCallUpdate`, Agents can also include +`name` when asking permission without adding another permission-specific +field. + +## Shiny future + +> How will things will play out once this feature exists? + +Users can see both what an invocation is doing and which tool is doing it. A +Client might render `Reading configuration file` as the main title, `read_file` +as a secondary label, and a read icon selected from `kind: "read"`. + +Agents no longer need to encode the programmatic tool name into the title or +use an implementation-specific `_meta` key. Clients can expose tool identity +consistently in detailed views, permission UI, logs, and debugging tools. + +## Implementation details and plan + +> Tell me more about your implementation. What is your detailed implementation plan? + +1. Add optional `name` fields to v1 `ToolCall` and `ToolCallUpdateFields` behind + the `unstable_tool_call_name` Rust feature. +2. Add an optional three-state `name` patch field to v2 `ToolCallUpdate` behind + the same feature. +3. Include the field in v1/v2 conversion and document the unrepresentable v2 + clear operation and the deliberately lossy bridge option. +4. Regenerate the draft schemas and generated schema documentation. +5. Update the hand-written draft tool-call documentation and examples. + +### Compatibility and conversion + +The proposal is additive. Peers that do not know about `name` continue to use +`title`, `kind`, and the remaining tool-call fields. No capability is required; +an Agent can omit `name` whenever it is unavailable or inappropriate to expose. + +Conversion preserves concrete names in both directions. An absent v1 name maps +to an omitted v2 patch field. An omitted v2 field maps to an absent v1 update. + +A v2 `name: null` clear cannot be represented by v1, where `null` and omission +both mean "leave unchanged" on an update. The strict stateless v2-to-v1 +conversion helper therefore reports this case as unrepresentable. A deliberately +lossy bridge may omit the clear, leaving the previous v1 name in place. + +## Frequently asked questions + +> What questions have arisen over the course of authoring this document or during subsequent discussions? + +### Why use `name` instead of `toolName`? + +The field is already scoped to a tool-call object, so `toolName` would repeat +that context. `name` also matches the terminology used by MCP tools and common +function-calling APIs. + +### Why is there no capability? + +`name` is optional display metadata and does not change how a tool call is +identified, executed, or authorized. Clients that do not recognize the field +can ignore it without changing protocol behavior, and Agents can omit it when +the name is unavailable or inappropriate to expose. A capability would only +indicate whether a Client might display the hint; it would not affect the +correctness of the interaction. + +### Why is this not part of `title`? + +`title` is user-facing copy about an invocation and may change as work +progresses. `name` is the programmatic identity of the invoked tool. Keeping +them separate lets Clients render either or both without parsing display text. + +### Why is this not part of `kind`? + +`kind` intentionally has a small set of presentation categories. Many tools +can be `read`, `edit`, `execute`, or `other`; expanding `kind` into a registry +of exact tool names would prevent Clients from using it as a stable UI hint. + +### What alternative approaches did you consider, and why did you settle on this one? + +**Put the name in `_meta`** - This works for coordinated implementations, but +does not give interoperable Clients a standard field to display. + +**Derive the name from `title` or `rawInput`** - Those fields do not have a +stable relationship to tool identity, so inference would be unreliable. + +**Use `toolName`** - More explicit in isolation, but redundant inside a tool +call and inconsistent with MCP and function-call naming. + +## Revision history + +- 2026-07-21: Initial draft. diff --git a/docs/rfds/updates.mdx b/docs/rfds/updates.mdx index bfb2162b1..bf226df10 100644 --- a/docs/rfds/updates.mdx +++ b/docs/rfds/updates.mdx @@ -6,6 +6,13 @@ rss: true This page tracks lifecycle changes for ACP Requests for Dialog. For broader ACP announcements, see [Updates](/updates). + +## Tool Call Name RFD moves to Draft stage + +The RFD for adding an optional programmatic `name` to tool calls in ACP v1 and v2 has moved to Draft stage. Please review the [RFD](/rfds/tool-call-name) for more information and provide feedback while the draft implementation is exercised. + + + ## Elicitation RFD moves to Preview stage diff --git a/schema/v1/schema.unstable.json b/schema/v1/schema.unstable.json index f342755ea..a23ff3f6b 100644 --- a/schema/v1/schema.unstable.json +++ b/schema/v1/schema.unstable.json @@ -445,6 +445,11 @@ "type": ["string", "null"], "x-deserialize-default-on-error": true }, + "name": { + "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nUpdate the programmatic name of the tool being invoked.\n\nThis field is optional. Omitting it or sending `null` both mean that\nthe existing name is left unchanged.", + "type": ["string", "null"], + "x-deserialize-default-on-error": true + }, "content": { "description": "Replace the content collection.", "type": ["array", "null"], @@ -5332,6 +5337,11 @@ "description": "Human-readable title describing what the tool is doing.", "type": "string" }, + "name": { + "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nProgrammatic name of the tool being invoked.\n\nThis field is optional. Omitting it or sending `null` both mean that no\ntool name is available.", + "type": ["string", "null"], + "x-deserialize-default-on-error": true + }, "kind": { "description": "The category of tool being invoked.\nHelps clients choose appropriate icons and UI treatment.", "x-deserialize-default-on-error": true, diff --git a/schema/v2/schema.unstable.json b/schema/v2/schema.unstable.json index a8110a821..09d2a0ad5 100644 --- a/schema/v2/schema.unstable.json +++ b/schema/v2/schema.unstable.json @@ -809,6 +809,11 @@ } ] }, + "name": { + "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.\n\nProgrammatic name of the tool being invoked.\n\nThis field is optional and has patch semantics. Omission means no\nchange, `null` clears the name, and a string replaces it. For a tool\ncall ID the client has not seen before, omission or `null` means that no\ntool name is available.", + "type": ["string", "null"], + "x-deserialize-default-on-error": true + }, "title": { "description": "Human-readable title describing what the tool is doing.", "type": ["string", "null"],