From b6459f876c8381a83c64519190624b216be0fc21 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Wed, 1 Jul 2026 10:50:39 +0200 Subject: [PATCH 1/6] feat(unstable-v2): open permission outcomes in v2 --- agent-client-protocol-schema/src/v2/client.rs | 130 ++++++++++++++++++ .../src/v2/conversion.rs | 13 ++ docs/protocol/v2/draft/schema.mdx | 26 ++++ docs/protocol/v2/draft/tool-calls.mdx | 8 +- docs/protocol/v2/schema.mdx | 26 ++++ docs/protocol/v2/tool-calls.mdx | 8 +- docs/rfds/v2/enum-variant-extension.mdx | 2 +- schema/v2/schema.json | 39 +++++- schema/v2/schema.unstable.json | 39 +++++- 9 files changed, 282 insertions(+), 9 deletions(-) diff --git a/agent-client-protocol-schema/src/v2/client.rs b/agent-client-protocol-schema/src/v2/client.rs index 03ea7a3d8..17134a34f 100644 --- a/agent-client-protocol-schema/src/v2/client.rs +++ b/agent-client-protocol-schema/src/v2/client.rs @@ -1476,6 +1476,90 @@ pub enum RequestPermissionOutcome { /// The user selected one of the provided options. #[serde(rename_all = "camelCase")] Selected(SelectedPermissionOutcome), + /// Custom or future permission outcome. + /// + /// Values beginning with `_` are reserved for implementation-specific + /// extensions. Unknown values that do not begin with `_` are reserved for + /// future ACP variants. + /// + /// Agents that do not understand this outcome MUST NOT treat it as approval. + /// They should preserve the raw payload when storing, replaying, proxying, or + /// forwarding permission responses, and otherwise fail or decline the + /// permission request according to policy. + #[serde(untagged)] + Other(OtherRequestPermissionOutcome), +} + +/// Custom or future permission outcome payload. +/// +/// This preserves the unknown `outcome` discriminator and the rest of the +/// outcome object for agents that store, replay, proxy, or forward permission +/// responses. +#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)] +#[schemars(inline)] +#[schemars(transform = other_request_permission_outcome_schema)] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub struct OtherRequestPermissionOutcome { + /// Custom or future permission outcome. + /// + /// Values beginning with `_` are reserved for implementation-specific + /// extensions. Unknown values that do not begin with `_` are reserved for + /// future ACP variants. + pub outcome: String, + /// Additional fields from the unknown permission outcome payload. + #[serde(flatten)] + pub fields: BTreeMap, +} + +impl OtherRequestPermissionOutcome { + /// Builds [`OtherRequestPermissionOutcome`] from an unknown discriminator and preserves the remaining extension fields. + #[must_use] + pub fn new( + outcome: impl Into, + mut fields: BTreeMap, + ) -> Self { + fields.remove("outcome"); + Self { + outcome: outcome.into(), + fields, + } + } +} + +impl<'de> Deserialize<'de> for OtherRequestPermissionOutcome { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let mut fields = BTreeMap::::deserialize(deserializer)?; + let outcome = fields + .remove("outcome") + .ok_or_else(|| serde::de::Error::missing_field("outcome"))?; + let serde_json::Value::String(outcome) = outcome else { + return Err(serde::de::Error::custom("`outcome` must be a string")); + }; + + if is_known_request_permission_outcome(&outcome) { + return Err(serde::de::Error::custom(format!( + "known request permission outcome `{outcome}` did not match its schema" + ))); + } + + Ok(Self { outcome, fields }) + } +} + +fn is_known_request_permission_outcome(outcome: &str) -> bool { + matches!(outcome, "cancelled" | "selected") +} + +fn other_request_permission_outcome_schema(schema: &mut Schema) { + super::schema_util::reject_known_string_discriminators( + schema, + "outcome", + &["cancelled", "selected"], + ); } /// The user selected one of the provided options. @@ -2505,6 +2589,52 @@ mod tests { ); } + #[test] + fn request_permission_outcome_preserves_unknown_variant() { + use serde_json::json; + + let outcome: RequestPermissionOutcome = serde_json::from_value(json!({ + "outcome": "_defer", + "reason": "needs-review", + "retryAfterSeconds": 30 + })) + .unwrap(); + + let RequestPermissionOutcome::Other(unknown) = outcome else { + panic!("expected unknown permission outcome"); + }; + + assert_eq!(unknown.outcome, "_defer"); + assert_eq!(unknown.fields.get("reason"), Some(&json!("needs-review"))); + assert_eq!(unknown.fields.get("retryAfterSeconds"), Some(&json!(30))); + assert_eq!( + serde_json::to_value(RequestPermissionOutcome::Other(unknown)).unwrap(), + json!({ + "outcome": "_defer", + "reason": "needs-review", + "retryAfterSeconds": 30 + }) + ); + } + + #[test] + fn request_permission_outcome_unknown_does_not_hide_malformed_known_variant() { + use serde_json::json; + + assert!( + serde_json::from_value::(json!({ + "outcome": "selected" + })) + .is_err() + ); + assert!( + serde_json::from_value::(json!({ + "outcome": 1 + })) + .is_err() + ); + } + #[test] fn available_command_input_unknown_does_not_hide_malformed_unstructured_variant() { use serde_json::json; diff --git a/agent-client-protocol-schema/src/v2/conversion.rs b/agent-client-protocol-schema/src/v2/conversion.rs index 8d4ef98cd..b3776bae0 100644 --- a/agent-client-protocol-schema/src/v2/conversion.rs +++ b/agent-client-protocol-schema/src/v2/conversion.rs @@ -1645,6 +1645,12 @@ impl IntoV1 for super::RequestPermissionOutcome { Self::Selected(value) => { crate::v1::RequestPermissionOutcome::Selected(value.into_v1()?) } + Self::Other(value) => { + return Err(unknown_v2_enum_variant( + "RequestPermissionOutcome", + &value.outcome, + )); + } }) } } @@ -10286,6 +10292,13 @@ mod tests { )), "v2 AvailableCommandInput variant `_choices` cannot be represented in v1", ); + assert_v2_to_v1_error( + v2::RequestPermissionOutcome::Other(v2::OtherRequestPermissionOutcome::new( + "_defer", + std::collections::BTreeMap::new(), + )), + "v2 RequestPermissionOutcome variant `_defer` cannot be represented in v1", + ); assert_v2_to_v1_error( v2::SessionConfigKind::Other(v2::OtherSessionConfigKind::new( "_slider", diff --git a/docs/protocol/v2/draft/schema.mdx b/docs/protocol/v2/draft/schema.mdx index e9504cf5c..90718868e 100644 --- a/docs/protocol/v2/draft/schema.mdx +++ b/docs/protocol/v2/draft/schema.mdx @@ -6524,6 +6524,32 @@ See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/v2/d + +Custom or future permission outcome. + +Values beginning with `_` are reserved for implementation-specific +extensions. Unknown values that do not begin with `_` are reserved for +future ACP variants. + +Agents that do not understand this outcome MUST NOT treat it as approval. +They should preserve the raw payload when storing, replaying, proxying, or +forwarding permission responses, and otherwise fail or decline the +permission request according to policy. + + + + + Custom or future permission outcome. + +Values beginning with `_` are reserved for implementation-specific +extensions. Unknown values that do not begin with `_` are reserved for +future ACP variants. + + + + + + ## RequiresActionStateUpdate The agent is waiting on user action before it can continue. diff --git a/docs/protocol/v2/draft/tool-calls.mdx b/docs/protocol/v2/draft/tool-calls.mdx index 5c83b5629..c146d5173 100644 --- a/docs/protocol/v2/draft/tool-calls.mdx +++ b/docs/protocol/v2/draft/tool-calls.mdx @@ -234,9 +234,11 @@ If the current active work gets [cancelled](/protocol/v2/draft/prompt-lifecycle# ``` - The user's decision, either: - `cancelled` - The [active work was - cancelled](/protocol/v2/draft/prompt-lifecycle#cancellation) - `selected` with - an `optionId` - The ID of the selected permission option + The user's decision. Known outcomes are `cancelled` when the [active work was + cancelled](/protocol/v2/draft/prompt-lifecycle#cancellation), and `selected` + with an `optionId` for the selected permission option. Custom or future + outcomes may appear; agents that do not understand the outcome MUST NOT treat + it as approval. ### Permission Options diff --git a/docs/protocol/v2/schema.mdx b/docs/protocol/v2/schema.mdx index 5329d46d9..4ed62369a 100644 --- a/docs/protocol/v2/schema.mdx +++ b/docs/protocol/v2/schema.mdx @@ -2700,6 +2700,32 @@ See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/v2/e + +Custom or future permission outcome. + +Values beginning with `_` are reserved for implementation-specific +extensions. Unknown values that do not begin with `_` are reserved for +future ACP variants. + +Agents that do not understand this outcome MUST NOT treat it as approval. +They should preserve the raw payload when storing, replaying, proxying, or +forwarding permission responses, and otherwise fail or decline the +permission request according to policy. + + + + + Custom or future permission outcome. + +Values beginning with `_` are reserved for implementation-specific +extensions. Unknown values that do not begin with `_` are reserved for +future ACP variants. + + + + + + ## RequiresActionStateUpdate The agent is waiting on user action before it can continue. diff --git a/docs/protocol/v2/tool-calls.mdx b/docs/protocol/v2/tool-calls.mdx index ed4e3ce24..71c1bcfa8 100644 --- a/docs/protocol/v2/tool-calls.mdx +++ b/docs/protocol/v2/tool-calls.mdx @@ -234,9 +234,11 @@ If the current active work gets [cancelled](/protocol/v2/prompt-lifecycle#cancel ``` - The user's decision, either: - `cancelled` - The [active work was - cancelled](/protocol/v2/prompt-lifecycle#cancellation) - `selected` with an - `optionId` - The ID of the selected permission option + The user's decision. Known outcomes are `cancelled` when the [active work was + cancelled](/protocol/v2/prompt-lifecycle#cancellation), and `selected` with an + `optionId` for the selected permission option. Custom or future outcomes may + appear; agents that do not understand the outcome MUST NOT treat it as + approval. ### Permission Options diff --git a/docs/rfds/v2/enum-variant-extension.mdx b/docs/rfds/v2/enum-variant-extension.mdx index 0c1ecaf2d..6e6043f20 100644 --- a/docs/rfds/v2/enum-variant-extension.mdx +++ b/docs/rfds/v2/enum-variant-extension.mdx @@ -40,7 +40,7 @@ ACP v2 should define one enum extension rule for ACP-owned values when the recei This applies to ACP-owned enum-like values where fallback behavior is acceptable. It does not apply to JSON-RPC fixed constants such as `"2.0"`, numeric code spaces such as JSON-RPC error codes, user-supplied elicitation option values such as `enum` or `oneOf` constants, or discriminators that are core to fulfilling a request. Elicitation schema annotations such as string `format` values can still be open because an implementation can safely treat an unknown format as an annotation. -Tagged unions use string discriminator fields too. When ACP v2 opens one of these unions, it must preserve both the discriminator and the unknown object payload. The same `_` namespace rule applies. Raw fallbacks are appropriate for notification-style and display-data payloads that can be stored, replayed, proxied, forwarded, ignored, or rendered with a generic UI. Tagged unions that are core to request handling, such as elicitation actions, permission outcomes, schema-shape discriminators, and transport selectors, may remain closed and produce a deserialization error for unknown variants. +Tagged unions use string discriminator fields too. When ACP v2 opens one of these unions, it must preserve both the discriminator and the unknown object payload. The same `_` namespace rule applies. Raw fallbacks are appropriate for notification-style and display-data payloads that can be stored, replayed, proxied, forwarded, ignored, or rendered with a generic UI. Tagged unions that are core to request handling, such as elicitation actions, schema-shape discriminators, and transport selectors, may remain closed and produce a deserialization error for unknown variants. Permission outcomes can be opened when unknown outcomes are handled conservatively and never treated as approval. ## Shiny future diff --git a/schema/v2/schema.json b/schema/v2/schema.json index 0c0d1e5da..9286b215d 100644 --- a/schema/v2/schema.json +++ b/schema/v2/schema.json @@ -4740,7 +4740,7 @@ }, "RequestPermissionOutcome": { "description": "The outcome of a permission request.", - "oneOf": [ + "anyOf": [ { "description": "Active session work was cancelled before the user responded.\n\nWhen a client sends a `session/cancel` notification to cancel active\nsession work, it MUST respond to all pending `session/request_permission`\nrequests with this `Cancelled` outcome.\n\nSee protocol docs: [Cancellation](https://agentclientprotocol.com/protocol/v2/prompt-lifecycle#cancellation)", "type": "object", @@ -4767,6 +4767,43 @@ "$ref": "#/$defs/SelectedPermissionOutcome" } ] + }, + { + "title": "other", + "description": "Custom or future permission outcome.\n\nValues beginning with `_` are reserved for implementation-specific\nextensions. Unknown values that do not begin with `_` are reserved for\nfuture ACP variants.\n\nAgents that do not understand this outcome MUST NOT treat it as approval.\nThey should preserve the raw payload when storing, replaying, proxying, or\nforwarding permission responses, and otherwise fail or decline the\npermission request according to policy.", + "type": "object", + "properties": { + "outcome": { + "description": "Custom or future permission outcome.\n\nValues beginning with `_` are reserved for implementation-specific\nextensions. Unknown values that do not begin with `_` are reserved for\nfuture ACP variants.", + "type": "string" + } + }, + "required": ["outcome"], + "not": { + "anyOf": [ + { + "type": "object", + "properties": { + "outcome": { + "type": "string", + "const": "cancelled" + } + }, + "required": ["outcome"] + }, + { + "type": "object", + "properties": { + "outcome": { + "type": "string", + "const": "selected" + } + }, + "required": ["outcome"] + } + ] + }, + "additionalProperties": true } ], "discriminator": { diff --git a/schema/v2/schema.unstable.json b/schema/v2/schema.unstable.json index 72a43e755..b12fbc2d8 100644 --- a/schema/v2/schema.unstable.json +++ b/schema/v2/schema.unstable.json @@ -8468,7 +8468,7 @@ }, "RequestPermissionOutcome": { "description": "The outcome of a permission request.", - "oneOf": [ + "anyOf": [ { "description": "Active session work was cancelled before the user responded.\n\nWhen a client sends a `session/cancel` notification to cancel active\nsession work, it MUST respond to all pending `session/request_permission`\nrequests with this `Cancelled` outcome.\n\nSee protocol docs: [Cancellation](https://agentclientprotocol.com/protocol/v2/draft/prompt-lifecycle#cancellation)", "type": "object", @@ -8495,6 +8495,43 @@ "$ref": "#/$defs/SelectedPermissionOutcome" } ] + }, + { + "title": "other", + "description": "Custom or future permission outcome.\n\nValues beginning with `_` are reserved for implementation-specific\nextensions. Unknown values that do not begin with `_` are reserved for\nfuture ACP variants.\n\nAgents that do not understand this outcome MUST NOT treat it as approval.\nThey should preserve the raw payload when storing, replaying, proxying, or\nforwarding permission responses, and otherwise fail or decline the\npermission request according to policy.", + "type": "object", + "properties": { + "outcome": { + "description": "Custom or future permission outcome.\n\nValues beginning with `_` are reserved for implementation-specific\nextensions. Unknown values that do not begin with `_` are reserved for\nfuture ACP variants.", + "type": "string" + } + }, + "required": ["outcome"], + "not": { + "anyOf": [ + { + "type": "object", + "properties": { + "outcome": { + "type": "string", + "const": "cancelled" + } + }, + "required": ["outcome"] + }, + { + "type": "object", + "properties": { + "outcome": { + "type": "string", + "const": "selected" + } + }, + "required": ["outcome"] + } + ] + }, + "additionalProperties": true } ], "discriminator": { From 861cd09e203973cbd134a8c6eba81d6b58329849 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Wed, 1 Jul 2026 11:01:19 +0200 Subject: [PATCH 2/6] feat(schema): preserve unknown elicitation schemas --- .../src/v1/elicitation.rs | 146 +++++++++++++++++- .../src/v2/conversion.rs | 61 ++++++++ .../src/v2/elicitation.rs | 129 +++++++++++++++- docs/protocol/v1/draft/schema.mdx | 25 +++ docs/protocol/v2/draft/schema.mdx | 25 +++ docs/rfds/elicitation.mdx | 2 + schema/v1/schema.unstable.json | 69 ++++++++- schema/v2/schema.unstable.json | 69 ++++++++- 8 files changed, 522 insertions(+), 4 deletions(-) diff --git a/agent-client-protocol-schema/src/v1/elicitation.rs b/agent-client-protocol-schema/src/v1/elicitation.rs index 153b64aad..2b875d623 100644 --- a/agent-client-protocol-schema/src/v1/elicitation.rs +++ b/agent-client-protocol-schema/src/v1/elicitation.rs @@ -8,7 +8,7 @@ use std::{collections::BTreeMap, sync::Arc}; use derive_more::{Display, From}; -use schemars::JsonSchema; +use schemars::{JsonSchema, Schema}; use serde::{Deserialize, Serialize}; use serde_with::{DefaultOnError, VecSkipError, serde_as, skip_serializing_none}; @@ -827,6 +827,107 @@ pub enum ElicitationPropertySchema { Boolean(BooleanPropertySchema), /// Multi-select array property. Array(MultiSelectPropertySchema), + /// Custom or future elicitation property schema. + /// + /// Values beginning with `_` are reserved for implementation-specific + /// extensions. Unknown values that do not begin with `_` are reserved for + /// future ACP variants. + /// + /// Clients that do not understand this property schema type should preserve + /// the raw schema when storing, replaying, proxying, or forwarding + /// elicitation requests. They MUST NOT render it as a known input control. + #[serde(untagged)] + Other(OtherElicitationPropertySchema), +} + +/// Custom or future elicitation property schema payload. +/// +/// This preserves the unknown `type` discriminator and the rest of the property +/// schema object for clients that store, replay, proxy, or forward elicitation +/// requests. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)] +#[schemars(inline)] +#[schemars(transform = other_elicitation_property_schema_schema)] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub struct OtherElicitationPropertySchema { + /// Custom or future elicitation property schema type. + /// + /// Values beginning with `_` are reserved for implementation-specific + /// extensions. Unknown values that do not begin with `_` are reserved for + /// future ACP variants. + #[serde(rename = "type")] + pub type_: String, + /// Additional fields from the unknown property schema payload. + #[serde(flatten)] + pub fields: BTreeMap, +} + +impl OtherElicitationPropertySchema { + /// Builds [`OtherElicitationPropertySchema`] from an unknown discriminator and preserves the remaining extension fields. + #[must_use] + pub fn new(type_: impl Into, mut fields: BTreeMap) -> Self { + fields.remove("type"); + Self { + type_: type_.into(), + fields, + } + } +} + +impl<'de> Deserialize<'de> for OtherElicitationPropertySchema { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let mut fields = BTreeMap::::deserialize(deserializer)?; + let type_ = fields + .remove("type") + .ok_or_else(|| serde::de::Error::missing_field("type"))?; + let serde_json::Value::String(type_) = type_ else { + return Err(serde::de::Error::custom("`type` must be a string")); + }; + + if is_known_elicitation_property_schema_type(&type_) { + return Err(serde::de::Error::custom(format!( + "known elicitation property schema type `{type_}` did not match its schema" + ))); + } + + Ok(Self { type_, fields }) + } +} + +const KNOWN_ELICITATION_PROPERTY_SCHEMA_TYPES: &[&str] = + &["string", "number", "integer", "boolean", "array"]; + +fn is_known_elicitation_property_schema_type(type_: &str) -> bool { + KNOWN_ELICITATION_PROPERTY_SCHEMA_TYPES.contains(&type_) +} + +fn other_elicitation_property_schema_schema(schema: &mut Schema) { + let known_value_schemas: Vec<_> = KNOWN_ELICITATION_PROPERTY_SCHEMA_TYPES + .iter() + .map(|value| { + serde_json::json!({ + "properties": { + "type": { + "const": value, + "type": "string" + } + }, + "required": ["type"], + "type": "object" + }) + }) + .collect(); + + schema.insert( + "not".into(), + serde_json::json!({ + "anyOf": known_value_schemas + }), + ); } impl From for ElicitationPropertySchema { @@ -2133,6 +2234,49 @@ mod tests { )); } + #[test] + fn property_schema_preserves_unknown_type() { + let schema: ElicitationSchema = serde_json::from_value(json!({ + "type": "object", + "properties": { + "location": { + "type": "_location", + "title": "Location", + "precision": "city" + } + } + })) + .unwrap(); + + let ElicitationPropertySchema::Other(unknown) = schema.properties.get("location").unwrap() + else { + panic!("expected unknown property schema"); + }; + + assert_eq!(unknown.type_, "_location"); + assert_eq!(unknown.fields.get("title"), Some(&json!("Location"))); + assert_eq!(unknown.fields.get("precision"), Some(&json!("city"))); + assert_eq!( + serde_json::to_value(ElicitationPropertySchema::Other(unknown.clone())).unwrap(), + json!({ + "type": "_location", + "title": "Location", + "precision": "city" + }) + ); + } + + #[test] + fn property_schema_unknown_does_not_hide_malformed_known_type() { + assert!( + serde_json::from_value::(json!({ + "type": "array" + })) + .is_err() + ); + assert!(serde_json::from_value::(json!({})).is_err()); + } + #[test] fn schema_titled_enum_serialization() { let schema = ElicitationSchema::new().property( diff --git a/agent-client-protocol-schema/src/v2/conversion.rs b/agent-client-protocol-schema/src/v2/conversion.rs index b3776bae0..03c032310 100644 --- a/agent-client-protocol-schema/src/v2/conversion.rs +++ b/agent-client-protocol-schema/src/v2/conversion.rs @@ -8028,6 +8028,7 @@ impl IntoV1 for super::ElicitationPropertySchema { Self::Integer(value) => crate::v1::ElicitationPropertySchema::Integer(value.into_v1()?), Self::Boolean(value) => crate::v1::ElicitationPropertySchema::Boolean(value.into_v1()?), Self::Array(value) => crate::v1::ElicitationPropertySchema::Array(value.into_v1()?), + Self::Other(value) => crate::v1::ElicitationPropertySchema::Other(value.into_v1()?), }) } } @@ -8043,6 +8044,33 @@ impl IntoV2 for crate::v1::ElicitationPropertySchema { Self::Integer(value) => super::ElicitationPropertySchema::Integer(value.into_v2()?), Self::Boolean(value) => super::ElicitationPropertySchema::Boolean(value.into_v2()?), Self::Array(value) => super::ElicitationPropertySchema::Array(value.into_v2()?), + Self::Other(value) => super::ElicitationPropertySchema::Other(value.into_v2()?), + }) + } +} + +#[cfg(feature = "unstable_elicitation")] +impl IntoV1 for super::OtherElicitationPropertySchema { + type Output = crate::v1::OtherElicitationPropertySchema; + + fn into_v1(self) -> Result { + let Self { type_, fields } = self; + Ok(crate::v1::OtherElicitationPropertySchema { + type_: type_.into_v1()?, + fields: fields.into_v1()?, + }) + } +} + +#[cfg(feature = "unstable_elicitation")] +impl IntoV2 for crate::v1::OtherElicitationPropertySchema { + type Output = super::OtherElicitationPropertySchema; + + fn into_v2(self) -> Result { + let Self { type_, fields } = self; + Ok(super::OtherElicitationPropertySchema { + type_: type_.into_v2()?, + fields: fields.into_v2()?, }) } } @@ -9615,6 +9643,39 @@ mod tests { assert_json_eq_after_v1_to_v2::(request); } + #[cfg(feature = "unstable_elicitation")] + #[test] + fn round_trips_elicitation_property_schema_unknown_type() { + let v1_schema = v1::ElicitationSchema::new().property( + "location", + v1::ElicitationPropertySchema::Other(v1::OtherElicitationPropertySchema::new( + "_location", + std::collections::BTreeMap::from([( + "precision".to_string(), + serde_json::json!("city"), + )]), + )), + false, + ); + + assert_v1_round_trip::(v1_schema.clone()); + assert_json_eq_after_v1_to_v2::(v1_schema); + + let v2_schema = v2::ElicitationSchema::new().property( + "location", + v2::ElicitationPropertySchema::Other(v2::OtherElicitationPropertySchema::new( + "_location", + std::collections::BTreeMap::from([( + "precision".to_string(), + serde_json::json!("city"), + )]), + )), + false, + ); + + assert_v2_round_trip::(v2_schema); + } + #[test] fn prompt_responses_do_not_convert_across_v1_v2_lifecycle_boundary() { assert_v2_to_v1_error( diff --git a/agent-client-protocol-schema/src/v2/elicitation.rs b/agent-client-protocol-schema/src/v2/elicitation.rs index 36bcbfd7d..87a4c3805 100644 --- a/agent-client-protocol-schema/src/v2/elicitation.rs +++ b/agent-client-protocol-schema/src/v2/elicitation.rs @@ -8,7 +8,7 @@ use std::{collections::BTreeMap, sync::Arc}; use derive_more::{Display, From}; -use schemars::JsonSchema; +use schemars::{JsonSchema, Schema}; use serde::{Deserialize, Serialize}; use serde_with::{DefaultOnError, VecSkipError, serde_as, skip_serializing_none}; @@ -826,6 +826,90 @@ pub enum ElicitationPropertySchema { Boolean(BooleanPropertySchema), /// Multi-select array property. Array(MultiSelectPropertySchema), + /// Custom or future elicitation property schema. + /// + /// Values beginning with `_` are reserved for implementation-specific + /// extensions. Unknown values that do not begin with `_` are reserved for + /// future ACP variants. + /// + /// Clients that do not understand this property schema type should preserve + /// the raw schema when storing, replaying, proxying, or forwarding + /// elicitation requests. They MUST NOT render it as a known input control. + #[serde(untagged)] + Other(OtherElicitationPropertySchema), +} + +/// Custom or future elicitation property schema payload. +/// +/// This preserves the unknown `type` discriminator and the rest of the property +/// schema object for clients that store, replay, proxy, or forward elicitation +/// requests. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)] +#[schemars(inline)] +#[schemars(transform = other_elicitation_property_schema_schema)] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub struct OtherElicitationPropertySchema { + /// Custom or future elicitation property schema type. + /// + /// Values beginning with `_` are reserved for implementation-specific + /// extensions. Unknown values that do not begin with `_` are reserved for + /// future ACP variants. + #[serde(rename = "type")] + pub type_: String, + /// Additional fields from the unknown property schema payload. + #[serde(flatten)] + pub fields: BTreeMap, +} + +impl OtherElicitationPropertySchema { + /// Builds [`OtherElicitationPropertySchema`] from an unknown discriminator and preserves the remaining extension fields. + #[must_use] + pub fn new(type_: impl Into, mut fields: BTreeMap) -> Self { + fields.remove("type"); + Self { + type_: type_.into(), + fields, + } + } +} + +impl<'de> Deserialize<'de> for OtherElicitationPropertySchema { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let mut fields = BTreeMap::::deserialize(deserializer)?; + let type_ = fields + .remove("type") + .ok_or_else(|| serde::de::Error::missing_field("type"))?; + let serde_json::Value::String(type_) = type_ else { + return Err(serde::de::Error::custom("`type` must be a string")); + }; + + if is_known_elicitation_property_schema_type(&type_) { + return Err(serde::de::Error::custom(format!( + "known elicitation property schema type `{type_}` did not match its schema" + ))); + } + + Ok(Self { type_, fields }) + } +} + +const KNOWN_ELICITATION_PROPERTY_SCHEMA_TYPES: &[&str] = + &["string", "number", "integer", "boolean", "array"]; + +fn is_known_elicitation_property_schema_type(type_: &str) -> bool { + KNOWN_ELICITATION_PROPERTY_SCHEMA_TYPES.contains(&type_) +} + +fn other_elicitation_property_schema_schema(schema: &mut Schema) { + super::schema_util::reject_known_string_discriminators( + schema, + "type", + KNOWN_ELICITATION_PROPERTY_SCHEMA_TYPES, + ); } impl From for ElicitationPropertySchema { @@ -2129,6 +2213,49 @@ mod tests { )); } + #[test] + fn property_schema_preserves_unknown_type() { + let schema: ElicitationSchema = serde_json::from_value(json!({ + "type": "object", + "properties": { + "location": { + "type": "_location", + "title": "Location", + "precision": "city" + } + } + })) + .unwrap(); + + let ElicitationPropertySchema::Other(unknown) = schema.properties.get("location").unwrap() + else { + panic!("expected unknown property schema"); + }; + + assert_eq!(unknown.type_, "_location"); + assert_eq!(unknown.fields.get("title"), Some(&json!("Location"))); + assert_eq!(unknown.fields.get("precision"), Some(&json!("city"))); + assert_eq!( + serde_json::to_value(ElicitationPropertySchema::Other(unknown.clone())).unwrap(), + json!({ + "type": "_location", + "title": "Location", + "precision": "city" + }) + ); + } + + #[test] + fn property_schema_unknown_does_not_hide_malformed_known_type() { + assert!( + serde_json::from_value::(json!({ + "type": "array" + })) + .is_err() + ); + assert!(serde_json::from_value::(json!({})).is_err()); + } + #[test] fn schema_titled_enum_serialization() { let schema = ElicitationSchema::new().property( diff --git a/docs/protocol/v1/draft/schema.mdx b/docs/protocol/v1/draft/schema.mdx index 1daff3eea..2e25047cd 100644 --- a/docs/protocol/v1/draft/schema.mdx +++ b/docs/protocol/v1/draft/schema.mdx @@ -4050,6 +4050,31 @@ See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/v1/d + +Custom or future elicitation property schema. + +Values beginning with `_` are reserved for implementation-specific +extensions. Unknown values that do not begin with `_` are reserved for +future ACP variants. + +Clients that do not understand this property schema type should preserve +the raw schema when storing, replaying, proxying, or forwarding +elicitation requests. They MUST NOT render it as a known input control. + + + + + Custom or future elicitation property schema type. + +Values beginning with `_` are reserved for implementation-specific +extensions. Unknown values that do not begin with `_` are reserved for +future ACP variants. + + + + + + ## ElicitationRequestScope **UNSTABLE** diff --git a/docs/protocol/v2/draft/schema.mdx b/docs/protocol/v2/draft/schema.mdx index 90718868e..c58f7d557 100644 --- a/docs/protocol/v2/draft/schema.mdx +++ b/docs/protocol/v2/draft/schema.mdx @@ -3590,6 +3590,31 @@ See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/v2/d + +Custom or future elicitation property schema. + +Values beginning with `_` are reserved for implementation-specific +extensions. Unknown values that do not begin with `_` are reserved for +future ACP variants. + +Clients that do not understand this property schema type should preserve +the raw schema when storing, replaying, proxying, or forwarding +elicitation requests. They MUST NOT render it as a known input control. + + + + + Custom or future elicitation property schema type. + +Values beginning with `_` are reserved for implementation-specific +extensions. Unknown values that do not begin with `_` are reserved for +future ACP variants. + + + + + + ## ElicitationRequestScope **UNSTABLE** diff --git a/docs/rfds/elicitation.mdx b/docs/rfds/elicitation.mdx index d708a8181..f17efc91b 100644 --- a/docs/rfds/elicitation.mdx +++ b/docs/rfds/elicitation.mdx @@ -341,6 +341,8 @@ Multi-select enum (with titles): - Conditional validation - Custom formats beyond the supported list +Unknown property schema `type` values are reserved for future ACP variants or implementation-specific extensions. Implementation-specific values MUST begin with `_`. Clients that do not understand a property schema type should preserve the raw schema when storing, replaying, proxying, or forwarding elicitation requests, but MUST NOT render it as a known input control. + Clients use this schema to generate appropriate input forms, validate user input before sending, and provide better guidance to users. All primitive types support optional default values; clients SHOULD pre-populate form fields with these values. **Security note:** Following MCP, servers MUST NOT use form mode elicitation to request sensitive information (passwords, API keys, credentials). Sensitive data collection MUST use URL mode elicitation, which bypasses the agent and client entirely. diff --git a/schema/v1/schema.unstable.json b/schema/v1/schema.unstable.json index 76af7aba5..e6f17d4bb 100644 --- a/schema/v1/schema.unstable.json +++ b/schema/v1/schema.unstable.json @@ -1539,7 +1539,7 @@ }, "ElicitationPropertySchema": { "description": "Property schema for elicitation form fields.\n\nEach variant corresponds to a JSON Schema `\"type\"` value.\nSingle-select enums use the `String` variant with `enum` or `oneOf` set.\nMulti-select enums use the `Array` variant.", - "oneOf": [ + "anyOf": [ { "description": "String property (or single-select enum when `enum`/`oneOf` is set).", "type": "object", @@ -1619,6 +1619,73 @@ "$ref": "#/$defs/MultiSelectPropertySchema" } ] + }, + { + "title": "other", + "description": "Custom or future elicitation property schema.\n\nValues beginning with `_` are reserved for implementation-specific\nextensions. Unknown values that do not begin with `_` are reserved for\nfuture ACP variants.\n\nClients that do not understand this property schema type should preserve\nthe raw schema when storing, replaying, proxying, or forwarding\nelicitation requests. They MUST NOT render it as a known input control.", + "type": "object", + "properties": { + "type": { + "description": "Custom or future elicitation property schema type.\n\nValues beginning with `_` are reserved for implementation-specific\nextensions. Unknown values that do not begin with `_` are reserved for\nfuture ACP variants.", + "type": "string" + } + }, + "required": ["type"], + "not": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "string" + } + }, + "required": ["type"] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "number" + } + }, + "required": ["type"] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "integer" + } + }, + "required": ["type"] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "boolean" + } + }, + "required": ["type"] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "array" + } + }, + "required": ["type"] + } + ] + }, + "additionalProperties": true } ], "discriminator": { diff --git a/schema/v2/schema.unstable.json b/schema/v2/schema.unstable.json index b12fbc2d8..773be1758 100644 --- a/schema/v2/schema.unstable.json +++ b/schema/v2/schema.unstable.json @@ -1735,7 +1735,7 @@ }, "ElicitationPropertySchema": { "description": "Property schema for elicitation form fields.\n\nEach variant corresponds to a JSON Schema `\"type\"` value.\nSingle-select enums use the `String` variant with `enum` or `oneOf` set.\nMulti-select enums use the `Array` variant.", - "oneOf": [ + "anyOf": [ { "description": "String property (or single-select enum when `enum`/`oneOf` is set).", "type": "object", @@ -1815,6 +1815,73 @@ "$ref": "#/$defs/MultiSelectPropertySchema" } ] + }, + { + "title": "other", + "description": "Custom or future elicitation property schema.\n\nValues beginning with `_` are reserved for implementation-specific\nextensions. Unknown values that do not begin with `_` are reserved for\nfuture ACP variants.\n\nClients that do not understand this property schema type should preserve\nthe raw schema when storing, replaying, proxying, or forwarding\nelicitation requests. They MUST NOT render it as a known input control.", + "type": "object", + "properties": { + "type": { + "description": "Custom or future elicitation property schema type.\n\nValues beginning with `_` are reserved for implementation-specific\nextensions. Unknown values that do not begin with `_` are reserved for\nfuture ACP variants.", + "type": "string" + } + }, + "required": ["type"], + "not": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "string" + } + }, + "required": ["type"] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "number" + } + }, + "required": ["type"] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "integer" + } + }, + "required": ["type"] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "boolean" + } + }, + "required": ["type"] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "array" + } + }, + "required": ["type"] + } + ] + }, + "additionalProperties": true } ], "discriminator": { From c81ad14bfee88e222061e6e1067cb958d9e5f56d Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Wed, 1 Jul 2026 11:13:51 +0200 Subject: [PATCH 3/6] feat(schema): allow unknown elicitation modes --- .../src/v1/elicitation.rs | 172 ++++++++++++++++++ .../src/v2/conversion.rs | 77 ++++++++ .../src/v2/elicitation.rs | 151 +++++++++++++++ docs/protocol/v1/draft/schema.mdx | 25 +++ docs/protocol/v2/draft/schema.mdx | 25 +++ docs/rfds/elicitation.mdx | 2 + schema/v1/schema.unstable.json | 59 +++++- schema/v2/schema.unstable.json | 59 +++++- 8 files changed, 568 insertions(+), 2 deletions(-) diff --git a/agent-client-protocol-schema/src/v1/elicitation.rs b/agent-client-protocol-schema/src/v1/elicitation.rs index 2b875d623..0e292ef5d 100644 --- a/agent-client-protocol-schema/src/v1/elicitation.rs +++ b/agent-client-protocol-schema/src/v1/elicitation.rs @@ -1458,6 +1458,130 @@ pub enum ElicitationMode { Form(ElicitationFormMode), /// URL-based elicitation where the client directs the user to a URL. Url(ElicitationUrlMode), + /// Custom or future elicitation mode. + /// + /// Values beginning with `_` are reserved for implementation-specific + /// extensions. Unknown values that do not begin with `_` are reserved for + /// future ACP variants. + /// + /// Clients that do not understand this mode should preserve the raw payload + /// when storing, replaying, proxying, or forwarding elicitation requests. + /// They MUST NOT render it as a known elicitation mode. + #[serde(untagged)] + Other(OtherElicitationMode), +} + +/// Custom or future elicitation mode payload. +/// +/// This preserves the unknown `mode` discriminator and the rest of the mode +/// object for clients that store, replay, proxy, or forward elicitation +/// requests. +#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq)] +#[schemars(inline)] +#[schemars(transform = other_elicitation_mode_schema)] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub struct OtherElicitationMode { + /// Custom or future elicitation mode. + /// + /// Values beginning with `_` are reserved for implementation-specific + /// extensions. Unknown values that do not begin with `_` are reserved for + /// future ACP variants. + pub mode: String, + /// The scope this elicitation is tied to. + #[serde(flatten)] + pub scope: ElicitationScope, + /// Additional fields from the unknown elicitation mode payload. + #[serde(flatten)] + pub fields: BTreeMap, +} + +impl OtherElicitationMode { + /// Builds [`OtherElicitationMode`] from an unknown discriminator and preserves the remaining extension fields. + #[must_use] + pub fn new( + mode: impl Into, + scope: impl Into, + mut fields: BTreeMap, + ) -> Self { + fields.remove("mode"); + remove_elicitation_scope_fields(&mut fields); + Self { + mode: mode.into(), + scope: scope.into(), + fields, + } + } +} + +impl<'de> Deserialize<'de> for OtherElicitationMode { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let mut fields = BTreeMap::::deserialize(deserializer)?; + let mode = fields + .remove("mode") + .ok_or_else(|| serde::de::Error::missing_field("mode"))?; + let serde_json::Value::String(mode) = mode else { + return Err(serde::de::Error::custom("`mode` must be a string")); + }; + + if is_known_elicitation_mode(&mode) { + return Err(serde::de::Error::custom(format!( + "known elicitation mode `{mode}` did not match its schema" + ))); + } + + let scope = serde_json::from_value::(serde_json::Value::Object( + fields.clone().into_iter().collect(), + )) + .map_err(serde::de::Error::custom)?; + remove_elicitation_scope_fields(&mut fields); + + Ok(Self { + mode, + scope, + fields, + }) + } +} + +const KNOWN_ELICITATION_MODES: &[&str] = &["form", "url"]; + +fn is_known_elicitation_mode(mode: &str) -> bool { + KNOWN_ELICITATION_MODES.contains(&mode) +} + +fn remove_elicitation_scope_fields(fields: &mut BTreeMap) { + fields.remove("sessionId"); + fields.remove("toolCallId"); + fields.remove("requestId"); +} + +fn other_elicitation_mode_schema(schema: &mut Schema) { + let known_value_schemas: Vec<_> = KNOWN_ELICITATION_MODES + .iter() + .map(|value| { + serde_json::json!({ + "properties": { + "mode": { + "const": value, + "type": "string" + } + }, + "required": ["mode"], + "type": "object" + }) + }) + .collect(); + + schema.insert( + "not".into(), + serde_json::json!({ + "anyOf": known_value_schemas + }), + ); } impl From for ElicitationMode { @@ -1472,6 +1596,12 @@ impl From for ElicitationMode { } } +impl From for ElicitationMode { + fn from(mode: OtherElicitationMode) -> Self { + Self::Other(mode) + } +} + impl ElicitationMode { /// Returns the scope this elicitation mode is tied to. #[must_use] @@ -1479,6 +1609,7 @@ impl ElicitationMode { match self { Self::Form(f) => &f.scope, Self::Url(u) => &u.scope, + Self::Other(other) => &other.scope, } } } @@ -1971,6 +2102,47 @@ mod tests { assert!(matches!(roundtripped.mode, ElicitationMode::Url(_))); } + #[test] + fn unknown_mode_request_serialization() { + let json = json!({ + "requestId": 42, + "mode": "_browser", + "message": "Open a browser window", + "target": "login" + }); + + let req: CreateElicitationRequest = serde_json::from_value(json.clone()).unwrap(); + let ElicitationMode::Other(other) = &req.mode else { + panic!("expected unknown elicitation mode"); + }; + + assert_eq!(other.mode, "_browser"); + assert_eq!( + other.scope, + ElicitationRequestScope::new(RequestId::Number(42)).into() + ); + assert_eq!(other.fields.get("target"), Some(&json!("login"))); + assert_eq!( + *req.scope(), + ElicitationRequestScope::new(RequestId::Number(42)).into() + ); + assert_eq!(serde_json::to_value(&req).unwrap(), json); + } + + #[test] + fn unknown_mode_does_not_hide_malformed_known_mode() { + let missing_requested_schema = json!({ + "requestId": 42, + "mode": "form", + "message": "Enter your name" + }); + + assert!( + serde_json::from_value::(missing_requested_schema).is_err() + ); + assert!(serde_json::from_value::(json!({})).is_err()); + } + #[test] fn request_scope_request_serialization() { let req = CreateElicitationRequest::new( diff --git a/agent-client-protocol-schema/src/v2/conversion.rs b/agent-client-protocol-schema/src/v2/conversion.rs index 03c032310..ad0bebd9d 100644 --- a/agent-client-protocol-schema/src/v2/conversion.rs +++ b/agent-client-protocol-schema/src/v2/conversion.rs @@ -8323,6 +8323,7 @@ impl IntoV1 for super::ElicitationMode { Ok(match self { Self::Form(value) => crate::v1::ElicitationMode::Form(value.into_v1()?), Self::Url(value) => crate::v1::ElicitationMode::Url(value.into_v1()?), + Self::Other(value) => crate::v1::ElicitationMode::Other(value.into_v1()?), }) } } @@ -8335,6 +8336,43 @@ impl IntoV2 for crate::v1::ElicitationMode { Ok(match self { Self::Form(value) => super::ElicitationMode::Form(value.into_v2()?), Self::Url(value) => super::ElicitationMode::Url(value.into_v2()?), + Self::Other(value) => super::ElicitationMode::Other(value.into_v2()?), + }) + } +} + +#[cfg(feature = "unstable_elicitation")] +impl IntoV1 for super::OtherElicitationMode { + type Output = crate::v1::OtherElicitationMode; + + fn into_v1(self) -> Result { + let Self { + mode, + scope, + fields, + } = self; + Ok(crate::v1::OtherElicitationMode { + mode: mode.into_v1()?, + scope: scope.into_v1()?, + fields: fields.into_v1()?, + }) + } +} + +#[cfg(feature = "unstable_elicitation")] +impl IntoV2 for crate::v1::OtherElicitationMode { + type Output = super::OtherElicitationMode; + + fn into_v2(self) -> Result { + let Self { + mode, + scope, + fields, + } = self; + Ok(super::OtherElicitationMode { + mode: mode.into_v2()?, + scope: scope.into_v2()?, + fields: fields.into_v2()?, }) } } @@ -9676,6 +9714,45 @@ mod tests { assert_v2_round_trip::(v2_schema); } + #[cfg(feature = "unstable_elicitation")] + #[test] + fn round_trips_elicitation_mode_unknown_type() { + let v1_request = v1::CreateElicitationRequest::new( + v1::OtherElicitationMode::new( + "_browser", + v1::ElicitationRequestScope::new(v1::RequestId::Number(42)), + std::collections::BTreeMap::from([( + "target".to_string(), + serde_json::json!("login"), + )]), + ), + "Open a browser window", + ); + + assert_v1_round_trip::( + v1_request.clone(), + ); + assert_json_eq_after_v1_to_v2::( + v1_request, + ); + + let v2_request = v2::CreateElicitationRequest::new( + v2::OtherElicitationMode::new( + "_browser", + v2::ElicitationRequestScope::new(v2::RequestId::Number(42)), + std::collections::BTreeMap::from([( + "target".to_string(), + serde_json::json!("login"), + )]), + ), + "Open a browser window", + ); + + assert_v2_round_trip::( + v2_request, + ); + } + #[test] fn prompt_responses_do_not_convert_across_v1_v2_lifecycle_boundary() { assert_v2_to_v1_error( diff --git a/agent-client-protocol-schema/src/v2/elicitation.rs b/agent-client-protocol-schema/src/v2/elicitation.rs index 87a4c3805..750083264 100644 --- a/agent-client-protocol-schema/src/v2/elicitation.rs +++ b/agent-client-protocol-schema/src/v2/elicitation.rs @@ -1440,6 +1440,109 @@ pub enum ElicitationMode { Form(ElicitationFormMode), /// URL-based elicitation where the client directs the user to a URL. Url(ElicitationUrlMode), + /// Custom or future elicitation mode. + /// + /// Values beginning with `_` are reserved for implementation-specific + /// extensions. Unknown values that do not begin with `_` are reserved for + /// future ACP variants. + /// + /// Clients that do not understand this mode should preserve the raw payload + /// when storing, replaying, proxying, or forwarding elicitation requests. + /// They MUST NOT render it as a known elicitation mode. + #[serde(untagged)] + Other(OtherElicitationMode), +} + +/// Custom or future elicitation mode payload. +/// +/// This preserves the unknown `mode` discriminator and the rest of the mode +/// object for clients that store, replay, proxy, or forward elicitation +/// requests. +#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq)] +#[schemars(inline)] +#[schemars(transform = other_elicitation_mode_schema)] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub struct OtherElicitationMode { + /// Custom or future elicitation mode. + /// + /// Values beginning with `_` are reserved for implementation-specific + /// extensions. Unknown values that do not begin with `_` are reserved for + /// future ACP variants. + pub mode: String, + /// The scope this elicitation is tied to. + #[serde(flatten)] + pub scope: ElicitationScope, + /// Additional fields from the unknown elicitation mode payload. + #[serde(flatten)] + pub fields: BTreeMap, +} + +impl OtherElicitationMode { + /// Builds [`OtherElicitationMode`] from an unknown discriminator and preserves the remaining extension fields. + #[must_use] + pub fn new( + mode: impl Into, + scope: impl Into, + mut fields: BTreeMap, + ) -> Self { + fields.remove("mode"); + remove_elicitation_scope_fields(&mut fields); + Self { + mode: mode.into(), + scope: scope.into(), + fields, + } + } +} + +impl<'de> Deserialize<'de> for OtherElicitationMode { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let mut fields = BTreeMap::::deserialize(deserializer)?; + let mode = fields + .remove("mode") + .ok_or_else(|| serde::de::Error::missing_field("mode"))?; + let serde_json::Value::String(mode) = mode else { + return Err(serde::de::Error::custom("`mode` must be a string")); + }; + + if is_known_elicitation_mode(&mode) { + return Err(serde::de::Error::custom(format!( + "known elicitation mode `{mode}` did not match its schema" + ))); + } + + let scope = serde_json::from_value::(serde_json::Value::Object( + fields.clone().into_iter().collect(), + )) + .map_err(serde::de::Error::custom)?; + remove_elicitation_scope_fields(&mut fields); + + Ok(Self { + mode, + scope, + fields, + }) + } +} + +const KNOWN_ELICITATION_MODES: &[&str] = &["form", "url"]; + +fn is_known_elicitation_mode(mode: &str) -> bool { + KNOWN_ELICITATION_MODES.contains(&mode) +} + +fn remove_elicitation_scope_fields(fields: &mut BTreeMap) { + fields.remove("sessionId"); + fields.remove("toolCallId"); + fields.remove("requestId"); +} + +fn other_elicitation_mode_schema(schema: &mut Schema) { + super::schema_util::reject_known_string_discriminators(schema, "mode", KNOWN_ELICITATION_MODES); } impl From for ElicitationMode { @@ -1454,6 +1557,12 @@ impl From for ElicitationMode { } } +impl From for ElicitationMode { + fn from(mode: OtherElicitationMode) -> Self { + Self::Other(mode) + } +} + impl ElicitationMode { /// Returns the scope this elicitation mode is tied to. #[must_use] @@ -1461,6 +1570,7 @@ impl ElicitationMode { match self { Self::Form(f) => &f.scope, Self::Url(u) => &u.scope, + Self::Other(other) => &other.scope, } } } @@ -1953,6 +2063,47 @@ mod tests { assert!(matches!(roundtripped.mode, ElicitationMode::Url(_))); } + #[test] + fn unknown_mode_request_serialization() { + let json = json!({ + "requestId": 42, + "mode": "_browser", + "message": "Open a browser window", + "target": "login" + }); + + let req: CreateElicitationRequest = serde_json::from_value(json.clone()).unwrap(); + let ElicitationMode::Other(other) = &req.mode else { + panic!("expected unknown elicitation mode"); + }; + + assert_eq!(other.mode, "_browser"); + assert_eq!( + other.scope, + ElicitationRequestScope::new(RequestId::Number(42)).into() + ); + assert_eq!(other.fields.get("target"), Some(&json!("login"))); + assert_eq!( + *req.scope(), + ElicitationRequestScope::new(RequestId::Number(42)).into() + ); + assert_eq!(serde_json::to_value(&req).unwrap(), json); + } + + #[test] + fn unknown_mode_does_not_hide_malformed_known_mode() { + let missing_requested_schema = json!({ + "requestId": 42, + "mode": "form", + "message": "Enter your name" + }); + + assert!( + serde_json::from_value::(missing_requested_schema).is_err() + ); + assert!(serde_json::from_value::(json!({})).is_err()); + } + #[test] fn request_scope_request_serialization() { let req = CreateElicitationRequest::new( diff --git a/docs/protocol/v1/draft/schema.mdx b/docs/protocol/v1/draft/schema.mdx index 2e25047cd..e5a445de2 100644 --- a/docs/protocol/v1/draft/schema.mdx +++ b/docs/protocol/v1/draft/schema.mdx @@ -1754,6 +1754,31 @@ URL-based elicitation where the client directs the user to a URL. + +Custom or future elicitation mode. + +Values beginning with `_` are reserved for implementation-specific +extensions. Unknown values that do not begin with `_` are reserved for +future ACP variants. + +Clients that do not understand this mode should preserve the raw payload +when storing, replaying, proxying, or forwarding elicitation requests. +They MUST NOT render it as a known elicitation mode. + + + + + Custom or future elicitation mode. + +Values beginning with `_` are reserved for implementation-specific +extensions. Unknown values that do not begin with `_` are reserved for +future ACP variants. + + + + + + #### CreateElicitationResponse **UNSTABLE** diff --git a/docs/protocol/v2/draft/schema.mdx b/docs/protocol/v2/draft/schema.mdx index c58f7d557..8915dc195 100644 --- a/docs/protocol/v2/draft/schema.mdx +++ b/docs/protocol/v2/draft/schema.mdx @@ -1687,6 +1687,31 @@ URL-based elicitation where the client directs the user to a URL. + +Custom or future elicitation mode. + +Values beginning with `_` are reserved for implementation-specific +extensions. Unknown values that do not begin with `_` are reserved for +future ACP variants. + +Clients that do not understand this mode should preserve the raw payload +when storing, replaying, proxying, or forwarding elicitation requests. +They MUST NOT render it as a known elicitation mode. + + + + + Custom or future elicitation mode. + +Values beginning with `_` are reserved for implementation-specific +extensions. Unknown values that do not begin with `_` are reserved for +future ACP variants. + + + + + + #### CreateElicitationResponse **UNSTABLE** diff --git a/docs/rfds/elicitation.mdx b/docs/rfds/elicitation.mdx index f17efc91b..2ab413e8c 100644 --- a/docs/rfds/elicitation.mdx +++ b/docs/rfds/elicitation.mdx @@ -198,6 +198,8 @@ This distinction is reflected in the client capabilities model, allowing clients - For URL mode, the `url` parameter MUST contain a valid URL. - Agents MUST NOT return the `URLElicitationRequiredError` (code `-32042`) except when URL mode elicitation is required. +Unknown elicitation `mode` values are reserved for future ACP variants or implementation-specific extensions. Implementation-specific modes MUST begin with `_`. Clients that do not understand a mode should preserve the raw payload when storing, replaying, proxying, or forwarding elicitation requests, but MUST NOT render it as `form` or `url` or otherwise treat it as supported. + ### Restricted JSON Schema Aligning with [MCP's draft elicitation specification](https://modelcontextprotocol.io/specification/draft/client/elicitation), form mode elicitation uses a restricted subset of JSON Schema. Schemas are limited to flat objects with primitive properties only—the client decides how to render appropriate input UI based on the schema. diff --git a/schema/v1/schema.unstable.json b/schema/v1/schema.unstable.json index e6f17d4bb..069d5429c 100644 --- a/schema/v1/schema.unstable.json +++ b/schema/v1/schema.unstable.json @@ -1394,7 +1394,7 @@ "additionalProperties": true } }, - "oneOf": [ + "anyOf": [ { "description": "Form-based elicitation where the client renders a form from the provided schema.", "type": "object", @@ -1426,6 +1426,63 @@ "$ref": "#/$defs/ElicitationUrlMode" } ] + }, + { + "title": "other", + "description": "Custom or future elicitation mode.\n\nValues beginning with `_` are reserved for implementation-specific\nextensions. Unknown values that do not begin with `_` are reserved for\nfuture ACP variants.\n\nClients that do not understand this mode should preserve the raw payload\nwhen storing, replaying, proxying, or forwarding elicitation requests.\nThey MUST NOT render it as a known elicitation mode.", + "type": "object", + "properties": { + "mode": { + "description": "Custom or future elicitation mode.\n\nValues beginning with `_` are reserved for implementation-specific\nextensions. Unknown values that do not begin with `_` are reserved for\nfuture ACP variants.", + "type": "string" + } + }, + "required": ["mode"], + "anyOf": [ + { + "title": "Session", + "description": "Tied to a session, optionally to a specific tool call within that session.", + "allOf": [ + { + "$ref": "#/$defs/ElicitationSessionScope" + } + ] + }, + { + "title": "Request", + "description": "Tied to a specific JSON-RPC request outside of a session\n(e.g., during auth/configuration phases before any session is started).", + "allOf": [ + { + "$ref": "#/$defs/ElicitationRequestScope" + } + ] + } + ], + "unevaluatedProperties": true, + "not": { + "anyOf": [ + { + "type": "object", + "properties": { + "mode": { + "type": "string", + "const": "form" + } + }, + "required": ["mode"] + }, + { + "type": "object", + "properties": { + "mode": { + "type": "string", + "const": "url" + } + }, + "required": ["mode"] + } + ] + } } ], "discriminator": { diff --git a/schema/v2/schema.unstable.json b/schema/v2/schema.unstable.json index 773be1758..3a8b4b50d 100644 --- a/schema/v2/schema.unstable.json +++ b/schema/v2/schema.unstable.json @@ -1590,7 +1590,7 @@ "additionalProperties": true } }, - "oneOf": [ + "anyOf": [ { "description": "Form-based elicitation where the client renders a form from the provided schema.", "type": "object", @@ -1622,6 +1622,63 @@ "$ref": "#/$defs/ElicitationUrlMode" } ] + }, + { + "title": "other", + "description": "Custom or future elicitation mode.\n\nValues beginning with `_` are reserved for implementation-specific\nextensions. Unknown values that do not begin with `_` are reserved for\nfuture ACP variants.\n\nClients that do not understand this mode should preserve the raw payload\nwhen storing, replaying, proxying, or forwarding elicitation requests.\nThey MUST NOT render it as a known elicitation mode.", + "type": "object", + "properties": { + "mode": { + "description": "Custom or future elicitation mode.\n\nValues beginning with `_` are reserved for implementation-specific\nextensions. Unknown values that do not begin with `_` are reserved for\nfuture ACP variants.", + "type": "string" + } + }, + "required": ["mode"], + "anyOf": [ + { + "title": "Session", + "description": "Tied to a session, optionally to a specific tool call within that session.", + "allOf": [ + { + "$ref": "#/$defs/ElicitationSessionScope" + } + ] + }, + { + "title": "Request", + "description": "Tied to a specific JSON-RPC request outside of a session\n(e.g., during auth/configuration phases before any session is started).", + "allOf": [ + { + "$ref": "#/$defs/ElicitationRequestScope" + } + ] + } + ], + "unevaluatedProperties": true, + "not": { + "anyOf": [ + { + "type": "object", + "properties": { + "mode": { + "type": "string", + "const": "form" + } + }, + "required": ["mode"] + }, + { + "type": "object", + "properties": { + "mode": { + "type": "string", + "const": "url" + } + }, + "required": ["mode"] + } + ] + } } ], "discriminator": { From afd44dd7fd4065648448f686ad345e4d63e3aa0e Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Wed, 1 Jul 2026 11:33:54 +0200 Subject: [PATCH 4/6] feat(schema): allow unknown elicitation actions --- .../src/v1/elicitation.rs | 149 +++++++++++++++++- .../src/v2/conversion.rs | 59 +++++++ .../src/v2/elicitation.rs | 132 +++++++++++++++- docs/protocol/v1/draft/schema.mdx | 25 +++ docs/protocol/v2/draft/schema.mdx | 25 +++ docs/rfds/elicitation.mdx | 2 + schema/v1/schema.unstable.json | 49 +++++- schema/v2/schema.unstable.json | 49 +++++- 8 files changed, 484 insertions(+), 6 deletions(-) diff --git a/agent-client-protocol-schema/src/v1/elicitation.rs b/agent-client-protocol-schema/src/v1/elicitation.rs index 0e292ef5d..e8cd853e5 100644 --- a/agent-client-protocol-schema/src/v1/elicitation.rs +++ b/agent-client-protocol-schema/src/v1/elicitation.rs @@ -1706,8 +1706,11 @@ pub struct CreateElicitationResponse { impl CreateElicitationResponse { /// Builds [`CreateElicitationResponse`] with the required response fields set; optional fields start unset or empty. #[must_use] - pub fn new(action: ElicitationAction) -> Self { - Self { action, meta: None } + pub fn new(action: impl Into) -> Self { + Self { + action: action.into(), + meta: None, + } } /// The _meta property is reserved by ACP to allow clients and agents to attach additional @@ -1738,6 +1741,117 @@ pub enum ElicitationAction { Decline, /// The elicitation was cancelled. Cancel, + /// Custom or future elicitation action. + /// + /// Values beginning with `_` are reserved for implementation-specific + /// extensions. Unknown values that do not begin with `_` are reserved for + /// future ACP variants. + /// + /// Agents that do not understand this action should preserve the raw + /// payload when storing, replaying, proxying, or forwarding elicitation + /// responses. They MUST NOT treat it as a known elicitation action. + #[serde(untagged)] + Other(OtherElicitationAction), +} + +/// Custom or future elicitation action payload. +/// +/// This preserves the unknown `action` discriminator and the rest of the +/// response object for agents that store, replay, proxy, or forward elicitation +/// responses. +#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq)] +#[schemars(inline)] +#[schemars(transform = other_elicitation_action_schema)] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub struct OtherElicitationAction { + /// Custom or future elicitation action. + /// + /// Values beginning with `_` are reserved for implementation-specific + /// extensions. Unknown values that do not begin with `_` are reserved for + /// future ACP variants. + pub action: String, + /// Additional fields from the unknown elicitation action payload. + #[serde(flatten)] + pub fields: BTreeMap, +} + +impl OtherElicitationAction { + /// Builds [`OtherElicitationAction`] from an unknown discriminator and preserves the remaining extension fields. + #[must_use] + pub fn new(action: impl Into, mut fields: BTreeMap) -> Self { + fields.remove("action"); + Self { + action: action.into(), + fields, + } + } +} + +impl<'de> Deserialize<'de> for OtherElicitationAction { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let mut fields = BTreeMap::::deserialize(deserializer)?; + let action = fields + .remove("action") + .ok_or_else(|| serde::de::Error::missing_field("action"))?; + let serde_json::Value::String(action) = action else { + return Err(serde::de::Error::custom("`action` must be a string")); + }; + + if is_known_elicitation_action(&action) { + return Err(serde::de::Error::custom(format!( + "known elicitation action `{action}` did not match its schema" + ))); + } + + Ok(Self { action, fields }) + } +} + +const KNOWN_ELICITATION_ACTIONS: &[&str] = &["accept", "decline", "cancel"]; + +fn is_known_elicitation_action(action: &str) -> bool { + KNOWN_ELICITATION_ACTIONS.contains(&action) +} + +fn other_elicitation_action_schema(schema: &mut Schema) { + let known_value_schemas: Vec<_> = KNOWN_ELICITATION_ACTIONS + .iter() + .map(|value| { + serde_json::json!({ + "properties": { + "action": { + "const": value, + "type": "string" + } + }, + "required": ["action"], + "type": "object" + }) + }) + .collect(); + + schema.insert( + "not".into(), + serde_json::json!({ + "anyOf": known_value_schemas + }), + ); +} + +impl From for ElicitationAction { + fn from(action: ElicitationAcceptAction) -> Self { + Self::Accept(action) + } +} + +impl From for ElicitationAction { + fn from(action: OtherElicitationAction) -> Self { + Self::Other(action) + } } /// **UNSTABLE** @@ -2075,6 +2189,37 @@ mod tests { assert!(matches!(roundtripped.action, ElicitationAction::Cancel)); } + #[test] + fn unknown_action_response_serialization() { + let json = json!({ + "action": "_defer", + "reason": "waiting", + "retryAfterMs": 1000 + }); + + let resp: CreateElicitationResponse = serde_json::from_value(json.clone()).unwrap(); + let ElicitationAction::Other(other) = &resp.action else { + panic!("expected unknown elicitation action"); + }; + + assert_eq!(other.action, "_defer"); + assert_eq!(other.fields.get("reason"), Some(&json!("waiting"))); + assert_eq!(other.fields.get("retryAfterMs"), Some(&json!(1000))); + assert_eq!(serde_json::to_value(&resp).unwrap(), json); + } + + #[test] + fn unknown_action_does_not_hide_known_action() { + assert!( + serde_json::from_value::(json!({ + "action": "accept", + "content": {} + })) + .is_err() + ); + assert!(serde_json::from_value::(json!({})).is_err()); + } + #[test] fn url_mode_request_scope_serialization() { let req = CreateElicitationRequest::new( diff --git a/agent-client-protocol-schema/src/v2/conversion.rs b/agent-client-protocol-schema/src/v2/conversion.rs index ad0bebd9d..f1ec774fc 100644 --- a/agent-client-protocol-schema/src/v2/conversion.rs +++ b/agent-client-protocol-schema/src/v2/conversion.rs @@ -8480,6 +8480,7 @@ impl IntoV1 for super::ElicitationAction { Self::Accept(value) => crate::v1::ElicitationAction::Accept(value.into_v1()?), Self::Decline => crate::v1::ElicitationAction::Decline, Self::Cancel => crate::v1::ElicitationAction::Cancel, + Self::Other(value) => crate::v1::ElicitationAction::Other(value.into_v1()?), }) } } @@ -8493,6 +8494,33 @@ impl IntoV2 for crate::v1::ElicitationAction { Self::Accept(value) => super::ElicitationAction::Accept(value.into_v2()?), Self::Decline => super::ElicitationAction::Decline, Self::Cancel => super::ElicitationAction::Cancel, + Self::Other(value) => super::ElicitationAction::Other(value.into_v2()?), + }) + } +} + +#[cfg(feature = "unstable_elicitation")] +impl IntoV1 for super::OtherElicitationAction { + type Output = crate::v1::OtherElicitationAction; + + fn into_v1(self) -> Result { + let Self { action, fields } = self; + Ok(crate::v1::OtherElicitationAction { + action: action.into_v1()?, + fields: fields.into_v1()?, + }) + } +} + +#[cfg(feature = "unstable_elicitation")] +impl IntoV2 for crate::v1::OtherElicitationAction { + type Output = super::OtherElicitationAction; + + fn into_v2(self) -> Result { + let Self { action, fields } = self; + Ok(super::OtherElicitationAction { + action: action.into_v2()?, + fields: fields.into_v2()?, }) } } @@ -9753,6 +9781,37 @@ mod tests { ); } + #[cfg(feature = "unstable_elicitation")] + #[test] + fn round_trips_elicitation_action_unknown_type() { + let v1_response = v1::CreateElicitationResponse::new(v1::OtherElicitationAction::new( + "_defer", + std::collections::BTreeMap::from([ + ("reason".to_string(), serde_json::json!("waiting")), + ("retryAfterMs".to_string(), serde_json::json!(1000)), + ]), + )); + + assert_v1_round_trip::( + v1_response.clone(), + ); + assert_json_eq_after_v1_to_v2::( + v1_response, + ); + + let v2_response = v2::CreateElicitationResponse::new(v2::OtherElicitationAction::new( + "_defer", + std::collections::BTreeMap::from([ + ("reason".to_string(), serde_json::json!("waiting")), + ("retryAfterMs".to_string(), serde_json::json!(1000)), + ]), + )); + + assert_v2_round_trip::( + v2_response, + ); + } + #[test] fn prompt_responses_do_not_convert_across_v1_v2_lifecycle_boundary() { assert_v2_to_v1_error( diff --git a/agent-client-protocol-schema/src/v2/elicitation.rs b/agent-client-protocol-schema/src/v2/elicitation.rs index 750083264..ab98d6d48 100644 --- a/agent-client-protocol-schema/src/v2/elicitation.rs +++ b/agent-client-protocol-schema/src/v2/elicitation.rs @@ -1667,8 +1667,11 @@ pub struct CreateElicitationResponse { impl CreateElicitationResponse { /// Builds [`CreateElicitationResponse`] with the required response fields set; optional fields start unset or empty. #[must_use] - pub fn new(action: ElicitationAction) -> Self { - Self { action, meta: None } + pub fn new(action: impl Into) -> Self { + Self { + action: action.into(), + meta: None, + } } /// The _meta property is reserved by ACP to allow clients and agents to attach additional @@ -1699,6 +1702,100 @@ pub enum ElicitationAction { Decline, /// The elicitation was cancelled. Cancel, + /// Custom or future elicitation action. + /// + /// Values beginning with `_` are reserved for implementation-specific + /// extensions. Unknown values that do not begin with `_` are reserved for + /// future ACP variants. + /// + /// Agents that do not understand this action should preserve the raw + /// payload when storing, replaying, proxying, or forwarding elicitation + /// responses. They MUST NOT treat it as a known elicitation action. + #[serde(untagged)] + Other(OtherElicitationAction), +} + +/// Custom or future elicitation action payload. +/// +/// This preserves the unknown `action` discriminator and the rest of the +/// response object for agents that store, replay, proxy, or forward elicitation +/// responses. +#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq)] +#[schemars(inline)] +#[schemars(transform = other_elicitation_action_schema)] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub struct OtherElicitationAction { + /// Custom or future elicitation action. + /// + /// Values beginning with `_` are reserved for implementation-specific + /// extensions. Unknown values that do not begin with `_` are reserved for + /// future ACP variants. + pub action: String, + /// Additional fields from the unknown elicitation action payload. + #[serde(flatten)] + pub fields: BTreeMap, +} + +impl OtherElicitationAction { + /// Builds [`OtherElicitationAction`] from an unknown discriminator and preserves the remaining extension fields. + #[must_use] + pub fn new(action: impl Into, mut fields: BTreeMap) -> Self { + fields.remove("action"); + Self { + action: action.into(), + fields, + } + } +} + +impl<'de> Deserialize<'de> for OtherElicitationAction { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let mut fields = BTreeMap::::deserialize(deserializer)?; + let action = fields + .remove("action") + .ok_or_else(|| serde::de::Error::missing_field("action"))?; + let serde_json::Value::String(action) = action else { + return Err(serde::de::Error::custom("`action` must be a string")); + }; + + if is_known_elicitation_action(&action) { + return Err(serde::de::Error::custom(format!( + "known elicitation action `{action}` did not match its schema" + ))); + } + + Ok(Self { action, fields }) + } +} + +const KNOWN_ELICITATION_ACTIONS: &[&str] = &["accept", "decline", "cancel"]; + +fn is_known_elicitation_action(action: &str) -> bool { + KNOWN_ELICITATION_ACTIONS.contains(&action) +} + +fn other_elicitation_action_schema(schema: &mut Schema) { + super::schema_util::reject_known_string_discriminators( + schema, + "action", + KNOWN_ELICITATION_ACTIONS, + ); +} + +impl From for ElicitationAction { + fn from(action: ElicitationAcceptAction) -> Self { + Self::Accept(action) + } +} + +impl From for ElicitationAction { + fn from(action: OtherElicitationAction) -> Self { + Self::Other(action) + } } /// **UNSTABLE** @@ -2036,6 +2133,37 @@ mod tests { assert!(matches!(roundtripped.action, ElicitationAction::Cancel)); } + #[test] + fn unknown_action_response_serialization() { + let json = json!({ + "action": "_defer", + "reason": "waiting", + "retryAfterMs": 1000 + }); + + let resp: CreateElicitationResponse = serde_json::from_value(json.clone()).unwrap(); + let ElicitationAction::Other(other) = &resp.action else { + panic!("expected unknown elicitation action"); + }; + + assert_eq!(other.action, "_defer"); + assert_eq!(other.fields.get("reason"), Some(&json!("waiting"))); + assert_eq!(other.fields.get("retryAfterMs"), Some(&json!(1000))); + assert_eq!(serde_json::to_value(&resp).unwrap(), json); + } + + #[test] + fn unknown_action_does_not_hide_known_action() { + assert!( + serde_json::from_value::(json!({ + "action": "accept", + "content": {} + })) + .is_err() + ); + assert!(serde_json::from_value::(json!({})).is_err()); + } + #[test] fn url_mode_request_scope_serialization() { let req = CreateElicitationRequest::new( diff --git a/docs/protocol/v1/draft/schema.mdx b/docs/protocol/v1/draft/schema.mdx index e5a445de2..d192d6dd9 100644 --- a/docs/protocol/v1/draft/schema.mdx +++ b/docs/protocol/v1/draft/schema.mdx @@ -1841,6 +1841,31 @@ The elicitation was cancelled. + +Custom or future elicitation action. + +Values beginning with `_` are reserved for implementation-specific +extensions. Unknown values that do not begin with `_` are reserved for +future ACP variants. + +Agents that do not understand this action should preserve the raw +payload when storing, replaying, proxying, or forwarding elicitation +responses. They MUST NOT treat it as a known elicitation action. + + + + + Custom or future elicitation action. + +Values beginning with `_` are reserved for implementation-specific +extensions. Unknown values that do not begin with `_` are reserved for +future ACP variants. + + + + + + ### fs/read_text_file diff --git a/docs/protocol/v2/draft/schema.mdx b/docs/protocol/v2/draft/schema.mdx index 8915dc195..86867d2ec 100644 --- a/docs/protocol/v2/draft/schema.mdx +++ b/docs/protocol/v2/draft/schema.mdx @@ -1774,6 +1774,31 @@ The elicitation was cancelled. + +Custom or future elicitation action. + +Values beginning with `_` are reserved for implementation-specific +extensions. Unknown values that do not begin with `_` are reserved for +future ACP variants. + +Agents that do not understand this action should preserve the raw +payload when storing, replaying, proxying, or forwarding elicitation +responses. They MUST NOT treat it as a known elicitation action. + + + + + Custom or future elicitation action. + +Values beginning with `_` are reserved for implementation-specific +extensions. Unknown values that do not begin with `_` are reserved for +future ACP variants. + + + + + + ### mcp/connect diff --git a/docs/rfds/elicitation.mdx b/docs/rfds/elicitation.mdx index 2ab413e8c..9d3b82177 100644 --- a/docs/rfds/elicitation.mdx +++ b/docs/rfds/elicitation.mdx @@ -478,6 +478,8 @@ Elicitation responses use a three-action model (following MCP) to clearly distin } ``` +Unknown elicitation `action` values are reserved for future ACP variants or implementation-specific extensions. Implementation-specific actions MUST begin with `_`. Agents that do not understand an action should preserve the raw payload when storing, replaying, proxying, or forwarding elicitation responses, but MUST NOT treat it as `accept`, `decline`, or `cancel`. + For URL mode elicitation, the response with `action: "accept"` indicates that the user consented to the interaction. It does not mean the interaction is complete—the interaction occurs out-of-band and the client is not aware of the outcome until the agent sends a completion notification. Agents should handle each state appropriately: diff --git a/schema/v1/schema.unstable.json b/schema/v1/schema.unstable.json index 069d5429c..402dc5dc6 100644 --- a/schema/v1/schema.unstable.json +++ b/schema/v1/schema.unstable.json @@ -8214,7 +8214,7 @@ "additionalProperties": true } }, - "oneOf": [ + "anyOf": [ { "description": "The user accepted and provided content.", "type": "object", @@ -8252,6 +8252,53 @@ } }, "required": ["action"] + }, + { + "title": "other", + "description": "Custom or future elicitation action.\n\nValues beginning with `_` are reserved for implementation-specific\nextensions. Unknown values that do not begin with `_` are reserved for\nfuture ACP variants.\n\nAgents that do not understand this action should preserve the raw\npayload when storing, replaying, proxying, or forwarding elicitation\nresponses. They MUST NOT treat it as a known elicitation action.", + "type": "object", + "properties": { + "action": { + "description": "Custom or future elicitation action.\n\nValues beginning with `_` are reserved for implementation-specific\nextensions. Unknown values that do not begin with `_` are reserved for\nfuture ACP variants.", + "type": "string" + } + }, + "required": ["action"], + "not": { + "anyOf": [ + { + "type": "object", + "properties": { + "action": { + "type": "string", + "const": "accept" + } + }, + "required": ["action"] + }, + { + "type": "object", + "properties": { + "action": { + "type": "string", + "const": "decline" + } + }, + "required": ["action"] + }, + { + "type": "object", + "properties": { + "action": { + "type": "string", + "const": "cancel" + } + }, + "required": ["action"] + } + ] + }, + "additionalProperties": true } ], "discriminator": { diff --git a/schema/v2/schema.unstable.json b/schema/v2/schema.unstable.json index 3a8b4b50d..5e6480a44 100644 --- a/schema/v2/schema.unstable.json +++ b/schema/v2/schema.unstable.json @@ -8694,7 +8694,7 @@ "additionalProperties": true } }, - "oneOf": [ + "anyOf": [ { "description": "The user accepted and provided content.", "type": "object", @@ -8732,6 +8732,53 @@ } }, "required": ["action"] + }, + { + "title": "other", + "description": "Custom or future elicitation action.\n\nValues beginning with `_` are reserved for implementation-specific\nextensions. Unknown values that do not begin with `_` are reserved for\nfuture ACP variants.\n\nAgents that do not understand this action should preserve the raw\npayload when storing, replaying, proxying, or forwarding elicitation\nresponses. They MUST NOT treat it as a known elicitation action.", + "type": "object", + "properties": { + "action": { + "description": "Custom or future elicitation action.\n\nValues beginning with `_` are reserved for implementation-specific\nextensions. Unknown values that do not begin with `_` are reserved for\nfuture ACP variants.", + "type": "string" + } + }, + "required": ["action"], + "not": { + "anyOf": [ + { + "type": "object", + "properties": { + "action": { + "type": "string", + "const": "accept" + } + }, + "required": ["action"] + }, + { + "type": "object", + "properties": { + "action": { + "type": "string", + "const": "decline" + } + }, + "required": ["action"] + }, + { + "type": "object", + "properties": { + "action": { + "type": "string", + "const": "cancel" + } + }, + "required": ["action"] + } + ] + }, + "additionalProperties": true } ], "discriminator": { From 4ca6dad4b2280e69c98ad9ff9645d658b7aa2892 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Wed, 1 Jul 2026 12:27:59 +0200 Subject: [PATCH 5/6] feat(schema): support unknown multi-select items --- .../src/v1/elicitation.rs | 195 +++++++++++++++--- .../src/v2/conversion.rs | 87 +++++--- .../src/v2/elicitation.rs | 180 +++++++++++++--- docs/protocol/v1/draft/schema.mdx | 80 +++---- docs/protocol/v2/draft/schema.mdx | 80 +++---- docs/rfds/elicitation.mdx | 2 + schema/v1/schema.unstable.json | 71 ++++--- schema/v2/schema.unstable.json | 71 ++++--- 8 files changed, 546 insertions(+), 220 deletions(-) diff --git a/agent-client-protocol-schema/src/v1/elicitation.rs b/agent-client-protocol-schema/src/v1/elicitation.rs index e8cd853e5..5308e1142 100644 --- a/agent-client-protocol-schema/src/v1/elicitation.rs +++ b/agent-client-protocol-schema/src/v1/elicitation.rs @@ -567,25 +567,12 @@ impl BooleanPropertySchema { } } -/// Items definition for untitled multi-select enum properties. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)] -#[serde(rename_all = "snake_case")] -#[non_exhaustive] -pub enum ElicitationStringType { - /// String schema type. - #[default] - String, -} - -/// Items definition for untitled multi-select enum properties. +/// String item schema for multi-select enum properties. #[serde_as] #[skip_serializing_none] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[non_exhaustive] -pub struct UntitledMultiSelectItems { - /// Item type discriminator. Must be `"string"`. - #[serde(rename = "type")] - pub type_: ElicitationStringType, +pub struct StringMultiSelectItems { /// Allowed enum values. #[serde_as(deserialize_as = "DefaultOnError>")] #[schemars(extend("x-deserialize-default-on-error" = true, "x-deserialize-skip-invalid-items" = true))] @@ -603,15 +590,11 @@ pub struct UntitledMultiSelectItems { pub meta: Option, } -impl UntitledMultiSelectItems { - /// Create new titled multi-select items. +impl StringMultiSelectItems { + /// Create new string multi-select items. #[must_use] - pub fn new(type_: ElicitationStringType, values: Vec) -> Self { - Self { - type_, - values, - meta: None, - } + pub fn new(values: Vec) -> Self { + Self { values, meta: None } } /// The _meta property is reserved by ACP to allow clients and agents to attach additional @@ -671,14 +654,103 @@ impl TitledMultiSelectItems { } } +/// Custom or future typed item schema for multi-select properties. +/// +/// This preserves unknown item `type` values and the rest of the `items` +/// payload for clients that store, replay, proxy, or forward elicitation +/// requests. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)] +#[schemars(inline)] +#[schemars(transform = other_multi_select_items_schema)] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub struct OtherMultiSelectItems { + /// Custom or future multi-select item type. + /// + /// Values beginning with `_` are reserved for implementation-specific + /// extensions. Unknown values that do not begin with `_` are reserved for + /// future ACP variants. + #[serde(rename = "type")] + pub type_: String, + /// Additional fields from the unknown item schema payload. + #[serde(flatten)] + pub fields: BTreeMap, +} + +impl OtherMultiSelectItems { + /// Builds [`OtherMultiSelectItems`] from an unknown discriminator and preserves the remaining extension fields. + #[must_use] + pub fn new(type_: impl Into, mut fields: BTreeMap) -> Self { + fields.remove("type"); + Self { + type_: type_.into(), + fields, + } + } +} + +impl<'de> Deserialize<'de> for OtherMultiSelectItems { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let mut fields = BTreeMap::::deserialize(deserializer)?; + let type_ = fields + .remove("type") + .ok_or_else(|| serde::de::Error::missing_field("type"))?; + let serde_json::Value::String(type_) = type_ else { + return Err(serde::de::Error::custom("`type` must be a string")); + }; + + if is_known_multi_select_item_type(&type_) { + return Err(serde::de::Error::custom(format!( + "known multi-select item type `{type_}` did not match its schema" + ))); + } + + Ok(Self { type_, fields }) + } +} + +const KNOWN_MULTI_SELECT_ITEM_TYPES: &[&str] = &["string"]; + +fn is_known_multi_select_item_type(type_: &str) -> bool { + KNOWN_MULTI_SELECT_ITEM_TYPES.contains(&type_) +} + +fn other_multi_select_items_schema(schema: &mut Schema) { + schema.insert( + "not".into(), + serde_json::json!({ + "anyOf": [ + { + "properties": { + "type": { + "const": "string", + "type": "string" + } + }, + "required": ["type"], + "type": "object" + } + ] + }), + ); +} + /// Items for a multi-select (array) property schema. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] -#[serde(untagged)] +#[serde(tag = "type", rename_all = "snake_case")] +#[schemars(extend("discriminator" = {"propertyName": "type"}))] #[non_exhaustive] pub enum MultiSelectItems { - /// Untitled multi-select items with plain string values. - Untitled(UntitledMultiSelectItems), + /// Multi-select string items with plain string values. + String(StringMultiSelectItems), + /// Custom or future typed multi-select items. + #[serde(untagged)] + Other(OtherMultiSelectItems), /// Titled multi-select items with human-readable labels. + #[serde(untagged)] Titled(TitledMultiSelectItems), } @@ -737,10 +809,7 @@ impl MultiSelectPropertySchema { description: None, min_items: None, max_items: None, - items: MultiSelectItems::Untitled(UntitledMultiSelectItems::new( - ElicitationStringType::String, - values, - )), + items: MultiSelectItems::String(StringMultiSelectItems::new(values)), default: None, meta: None, } @@ -2545,10 +2614,68 @@ mod tests { assert_eq!(json["properties"]["colors"]["maxItems"], 3); let roundtripped: ElicitationSchema = serde_json::from_value(json).unwrap(); - assert!(matches!( - roundtripped.properties.get("colors").unwrap(), - ElicitationPropertySchema::Array(_) - )); + let ElicitationPropertySchema::Array(array) = + roundtripped.properties.get("colors").unwrap() + else { + panic!("expected Array variant"); + }; + let MultiSelectItems::String(items) = &array.items else { + panic!("expected String multi-select items"); + }; + assert_eq!(items.values.len(), 3); + } + + #[test] + fn multi_select_titled_items_keep_mcp_shape() { + let items = MultiSelectItems::Titled(TitledMultiSelectItems::new(vec![EnumOption::new( + "#ff0000", "Red", + )])); + + let json = serde_json::to_value(&items).unwrap(); + assert!(json.get("type").is_none()); + assert_eq!(json["anyOf"][0]["const"], "#ff0000"); + assert_eq!(json["anyOf"][0]["title"], "Red"); + + let roundtripped: MultiSelectItems = serde_json::from_value(json).unwrap(); + assert!(matches!(roundtripped, MultiSelectItems::Titled(_))); + } + + #[test] + fn multi_select_items_preserve_unknown_type() { + let json = json!({ + "type": "_token", + "format": "workspace", + "anyOf": [ + { "const": "repo", "title": "Repository" } + ] + }); + + let items: MultiSelectItems = serde_json::from_value(json.clone()).unwrap(); + let MultiSelectItems::Other(other) = &items else { + panic!("expected unknown multi-select items"); + }; + + assert_eq!(other.type_, "_token"); + assert_eq!(other.fields.get("format"), Some(&json!("workspace"))); + assert_eq!(other.fields.get("anyOf"), Some(&json["anyOf"])); + assert_eq!(serde_json::to_value(&items).unwrap(), json); + } + + #[test] + fn multi_select_items_unknown_does_not_hide_malformed_string_type() { + assert!( + serde_json::from_value::(json!({ + "type": "string" + })) + .is_err() + ); + assert!( + serde_json::from_value::(json!({ + "type": "string", + "format": "workspace" + })) + .is_err() + ); } #[test] diff --git a/agent-client-protocol-schema/src/v2/conversion.rs b/agent-client-protocol-schema/src/v2/conversion.rs index f1ec774fc..94a3ef19d 100644 --- a/agent-client-protocol-schema/src/v2/conversion.rs +++ b/agent-client-protocol-schema/src/v2/conversion.rs @@ -7858,59 +7858,53 @@ impl IntoV2 for crate::v1::BooleanPropertySchema { } #[cfg(feature = "unstable_elicitation")] -impl IntoV1 for super::ElicitationStringType { - type Output = crate::v1::ElicitationStringType; +impl IntoV1 for super::StringMultiSelectItems { + type Output = crate::v1::StringMultiSelectItems; fn into_v1(self) -> Result { - Ok(match self { - Self::String => crate::v1::ElicitationStringType::String, + let Self { values, meta } = self; + Ok(crate::v1::StringMultiSelectItems { + values: values.into_v1()?, + meta: meta.into_v1()?, }) } } #[cfg(feature = "unstable_elicitation")] -impl IntoV2 for crate::v1::ElicitationStringType { - type Output = super::ElicitationStringType; +impl IntoV2 for crate::v1::StringMultiSelectItems { + type Output = super::StringMultiSelectItems; fn into_v2(self) -> Result { - Ok(match self { - Self::String => super::ElicitationStringType::String, + let Self { values, meta } = self; + Ok(super::StringMultiSelectItems { + values: values.into_v2()?, + meta: meta.into_v2()?, }) } } #[cfg(feature = "unstable_elicitation")] -impl IntoV1 for super::UntitledMultiSelectItems { - type Output = crate::v1::UntitledMultiSelectItems; +impl IntoV1 for super::OtherMultiSelectItems { + type Output = crate::v1::OtherMultiSelectItems; fn into_v1(self) -> Result { - let Self { - type_, - values, - meta, - } = self; - Ok(crate::v1::UntitledMultiSelectItems { + let Self { type_, fields } = self; + Ok(crate::v1::OtherMultiSelectItems { type_: type_.into_v1()?, - values: values.into_v1()?, - meta: meta.into_v1()?, + fields: fields.into_v1()?, }) } } #[cfg(feature = "unstable_elicitation")] -impl IntoV2 for crate::v1::UntitledMultiSelectItems { - type Output = super::UntitledMultiSelectItems; +impl IntoV2 for crate::v1::OtherMultiSelectItems { + type Output = super::OtherMultiSelectItems; fn into_v2(self) -> Result { - let Self { - type_, - values, - meta, - } = self; - Ok(super::UntitledMultiSelectItems { + let Self { type_, fields } = self; + Ok(super::OtherMultiSelectItems { type_: type_.into_v2()?, - values: values.into_v2()?, - meta: meta.into_v2()?, + fields: fields.into_v2()?, }) } } @@ -7947,7 +7941,8 @@ impl IntoV1 for super::MultiSelectItems { fn into_v1(self) -> Result { Ok(match self { - Self::Untitled(value) => crate::v1::MultiSelectItems::Untitled(value.into_v1()?), + Self::String(value) => crate::v1::MultiSelectItems::String(value.into_v1()?), + Self::Other(value) => crate::v1::MultiSelectItems::Other(value.into_v1()?), Self::Titled(value) => crate::v1::MultiSelectItems::Titled(value.into_v1()?), }) } @@ -7959,7 +7954,8 @@ impl IntoV2 for crate::v1::MultiSelectItems { fn into_v2(self) -> Result { Ok(match self { - Self::Untitled(value) => super::MultiSelectItems::Untitled(value.into_v2()?), + Self::String(value) => super::MultiSelectItems::String(value.into_v2()?), + Self::Other(value) => super::MultiSelectItems::Other(value.into_v2()?), Self::Titled(value) => super::MultiSelectItems::Titled(value.into_v2()?), }) } @@ -9742,6 +9738,37 @@ mod tests { assert_v2_round_trip::(v2_schema); } + #[cfg(feature = "unstable_elicitation")] + #[test] + fn round_trips_multi_select_items_unknown_type() { + let v1_items = v1::MultiSelectItems::Other(v1::OtherMultiSelectItems::new( + "_token", + std::collections::BTreeMap::from([ + ("format".to_string(), serde_json::json!("workspace")), + ( + "anyOf".to_string(), + serde_json::json!([{ "const": "repo", "title": "Repository" }]), + ), + ]), + )); + + assert_v1_round_trip::(v1_items.clone()); + assert_json_eq_after_v1_to_v2::(v1_items); + + let v2_items = v2::MultiSelectItems::Other(v2::OtherMultiSelectItems::new( + "_token", + std::collections::BTreeMap::from([ + ("format".to_string(), serde_json::json!("workspace")), + ( + "anyOf".to_string(), + serde_json::json!([{ "const": "repo", "title": "Repository" }]), + ), + ]), + )); + + assert_v2_round_trip::(v2_items); + } + #[cfg(feature = "unstable_elicitation")] #[test] fn round_trips_elicitation_mode_unknown_type() { diff --git a/agent-client-protocol-schema/src/v2/elicitation.rs b/agent-client-protocol-schema/src/v2/elicitation.rs index ab98d6d48..1d8cc93ae 100644 --- a/agent-client-protocol-schema/src/v2/elicitation.rs +++ b/agent-client-protocol-schema/src/v2/elicitation.rs @@ -572,25 +572,12 @@ impl BooleanPropertySchema { } } -/// Items definition for untitled multi-select enum properties. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)] -#[serde(rename_all = "snake_case")] -#[non_exhaustive] -pub enum ElicitationStringType { - /// String schema type. - #[default] - String, -} - -/// Items definition for untitled multi-select enum properties. +/// String item schema for multi-select enum properties. #[serde_as] #[skip_serializing_none] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[non_exhaustive] -pub struct UntitledMultiSelectItems { - /// Item type discriminator. Must be `"string"`. - #[serde(rename = "type")] - pub type_: ElicitationStringType, +pub struct StringMultiSelectItems { /// Allowed enum values. #[serde_as(deserialize_as = "DefaultOnError>")] #[schemars(extend("x-deserialize-default-on-error" = true, "x-deserialize-skip-invalid-items" = true))] @@ -608,7 +595,13 @@ pub struct UntitledMultiSelectItems { pub meta: Option, } -impl UntitledMultiSelectItems { +impl StringMultiSelectItems { + /// Create new string multi-select items. + #[must_use] + pub fn new(values: Vec) -> Self { + Self { values, meta: None } + } + /// The _meta property is reserved by ACP to allow clients and agents to attach additional /// metadata to their interactions. Implementations MUST NOT make assumptions about values at /// these keys. @@ -666,14 +659,91 @@ impl TitledMultiSelectItems { } } +/// Custom or future typed item schema for multi-select properties. +/// +/// This preserves unknown item `type` values and the rest of the `items` +/// payload for clients that store, replay, proxy, or forward elicitation +/// requests. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)] +#[schemars(inline)] +#[schemars(transform = other_multi_select_items_schema)] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub struct OtherMultiSelectItems { + /// Custom or future multi-select item type. + /// + /// Values beginning with `_` are reserved for implementation-specific + /// extensions. Unknown values that do not begin with `_` are reserved for + /// future ACP variants. + #[serde(rename = "type")] + pub type_: String, + /// Additional fields from the unknown item schema payload. + #[serde(flatten)] + pub fields: BTreeMap, +} + +impl OtherMultiSelectItems { + /// Builds [`OtherMultiSelectItems`] from an unknown discriminator and preserves the remaining extension fields. + #[must_use] + pub fn new(type_: impl Into, mut fields: BTreeMap) -> Self { + fields.remove("type"); + Self { + type_: type_.into(), + fields, + } + } +} + +impl<'de> Deserialize<'de> for OtherMultiSelectItems { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let mut fields = BTreeMap::::deserialize(deserializer)?; + let type_ = fields + .remove("type") + .ok_or_else(|| serde::de::Error::missing_field("type"))?; + let serde_json::Value::String(type_) = type_ else { + return Err(serde::de::Error::custom("`type` must be a string")); + }; + + if is_known_multi_select_item_type(&type_) { + return Err(serde::de::Error::custom(format!( + "known multi-select item type `{type_}` did not match its schema" + ))); + } + + Ok(Self { type_, fields }) + } +} + +const KNOWN_MULTI_SELECT_ITEM_TYPES: &[&str] = &["string"]; + +fn is_known_multi_select_item_type(type_: &str) -> bool { + KNOWN_MULTI_SELECT_ITEM_TYPES.contains(&type_) +} + +fn other_multi_select_items_schema(schema: &mut Schema) { + super::schema_util::reject_known_string_discriminators( + schema, + "type", + KNOWN_MULTI_SELECT_ITEM_TYPES, + ); +} + /// Items for a multi-select (array) property schema. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] -#[serde(untagged)] +#[serde(tag = "type", rename_all = "snake_case")] +#[schemars(extend("discriminator" = {"propertyName": "type"}))] #[non_exhaustive] pub enum MultiSelectItems { - /// Untitled multi-select items with plain string values. - Untitled(UntitledMultiSelectItems), + /// Multi-select string items with plain string values. + String(StringMultiSelectItems), + /// Custom or future typed multi-select items. + #[serde(untagged)] + Other(OtherMultiSelectItems), /// Titled multi-select items with human-readable labels. + #[serde(untagged)] Titled(TitledMultiSelectItems), } @@ -732,11 +802,7 @@ impl MultiSelectPropertySchema { description: None, min_items: None, max_items: None, - items: MultiSelectItems::Untitled(UntitledMultiSelectItems { - type_: ElicitationStringType::String, - values, - meta: None, - }), + items: MultiSelectItems::String(StringMultiSelectItems::new(values)), default: None, meta: None, } @@ -2486,10 +2552,68 @@ mod tests { assert_eq!(json["properties"]["colors"]["maxItems"], 3); let roundtripped: ElicitationSchema = serde_json::from_value(json).unwrap(); - assert!(matches!( - roundtripped.properties.get("colors").unwrap(), - ElicitationPropertySchema::Array(_) - )); + let ElicitationPropertySchema::Array(array) = + roundtripped.properties.get("colors").unwrap() + else { + panic!("expected Array variant"); + }; + let MultiSelectItems::String(items) = &array.items else { + panic!("expected String multi-select items"); + }; + assert_eq!(items.values.len(), 3); + } + + #[test] + fn multi_select_titled_items_keep_mcp_shape() { + let items = MultiSelectItems::Titled(TitledMultiSelectItems::new(vec![EnumOption::new( + "#ff0000", "Red", + )])); + + let json = serde_json::to_value(&items).unwrap(); + assert!(json.get("type").is_none()); + assert_eq!(json["anyOf"][0]["const"], "#ff0000"); + assert_eq!(json["anyOf"][0]["title"], "Red"); + + let roundtripped: MultiSelectItems = serde_json::from_value(json).unwrap(); + assert!(matches!(roundtripped, MultiSelectItems::Titled(_))); + } + + #[test] + fn multi_select_items_preserve_unknown_type() { + let json = json!({ + "type": "_token", + "format": "workspace", + "anyOf": [ + { "const": "repo", "title": "Repository" } + ] + }); + + let items: MultiSelectItems = serde_json::from_value(json.clone()).unwrap(); + let MultiSelectItems::Other(other) = &items else { + panic!("expected unknown multi-select items"); + }; + + assert_eq!(other.type_, "_token"); + assert_eq!(other.fields.get("format"), Some(&json!("workspace"))); + assert_eq!(other.fields.get("anyOf"), Some(&json["anyOf"])); + assert_eq!(serde_json::to_value(&items).unwrap(), json); + } + + #[test] + fn multi_select_items_unknown_does_not_hide_malformed_string_type() { + assert!( + serde_json::from_value::(json!({ + "type": "string" + })) + .is_err() + ); + assert!( + serde_json::from_value::(json!({ + "type": "string", + "format": "workspace" + })) + .is_err() + ); } #[test] diff --git a/docs/protocol/v1/draft/schema.mdx b/docs/protocol/v1/draft/schema.mdx index d192d6dd9..9a0404718 100644 --- a/docs/protocol/v1/draft/schema.mdx +++ b/docs/protocol/v1/draft/schema.mdx @@ -4234,16 +4234,6 @@ during a tool call and needs to redirect it to the user. Optional tool call within the session. -## ElicitationStringType - -Items definition for untitled multi-select enum properties. - -**Type:** Union - - - String schema type. - - ## ElicitationUrlCapabilities **UNSTABLE** @@ -5111,8 +5101,8 @@ Items for a multi-select (array) property schema. **Type:** Union - -Untitled multi-select items with plain string values. + +Multi-select string items with plain string values. @@ -5127,14 +5117,31 @@ See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/v1/d "string"[]} required> Allowed enum values. -ElicitationStringType} required> - Item type discriminator. Must be `"string"`. + + The discriminator value. Must be `"string"`. - + +Custom or future typed multi-select items. + + + + + Custom or future multi-select item type. + +Values beginning with `_` are reserved for implementation-specific +extensions. Unknown values that do not begin with `_` are reserved for +future ACP variants. + + + + + + + Titled multi-select items with human-readable labels. @@ -8023,6 +8030,26 @@ String format types for string properties in elicitation schemas. Date-time format (ISO 8601). +## StringMultiSelectItems + +String item schema for multi-select enum properties. + +**Type:** Object + +**Properties:** + + + The _meta property is reserved by ACP to allow clients and agents to attach additional +metadata to their interactions. Implementations MUST NOT make assumptions about values at +these keys. + +See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/v1/draft/extensibility) + + +"string"[]} required> + Allowed enum values. + + ## StringPropertySchema Schema for string properties in an elicitation form. @@ -8560,29 +8587,6 @@ See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/v1/d A hint to display when the input hasn't been provided yet -## UntitledMultiSelectItems - -Items definition for untitled multi-select enum properties. - -**Type:** Object - -**Properties:** - - - The _meta property is reserved by ACP to allow clients and agents to attach additional -metadata to their interactions. Implementations MUST NOT make assumptions about values at -these keys. - -See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/v1/draft/extensibility) - - -"string"[]} required> - Allowed enum values. - -ElicitationStringType} required> - Item type discriminator. Must be `"string"`. - - ## Usage **UNSTABLE** diff --git a/docs/protocol/v2/draft/schema.mdx b/docs/protocol/v2/draft/schema.mdx index 86867d2ec..6cffbf402 100644 --- a/docs/protocol/v2/draft/schema.mdx +++ b/docs/protocol/v2/draft/schema.mdx @@ -3774,16 +3774,6 @@ during a tool call and needs to redirect it to the user. Optional tool call within the session. -## ElicitationStringType - -Items definition for untitled multi-select enum properties. - -**Type:** Union - - - String schema type. - - ## ElicitationUrlCapabilities **UNSTABLE** @@ -4676,8 +4666,8 @@ Items for a multi-select (array) property schema. **Type:** Union - -Untitled multi-select items with plain string values. + +Multi-select string items with plain string values. @@ -4692,14 +4682,31 @@ See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/v2/d "string"[]} required> Allowed enum values. -ElicitationStringType} required> - Item type discriminator. Must be `"string"`. + + The discriminator value. Must be `"string"`. - + +Custom or future typed multi-select items. + + + + + Custom or future multi-select item type. + +Values beginning with `_` are reserved for implementation-specific +extensions. Unknown values that do not begin with `_` are reserved for +future ACP variants. + + + + + + + Titled multi-select items with human-readable labels. @@ -7944,6 +7951,26 @@ format should treat it as an annotation rather than rejecting the schema. +## StringMultiSelectItems + +String item schema for multi-select enum properties. + +**Type:** Object + +**Properties:** + + + The _meta property is reserved by ACP to allow clients and agents to attach additional +metadata to their interactions. Implementations MUST NOT make assumptions about values at +these keys. + +See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/v2/draft/extensibility) + + +"string"[]} required> + Allowed enum values. + + ## StringPropertySchema Schema for string properties in an elicitation form. @@ -8454,29 +8481,6 @@ See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/v2/d A hint to display when the input hasn't been provided yet -## UntitledMultiSelectItems - -Items definition for untitled multi-select enum properties. - -**Type:** Object - -**Properties:** - - - The _meta property is reserved by ACP to allow clients and agents to attach additional -metadata to their interactions. Implementations MUST NOT make assumptions about values at -these keys. - -See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/v2/draft/extensibility) - - -"string"[]} required> - Allowed enum values. - -ElicitationStringType} required> - Item type discriminator. Must be `"string"`. - - ## Usage **UNSTABLE** diff --git a/docs/rfds/elicitation.mdx b/docs/rfds/elicitation.mdx index 9d3b82177..78d722d9e 100644 --- a/docs/rfds/elicitation.mdx +++ b/docs/rfds/elicitation.mdx @@ -316,6 +316,8 @@ Multi-select enum (with titles): } ``` +Unknown multi-select `items.type` values are reserved for future ACP variants or implementation-specific extensions. Implementation-specific values MUST begin with `_`. Clients that do not understand a multi-select item type should preserve the raw `items` schema when storing, replaying, proxying, or forwarding elicitation requests, but MUST NOT render it as string multi-select items. + **Request schema structure:** ```json diff --git a/schema/v1/schema.unstable.json b/schema/v1/schema.unstable.json index 402dc5dc6..77f326815 100644 --- a/schema/v1/schema.unstable.json +++ b/schema/v1/schema.unstable.json @@ -1982,16 +1982,50 @@ "description": "Items for a multi-select (array) property schema.", "anyOf": [ { - "title": "Untitled", - "description": "Untitled multi-select items with plain string values.", + "description": "Multi-select string items with plain string values.", + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "string" + } + }, + "required": ["type"], "allOf": [ { - "$ref": "#/$defs/UntitledMultiSelectItems" + "$ref": "#/$defs/StringMultiSelectItems" } ] }, { - "title": "Titled", + "title": "other", + "description": "Custom or future typed multi-select items.", + "type": "object", + "properties": { + "type": { + "description": "Custom or future multi-select item type.\n\nValues beginning with `_` are reserved for implementation-specific\nextensions. Unknown values that do not begin with `_` are reserved for\nfuture ACP variants.", + "type": "string" + } + }, + "required": ["type"], + "not": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "string" + } + }, + "required": ["type"] + } + ] + }, + "additionalProperties": true + }, + { + "title": "titled", "description": "Titled multi-select items with human-readable labels.", "allOf": [ { @@ -1999,20 +2033,15 @@ } ] } - ] + ], + "discriminator": { + "propertyName": "type" + } }, - "UntitledMultiSelectItems": { - "description": "Items definition for untitled multi-select enum properties.", + "StringMultiSelectItems": { + "description": "String item schema for multi-select enum properties.", "type": "object", "properties": { - "type": { - "description": "Item type discriminator. Must be `\"string\"`.", - "allOf": [ - { - "$ref": "#/$defs/ElicitationStringType" - } - ] - }, "enum": { "description": "Allowed enum values.", "type": "array", @@ -2029,17 +2058,7 @@ "additionalProperties": true } }, - "required": ["type", "enum"] - }, - "ElicitationStringType": { - "description": "Items definition for untitled multi-select enum properties.", - "oneOf": [ - { - "description": "String schema type.", - "type": "string", - "const": "string" - } - ] + "required": ["enum"] }, "TitledMultiSelectItems": { "description": "Items definition for titled multi-select enum properties.", diff --git a/schema/v2/schema.unstable.json b/schema/v2/schema.unstable.json index 5e6480a44..7ab3c91f7 100644 --- a/schema/v2/schema.unstable.json +++ b/schema/v2/schema.unstable.json @@ -2183,16 +2183,50 @@ "description": "Items for a multi-select (array) property schema.", "anyOf": [ { - "title": "Untitled", - "description": "Untitled multi-select items with plain string values.", + "description": "Multi-select string items with plain string values.", + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "string" + } + }, + "required": ["type"], "allOf": [ { - "$ref": "#/$defs/UntitledMultiSelectItems" + "$ref": "#/$defs/StringMultiSelectItems" } ] }, { - "title": "Titled", + "title": "other", + "description": "Custom or future typed multi-select items.", + "type": "object", + "properties": { + "type": { + "description": "Custom or future multi-select item type.\n\nValues beginning with `_` are reserved for implementation-specific\nextensions. Unknown values that do not begin with `_` are reserved for\nfuture ACP variants.", + "type": "string" + } + }, + "required": ["type"], + "not": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "string" + } + }, + "required": ["type"] + } + ] + }, + "additionalProperties": true + }, + { + "title": "titled", "description": "Titled multi-select items with human-readable labels.", "allOf": [ { @@ -2200,20 +2234,15 @@ } ] } - ] + ], + "discriminator": { + "propertyName": "type" + } }, - "UntitledMultiSelectItems": { - "description": "Items definition for untitled multi-select enum properties.", + "StringMultiSelectItems": { + "description": "String item schema for multi-select enum properties.", "type": "object", "properties": { - "type": { - "description": "Item type discriminator. Must be `\"string\"`.", - "allOf": [ - { - "$ref": "#/$defs/ElicitationStringType" - } - ] - }, "enum": { "description": "Allowed enum values.", "type": "array", @@ -2230,17 +2259,7 @@ "additionalProperties": true } }, - "required": ["type", "enum"] - }, - "ElicitationStringType": { - "description": "Items definition for untitled multi-select enum properties.", - "oneOf": [ - { - "description": "String schema type.", - "type": "string", - "const": "string" - } - ] + "required": ["enum"] }, "TitledMultiSelectItems": { "description": "Items definition for titled multi-select enum properties.", From 09ab00849e4796c9cb7935be3b58ea3f92e82932 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Wed, 1 Jul 2026 14:51:30 +0200 Subject: [PATCH 6/6] feat(unstable-v2): Discriminate v2 command input variants --- agent-client-protocol-schema/src/v2/client.rs | 104 +++++++++++------- .../src/v2/conversion.rs | 39 +++++-- .../src/v2/schema_util.rs | 10 -- docs/protocol/v2/draft/schema.mdx | 45 ++++---- docs/protocol/v2/draft/slash-commands.mdx | 2 +- docs/protocol/v2/schema.mdx | 45 ++++---- docs/protocol/v2/slash-commands.mdx | 2 +- schema/v2/schema.json | 38 +++++-- schema/v2/schema.unstable.json | 38 +++++-- 9 files changed, 201 insertions(+), 122 deletions(-) diff --git a/agent-client-protocol-schema/src/v2/client.rs b/agent-client-protocol-schema/src/v2/client.rs index 17134a34f..abd086ef8 100644 --- a/agent-client-protocol-schema/src/v2/client.rs +++ b/agent-client-protocol-schema/src/v2/client.rs @@ -1119,11 +1119,13 @@ impl AvailableCommand { /// The input specification for a command. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] -#[serde(untagged, rename_all = "camelCase")] +#[serde(tag = "type", rename_all = "snake_case")] +#[schemars(extend("discriminator" = {"propertyName": "type"}))] #[non_exhaustive] pub enum AvailableCommandInput { /// All text that was typed after the command name is provided as input. - Unstructured(UnstructuredCommandInput), + #[serde(rename = "text")] + Text(TextCommandInput), /// Custom or future command input specification. /// /// Values beginning with `_` are reserved for implementation-specific @@ -1134,12 +1136,14 @@ pub enum AvailableCommandInput { /// payload when storing, replaying, proxying, or forwarding command /// metadata, and otherwise ignore the input specification or display the /// command without structured input. + #[serde(untagged)] Other(OtherAvailableCommandInput), } /// Custom or future command input specification. #[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)] #[schemars(inline)] +#[schemars(transform = other_available_command_input_schema)] #[serde(rename_all = "camelCase")] #[non_exhaustive] pub struct OtherAvailableCommandInput { @@ -1180,18 +1184,37 @@ impl<'de> Deserialize<'de> for OtherAvailableCommandInput { return Err(serde::de::Error::custom("`type` must be a string")); }; + if is_known_available_command_input_type(&type_) { + return Err(serde::de::Error::custom(format!( + "known available command input type `{type_}` did not match its schema" + ))); + } + Ok(Self { type_, fields }) } } +const KNOWN_AVAILABLE_COMMAND_INPUT_TYPES: &[&str] = &["text"]; + +fn is_known_available_command_input_type(type_: &str) -> bool { + KNOWN_AVAILABLE_COMMAND_INPUT_TYPES.contains(&type_) +} + +fn other_available_command_input_schema(schema: &mut Schema) { + super::schema_util::reject_known_string_discriminators( + schema, + "type", + KNOWN_AVAILABLE_COMMAND_INPUT_TYPES, + ); +} + /// All text that was typed after the command name is provided as input. #[serde_as] #[skip_serializing_none] -#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)] -#[schemars(transform = unstructured_command_input_schema)] +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] #[serde(rename_all = "camelCase")] #[non_exhaustive] -pub struct UnstructuredCommandInput { +pub struct TextCommandInput { /// A hint to display when the input hasn't been provided yet pub hint: String, /// The _meta property is reserved by ACP to allow clients and agents to attach additional @@ -1206,8 +1229,8 @@ pub struct UnstructuredCommandInput { pub meta: Option, } -impl UnstructuredCommandInput { - /// Builds [`UnstructuredCommandInput`] with the required fields set; optional fields start unset or empty. +impl TextCommandInput { + /// Builds [`TextCommandInput`] with the required fields set; optional fields start unset or empty. #[must_use] pub fn new(hint: impl Into) -> Self { Self { @@ -1228,39 +1251,6 @@ impl UnstructuredCommandInput { } } -impl<'de> Deserialize<'de> for UnstructuredCommandInput { - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - #[derive(Deserialize)] - #[serde(rename_all = "camelCase")] - struct RawUnstructuredCommandInput { - hint: String, - #[serde(rename = "_meta")] - meta: Option, - #[serde(flatten)] - fields: BTreeMap, - } - - let raw = RawUnstructuredCommandInput::deserialize(deserializer)?; - if raw.fields.contains_key("type") { - return Err(serde::de::Error::custom( - "unstructured command input cannot include a `type` field", - )); - } - - Ok(Self { - hint: raw.hint, - meta: raw.meta, - }) - } -} - -fn unstructured_command_input_schema(schema: &mut Schema) { - super::schema_util::reject_property(schema, "type"); -} - // Permission /// Request for user permission to execute a tool call. @@ -2589,6 +2579,25 @@ mod tests { ); } + #[test] + fn available_command_input_text_uses_type_discriminator() { + use serde_json::json; + + let input = AvailableCommandInput::Text(TextCommandInput::new("Describe changes")); + + let json = serde_json::to_value(&input).unwrap(); + assert_eq!( + json, + json!({ + "type": "text", + "hint": "Describe changes" + }) + ); + + let roundtripped: AvailableCommandInput = serde_json::from_value(json).unwrap(); + assert!(matches!(roundtripped, AvailableCommandInput::Text(_))); + } + #[test] fn request_permission_outcome_preserves_unknown_variant() { use serde_json::json; @@ -2636,10 +2645,16 @@ mod tests { } #[test] - fn available_command_input_unknown_does_not_hide_malformed_unstructured_variant() { + fn available_command_input_unknown_does_not_hide_malformed_text_variant() { use serde_json::json; assert!(serde_json::from_value::(json!({})).is_err()); + assert!( + serde_json::from_value::(json!({ + "hint": "Pick one" + })) + .is_err() + ); assert!( serde_json::from_value::(json!({ "type": 1, @@ -2647,6 +2662,13 @@ mod tests { })) .is_err() ); + assert!( + serde_json::from_value::(json!({ + "type": "text", + "hint": "Pick one" + })) + .is_err() + ); } #[cfg(feature = "unstable_nes")] diff --git a/agent-client-protocol-schema/src/v2/conversion.rs b/agent-client-protocol-schema/src/v2/conversion.rs index 94a3ef19d..a187cf131 100644 --- a/agent-client-protocol-schema/src/v2/conversion.rs +++ b/agent-client-protocol-schema/src/v2/conversion.rs @@ -1442,9 +1442,7 @@ impl IntoV1 for super::AvailableCommandInput { fn into_v1(self) -> Result { Ok(match self { - Self::Unstructured(value) => { - crate::v1::AvailableCommandInput::Unstructured(value.into_v1()?) - } + Self::Text(value) => crate::v1::AvailableCommandInput::Unstructured(value.into_v1()?), Self::Other(value) => { return Err(unknown_v2_enum_variant( "AvailableCommandInput", @@ -1460,14 +1458,12 @@ impl IntoV2 for crate::v1::AvailableCommandInput { fn into_v2(self) -> Result { Ok(match self { - Self::Unstructured(value) => { - super::AvailableCommandInput::Unstructured(value.into_v2()?) - } + Self::Unstructured(value) => super::AvailableCommandInput::Text(value.into_v2()?), }) } } -impl IntoV1 for super::UnstructuredCommandInput { +impl IntoV1 for super::TextCommandInput { type Output = crate::v1::UnstructuredCommandInput; fn into_v1(self) -> Result { @@ -1480,11 +1476,11 @@ impl IntoV1 for super::UnstructuredCommandInput { } impl IntoV2 for crate::v1::UnstructuredCommandInput { - type Output = super::UnstructuredCommandInput; + type Output = super::TextCommandInput; fn into_v2(self) -> Result { let Self { hint, meta } = self; - Ok(super::UnstructuredCommandInput { + Ok(super::TextCommandInput { hint: hint.into_v2()?, meta: meta.into_v2()?, }) @@ -10437,6 +10433,31 @@ mod tests { ); } + #[test] + fn available_command_input_conversion_adds_v2_discriminator() { + let input = v1::AvailableCommandInput::Unstructured(v1::UnstructuredCommandInput::new( + "Describe changes", + )); + + let v2_input: v2::AvailableCommandInput = v1_to_v2(input.clone()).unwrap(); + assert_eq!( + serde_json::to_value(&v2_input).unwrap(), + serde_json::json!({ + "type": "text", + "hint": "Describe changes" + }) + ); + + let v1_input: v1::AvailableCommandInput = v2_to_v1(v2_input).unwrap(); + assert_eq!(v1_input, input); + assert_eq!( + serde_json::to_value(v1_input).unwrap(), + serde_json::json!({ + "hint": "Describe changes" + }) + ); + } + #[test] fn v2_plan_entries_skip_unrepresentable_items_inside_tolerant_vectors() { let update = v2::PlanUpdate::new(v2::PlanUpdateContent::items( diff --git a/agent-client-protocol-schema/src/v2/schema_util.rs b/agent-client-protocol-schema/src/v2/schema_util.rs index 9b4158321..8bab35ec0 100644 --- a/agent-client-protocol-schema/src/v2/schema_util.rs +++ b/agent-client-protocol-schema/src/v2/schema_util.rs @@ -29,13 +29,3 @@ pub(crate) fn reject_known_string_discriminators( }), ); } - -pub(crate) fn reject_property(schema: &mut Schema, property_name: &str) { - schema.insert( - "not".into(), - json!({ - "required": [property_name], - "type": "object" - }), - ); -} diff --git a/docs/protocol/v2/draft/schema.mdx b/docs/protocol/v2/draft/schema.mdx index 6cffbf402..62f075213 100644 --- a/docs/protocol/v2/draft/schema.mdx +++ b/docs/protocol/v2/draft/schema.mdx @@ -2732,7 +2732,7 @@ The input specification for a command. **Type:** Union - + All text that was typed after the command name is provided as input. @@ -2748,6 +2748,9 @@ See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/v2/d A hint to display when the input hasn't been provided yet + + The discriminator value. Must be `"text"`. + @@ -8047,6 +8050,26 @@ See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/v2/d +## TextCommandInput + +All text that was typed after the command name is provided as input. + +**Type:** Object + +**Properties:** + + + The _meta property is reserved by ACP to allow clients and agents to attach additional +metadata to their interactions. Implementations MUST NOT make assumptions about values at +these keys. + +See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/v2/draft/extensibility) + + + + A hint to display when the input hasn't been provided yet + + ## TextContent Text provided to or from an LLM. @@ -8461,26 +8484,6 @@ future ACP variants. -## UnstructuredCommandInput - -All text that was typed after the command name is provided as input. - -**Type:** Object - -**Properties:** - - - The _meta property is reserved by ACP to allow clients and agents to attach additional -metadata to their interactions. Implementations MUST NOT make assumptions about values at -these keys. - -See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/v2/draft/extensibility) - - - - A hint to display when the input hasn't been provided yet - - ## Usage **UNSTABLE** diff --git a/docs/protocol/v2/draft/slash-commands.mdx b/docs/protocol/v2/draft/slash-commands.mdx index 776c5371a..153c03b36 100644 --- a/docs/protocol/v2/draft/slash-commands.mdx +++ b/docs/protocol/v2/draft/slash-commands.mdx @@ -62,7 +62,7 @@ After creating a session, the Agent **MAY** send a list of available commands vi ### AvailableCommandInput -Currently supports unstructured text input: +Currently supports text input with `type: "text"`: A hint to display when the input hasn't been provided yet diff --git a/docs/protocol/v2/schema.mdx b/docs/protocol/v2/schema.mdx index 4ed62369a..dc9594e54 100644 --- a/docs/protocol/v2/schema.mdx +++ b/docs/protocol/v2/schema.mdx @@ -1237,7 +1237,7 @@ The input specification for a command. **Type:** Union - + All text that was typed after the command name is provided as input. @@ -1253,6 +1253,9 @@ See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/v2/e A hint to display when the input hasn't been provided yet + + The discriminator value. Must be `"text"`. + @@ -3911,6 +3914,26 @@ future ACP variants. +## TextCommandInput + +All text that was typed after the command name is provided as input. + +**Type:** Object + +**Properties:** + + + The _meta property is reserved by ACP to allow clients and agents to attach additional +metadata to their interactions. Implementations MUST NOT make assumptions about values at +these keys. + +See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/v2/extensibility) + + + + A hint to display when the input hasn't been provided yet + + ## TextContent Text provided to or from an LLM. @@ -4265,26 +4288,6 @@ future ACP variants. -## UnstructuredCommandInput - -All text that was typed after the command name is provided as input. - -**Type:** Object - -**Properties:** - - - The _meta property is reserved by ACP to allow clients and agents to attach additional -metadata to their interactions. Implementations MUST NOT make assumptions about values at -these keys. - -See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/v2/extensibility) - - - - A hint to display when the input hasn't been provided yet - - ## UsageUpdate Context window and cost update for a session. diff --git a/docs/protocol/v2/slash-commands.mdx b/docs/protocol/v2/slash-commands.mdx index 098c7c521..66ff40b65 100644 --- a/docs/protocol/v2/slash-commands.mdx +++ b/docs/protocol/v2/slash-commands.mdx @@ -62,7 +62,7 @@ After creating a session, the Agent **MAY** send a list of available commands vi ### AvailableCommandInput -Currently supports unstructured text input: +Currently supports text input with `type: "text"`: A hint to display when the input hasn't been provided yet diff --git a/schema/v2/schema.json b/schema/v2/schema.json index 9286b215d..157aaadff 100644 --- a/schema/v2/schema.json +++ b/schema/v2/schema.json @@ -3787,11 +3787,18 @@ "description": "The input specification for a command.", "anyOf": [ { - "title": "unstructured", "description": "All text that was typed after the command name is provided as input.", + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "text" + } + }, + "required": ["type"], "allOf": [ { - "$ref": "#/$defs/UnstructuredCommandInput" + "$ref": "#/$defs/TextCommandInput" } ] }, @@ -3806,11 +3813,28 @@ } }, "required": ["type"], + "not": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "text" + } + }, + "required": ["type"] + } + ] + }, "additionalProperties": true } - ] + ], + "discriminator": { + "propertyName": "type" + } }, - "UnstructuredCommandInput": { + "TextCommandInput": { "description": "All text that was typed after the command name is provided as input.", "type": "object", "properties": { @@ -3825,11 +3849,7 @@ "additionalProperties": true } }, - "required": ["hint"], - "not": { - "type": "object", - "required": ["type"] - } + "required": ["hint"] }, "AvailableCommandsUpdate": { "description": "Available commands are ready or have changed", diff --git a/schema/v2/schema.unstable.json b/schema/v2/schema.unstable.json index 7ab3c91f7..af27139e7 100644 --- a/schema/v2/schema.unstable.json +++ b/schema/v2/schema.unstable.json @@ -6528,11 +6528,18 @@ "description": "The input specification for a command.", "anyOf": [ { - "title": "unstructured", "description": "All text that was typed after the command name is provided as input.", + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "text" + } + }, + "required": ["type"], "allOf": [ { - "$ref": "#/$defs/UnstructuredCommandInput" + "$ref": "#/$defs/TextCommandInput" } ] }, @@ -6547,11 +6554,28 @@ } }, "required": ["type"], + "not": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "text" + } + }, + "required": ["type"] + } + ] + }, "additionalProperties": true } - ] + ], + "discriminator": { + "propertyName": "type" + } }, - "UnstructuredCommandInput": { + "TextCommandInput": { "description": "All text that was typed after the command name is provided as input.", "type": "object", "properties": { @@ -6566,11 +6590,7 @@ "additionalProperties": true } }, - "required": ["hint"], - "not": { - "type": "object", - "required": ["type"] - } + "required": ["hint"] }, "AvailableCommandsUpdate": { "description": "Available commands are ready or have changed",