diff --git a/agent-client-protocol-schema/src/v1/elicitation.rs b/agent-client-protocol-schema/src/v1/elicitation.rs index 153b64aad..5308e1142 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}; @@ -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, } @@ -827,6 +896,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 { @@ -1357,6 +1527,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 { @@ -1371,6 +1665,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] @@ -1378,6 +1678,7 @@ impl ElicitationMode { match self { Self::Form(f) => &f.scope, Self::Url(u) => &u.scope, + Self::Other(other) => &other.scope, } } } @@ -1474,8 +1775,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 @@ -1506,6 +1810,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** @@ -1843,6 +2258,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( @@ -1870,6 +2316,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( @@ -2127,10 +2614,111 @@ 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] + 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] diff --git a/agent-client-protocol-schema/src/v2/client.rs b/agent-client-protocol-schema/src/v2/client.rs index 03ea7a3d8..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. @@ -1476,6 +1466,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. @@ -2506,10 +2580,81 @@ mod tests { } #[test] - fn available_command_input_unknown_does_not_hide_malformed_unstructured_variant() { + 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; + + 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_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, @@ -2517,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 8d4ef98cd..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()?, }) @@ -1645,6 +1641,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, + )); + } }) } } @@ -7852,59 +7854,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()?, }) } } @@ -7941,7 +7937,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()?), }) } @@ -7953,7 +7950,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()?), }) } @@ -8022,6 +8020,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()?), }) } } @@ -8037,6 +8036,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()?, }) } } @@ -8289,6 +8315,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()?), }) } } @@ -8301,6 +8328,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()?, }) } } @@ -8408,6 +8472,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()?), }) } } @@ -8421,6 +8486,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()?, }) } } @@ -9609,6 +9701,140 @@ 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); + } + + #[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() { + 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, + ); + } + + #[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( @@ -10207,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( @@ -10286,6 +10537,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/agent-client-protocol-schema/src/v2/elicitation.rs b/agent-client-protocol-schema/src/v2/elicitation.rs index 36bcbfd7d..1d8cc93ae 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}; @@ -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, } @@ -826,6 +892,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 { @@ -1356,6 +1506,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 { @@ -1370,6 +1623,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] @@ -1377,6 +1636,7 @@ impl ElicitationMode { match self { Self::Form(f) => &f.scope, Self::Url(u) => &u.scope, + Self::Other(other) => &other.scope, } } } @@ -1473,8 +1733,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 @@ -1505,6 +1768,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** @@ -1842,6 +2199,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( @@ -1869,6 +2257,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( @@ -2123,10 +2552,111 @@ 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] + 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] 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/v1/draft/schema.mdx b/docs/protocol/v1/draft/schema.mdx index 1daff3eea..9a0404718 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** @@ -1816,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 @@ -4050,6 +4100,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** @@ -4159,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** @@ -5036,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. @@ -5052,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. @@ -7948,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. @@ -8485,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 e9504cf5c..62f075213 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** @@ -1749,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 @@ -2682,7 +2732,7 @@ The input specification for a command. **Type:** Union - + All text that was typed after the command name is provided as input. @@ -2698,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"`. + @@ -3590,6 +3643,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** @@ -3699,16 +3777,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** @@ -4601,8 +4669,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. @@ -4617,14 +4685,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. @@ -6524,6 +6609,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. @@ -7843,6 +7954,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. @@ -7919,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. @@ -8333,49 +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 - - -## 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/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/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..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"`. + @@ -2700,6 +2703,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. @@ -3885,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. @@ -4239,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/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/elicitation.mdx b/docs/rfds/elicitation.mdx index d708a8181..78d722d9e 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. @@ -314,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 @@ -341,6 +345,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. @@ -474,6 +480,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/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/v1/schema.unstable.json b/schema/v1/schema.unstable.json index 76af7aba5..77f326815 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": { @@ -1539,7 +1596,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 +1676,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": { @@ -1858,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": [ { @@ -1875,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", @@ -1905,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.", @@ -8090,7 +8233,7 @@ "additionalProperties": true } }, - "oneOf": [ + "anyOf": [ { "description": "The user accepted and provided content.", "type": "object", @@ -8128,6 +8271,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.json b/schema/v2/schema.json index 0c0d1e5da..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", @@ -4740,7 +4760,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 +4787,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..af27139e7 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": { @@ -1735,7 +1792,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 +1872,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": { @@ -2059,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": [ { @@ -2076,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", @@ -2106,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.", @@ -6385,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" } ] }, @@ -6404,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": { @@ -6423,11 +6590,7 @@ "additionalProperties": true } }, - "required": ["hint"], - "not": { - "type": "object", - "required": ["type"] - } + "required": ["hint"] }, "AvailableCommandsUpdate": { "description": "Available commands are ready or have changed", @@ -8468,7 +8631,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 +8658,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": { @@ -8533,7 +8733,7 @@ "additionalProperties": true } }, - "oneOf": [ + "anyOf": [ { "description": "The user accepted and provided content.", "type": "object", @@ -8571,6 +8771,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": {