Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions agent-client-protocol-schema/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
127 changes: 127 additions & 0 deletions agent-client-protocol-schema/src/v1/tool_call.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
/// The category of tool being invoked.
/// Helps clients choose appropriate icons and UI treatment.
#[serde_as(deserialize_as = "DefaultOnError")]
Expand Down Expand Up @@ -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(),
Expand All @@ -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<String>) -> Self {
self.name = name.into_option();
self
}

/// The category of tool being invoked.
/// Helps clients choose appropriate icons and UI treatment.
#[must_use]
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -253,6 +284,19 @@ pub struct ToolCallUpdateFields {
#[schemars(extend("x-deserialize-default-on-error" = true))]
#[serde(default)]
pub title: Option<String>,
/// **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<String>,
/// Replace the content collection.
#[serde_as(deserialize_as = "DefaultOnError<Option<VecSkipError<_, SkipListener>>>")]
#[schemars(extend("x-deserialize-default-on-error" = true, "x-deserialize-skip-invalid-items" = true))]
Expand Down Expand Up @@ -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<String>) -> Self {
self.name = name.into_option();
self
}

/// Replace the content collection.
#[must_use]
pub fn content(mut self, content: impl IntoOption<Vec<ToolCallContent>>) -> Self {
Expand Down Expand Up @@ -345,6 +401,8 @@ impl TryFrom<ToolCallUpdate> for ToolCall {
kind,
status,
title,
#[cfg(feature = "unstable_tool_call_name")]
name,
content,
locations,
raw_input,
Expand All @@ -358,6 +416,8 @@ impl TryFrom<ToolCallUpdate> 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(),
Expand All @@ -374,6 +434,8 @@ impl From<ToolCall> for ToolCallUpdate {
let ToolCall {
tool_call_id,
title,
#[cfg(feature = "unstable_tool_call_name")]
name,
kind,
status,
content,
Expand All @@ -388,6 +450,8 @@ impl From<ToolCall> 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,
Expand Down Expand Up @@ -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);
}
}
39 changes: 39 additions & 0 deletions agent-client-protocol-schema/src/v2/conversion.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3025,6 +3025,8 @@ impl TryToV1 for super::ToolCallUpdate {
fn try_to_v1(self) -> Result<Self::Output> {
let Self {
tool_call_id,
#[cfg(feature = "unstable_tool_call_name")]
name,
title,
kind,
status,
Expand All @@ -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)?,
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -3109,13 +3117,17 @@ impl TryToV2 for crate::v1::ToolCallUpdate {
kind,
status,
title,
#[cfg(feature = "unstable_tool_call_name")]
name,
content,
locations,
raw_input,
raw_output,
} = 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)?,
Expand Down Expand Up @@ -10241,6 +10253,33 @@ mod tests {
assert_json_eq_after_v1_to_v2::<v1::ToolCallUpdate, v2::ToolCallUpdate>(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::ToolCallUpdate, v2::ToolCallUpdate>(v1_update.clone());
assert_json_eq_after_v1_to_v2::<v1::ToolCallUpdate, v2::ToolCallUpdate>(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::<String>),
"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(
Expand Down
Loading