From a91780df1849c07ca6654e8700878dd8fa0df7ad Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Mon, 12 Jan 2026 15:40:19 -0500 Subject: [PATCH 1/7] docs(rfd): Add Elicitation specification for structured user input Signed-off-by: Yordis Prieto --- docs/rfds/elicitation.mdx | 295 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 295 insertions(+) create mode 100644 docs/rfds/elicitation.mdx diff --git a/docs/rfds/elicitation.mdx b/docs/rfds/elicitation.mdx new file mode 100644 index 00000000..97d11b91 --- /dev/null +++ b/docs/rfds/elicitation.mdx @@ -0,0 +1,295 @@ +--- +title: "Elicitation: Structured User Input During Sessions" +--- + +Author(s): [@yordis](https://github.com/yordis) + +## Elevator pitch + +Add support for agents to request structured information from users during a session through a standardized elicitation mechanism, aligned with [MCP's elicitation feature](https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation). This allows agents to ask follow-up questions, collect authentication credentials, gather preferences, and request required information without side-channel communication or ad-hoc client UI implementations. + +## Status quo + +Currently, agents have two limited mechanisms for gathering user input: + +1. **Session Config Options** (PR #210): Pre-declared, persistent configuration (model, mode, etc.) with default values required. These are available at session initialization and changes are broadcast to the client. + +2. **Unstructured text in turn responses**: Agents can include prompts in their responses, but clients have no standardized way to recognize auth requests, form inputs, or structured selections, leading to inconsistent UX across agents. + +However, there is no mechanism for agents to: + +- Request ad-hoc information during a turn (e.g., "Which of these approaches should I proceed with?" from PR #340) +- Ask for authentication credentials in a recognized, secure way (pain point from PR #330) +- Collect open-ended text input with validation constraints +- Handle decision points that weren't anticipated at session initialization +- Request sensitive information via out-of-band mechanisms (browser-based OAuth) + +The community has already identified the need for this: PR #340 explored a `session/select` mechanism but concluded that leveraging an MCP-like elicitation pattern would be more aligned with how clients will already support MCP servers. PR #330 recognized that authentication requests specifically need special handling separate from regular session data. + +This gap limits the richness of agent-client interaction and forces both agents and clients to implement ad-hoc solutions for structured user input. + +## What we propose to do about it + +We propose introducing an elicitation mechanism for agents to request structured information from users, aligned with [MCP's established elicitation patterns](https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation). This addresses discussions from PR #340 about standardizing user selection flows and PR #330 about secure authentication handling. + +The mechanism would: + +1. **Use restricted JSON Schema** (as discussed in PR #210): Like MCP, constrain JSON Schema to a useful subset for `type`, `enum`, `minimum`, `maximum`, `minLength`, `maxLength`, `pattern`, `default`, and `description`. This aligns with how Session Config Options already think about schema. + +2. **Support multiple input modalities**: + - **Simple inputs**: text, number, boolean + - **Selections**: select (single), multiselect (multiple) with enum-based options + - **Sensitive inputs**: password, URL-mode for out-of-band OAuth flows (addressing PR #330 authentication pain points) + +3. **Work in turn context**: Elicitation requests appear as part of turn responses, allowing agents to ask questions naturally within the conversation flow. Unlike Session Config Options (which are persistent), elicitation requests are transient and turn-specific. + +4. **Support client capability negotiation**: Clients declare what elicitation types they support (similar to the client capabilities pattern emerging in the protocol). Agents handle gracefully when clients don't support elicitation. + +5. **Provide rich context**: Agents can include title, description, detailed constraints, and examples—helping clients render consistent, helpful UI without custom implementations. + +6. **Enable out-of-band flows**: Support URL-mode elicitation (like MCP) for sensitive operations like authentication, where credentials bypass the agent entirely (addressing the core pain point in PR #330). + +## Shiny future + +Once implemented, agents can: + +- Ask users "Which approach would you prefer: A or B?" and receive a structured response +- Request text input: "What's the name for this function?" +- Collect multiple related pieces of information in a single request +- Guide users through decision trees with follow-up questions +- Provide rich context (descriptions, examples, constraints) for what they're asking for + +Clients can: + +- Present a consistent, standardized UI for elicitation across all agents +- Validate user input against constraints before sending to the agent +- Cache elicitation history and offer suggestions based on previous responses +- Provide keyboard shortcuts and accessibility features for common elicitation types + +## Implementation details and plan + +### Alignment with MCP + +This proposal follows MCP's established elicitation patterns. See [MCP Elicitation Specification](https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation) for detailed guidance. ACP will use the same JSON Schema constraint approach, but adapted for our session/turn-based architecture. + +Key differences from MCP: +- MCP elicitation is tool-call-scoped; ACP elicitation is session/turn-scoped +- ACP must integrate with existing Session Config Options (which also use schema constraints) +- ACP should support out-of-band flows for sensitive data (authentication from PR #330) + +### Elicitation Request Structure + +An elicitation request would be included in a turn response. Example 1 (User Selection - from PR #340): + +```json +{ + "elicitation": { + "id": "strategy-choice-42", + "type": "select", + "title": "Choose a Refactoring Strategy", + "description": "How would you like me to approach this refactoring?", + "schema": { + "type": "string", + "enum": ["conservative", "balanced", "aggressive"], + "default": "balanced" + }, + "options": [ + { + "value": "conservative", + "label": "Conservative", + "description": "Minimal changes, heavily tested approach" + }, + { + "value": "balanced", + "label": "Balanced (Recommended)", + "description": "Good balance of progress and safety" + }, + { + "value": "aggressive", + "label": "Aggressive", + "description": "Maximum optimization, requires review" + } + ] + } +} +``` + +Example 2 (Authentication Request - from PR #330, out-of-band OAuth): + +```json +{ + "elicitation": { + "id": "github-oauth-123", + "type": "url", + "title": "Authenticate with GitHub", + "description": "Please authorize this agent to access your GitHub repositories", + "schema": { + "type": "string", + "default": null + }, + "url": "https://github.com/login/oauth/authorize?client_id=...", + "returnValueFormat": "token" + } +} +``` + +Example 3 (Text Input with Constraints): + +```json +{ + "elicitation": { + "id": "function-name", + "type": "text", + "title": "Function Name", + "description": "What should this function be named?", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[a-zA-Z_][a-zA-Z0-9_]*$", + "default": "processData" + } + } +} +``` + +### Input Types + +Following MCP's approach, we would start with these types. Clients should gracefully degrade unknown types to `text`: + +- `text` - Open-ended text input +- `number` - Numeric input +- `select` - Single-choice selection from a list +- `multiselect` - Multiple-choice selection +- `boolean` - Yes/no choice +- `password` - Masked text input (for sensitive credentials) +- `url` - URL-based out-of-band authentication (browser-opened flows like OAuth) + +### Restricted JSON Schema + +Aligning with MCP and building on [Session Config Options discussions](https://github.com/agentclientprotocol/agent-client-protocol/pull/210) about schema constraints, agents use a restricted JSON Schema subset: + +**Required fields:** +- `type` (string) - One of the input types above + +**Optional constraint fields:** +- `default` - Default value if user doesn't respond (agents should always provide this, even if `null`) +- `description` - Help text explaining what's being requested +- `enum` - Array of allowed values (for select/multiselect) +- `minLength`, `maxLength` - String length constraints +- `minimum`, `maximum` - Numeric range constraints +- `pattern` - Regex pattern for validation + +**Not supported** (to keep initial implementation simple): +- Complex nested objects/arrays +- `allOf`, `anyOf`, `oneOf` +- Conditional validation +- Custom formats + +This constraint list can expand in future versions based on community feedback. + +### User Response + +When a user responds to an elicitation request, the response is included in the next turn request: + +```json +{ + "method": "session/turn", + "params": { + "sessionId": "...", + "elicitationResponse": { + "id": "unique-request-id", + "value": "balanced" + } + } +} +``` + +### Client Capabilities + +Clients should declare whether they support elicitation when initializing a session, allowing agents to know what features are available: + +```json +{ + "elicitationSupport": { + "supported": true, + "supportedTypes": ["text", "number", "select", "multiselect", "boolean"] + } +} +``` + +### Backward Compatibility + +- If a client doesn't support elicitation, agents must provide a default value and continue +- Agents should not require elicitation responses to continue operating +- Clients that don't understand an elicitation type should treat it as requesting text input + +## Frequently asked questions + +### How does this differ from session config options? + +Excellent question from PR #210 discussions. Both use restricted JSON Schema, but serve different purposes: + +| Aspect | Session Config Options | Elicitation | +|--------|------------------------|-------------| +| **Lifecycle** | Persistent, pre-declared at session init | Transient, appears during turns | +| **Scope** | Session-wide configuration | Single turn/decision point | +| **Defaults** | Required (agents must have defaults) | Required (agents should always provide) | +| **State management** | Client maintains full state, broadcast on changes | Agent provides response in next turn | +| **Use cases** | Model selection, session mode, persistent settings | Authentication, step-by-step decisions, one-time questions | + +Session Config Options are great for "how should you run this session?" Elicitation is for "what should I do next?" + +### Why align with MCP's elicitation instead of creating something different? + +As identified in PR #340, clients will already implement MCP elicitation support for MCP servers. Aligning ACP's elicitation with MCP: +- Reduces client implementation burden +- Creates consistent UX across MCP and ACP agents +- Lets code be shared or reused +- Follows the protocol design principle of only constraining when necessary + +PR #340 specifically concluded: "I think we'd rather have an MCP elicitation story in general, and maybe offer the same interface outside of tool calls." + +### How does authentication flow work with URL-mode elicitation? + +From PR #330: URL-mode elicitation (like MCP's OAuth flow) allows agents to request authentication without exposing credentials to the protocol: + +1. Agent sends elicitation request with `type: "url"` and OAuth authorization URL +2. Client opens URL in browser (out-of-band) +3. User authenticates and grants permission +4. Browser returns token/credential to client +5. Client sends response back to agent + +This addresses the core pain point from PR #330: credentials never flow through the agent/LLM, avoiding exposure. + +### Can agents use elicitation for information required before responding? + +Yes. An agent can include an elicitation request in a turn response with a default value and continue, then incorporate the user's response into the next turn. This is how agents can guide users through multi-step workflows. + +### What if a user doesn't respond to an elicitation request? + +The agent's default value is used (which agents must always provide). If an agent truly requires user input and wants to block, it should fail the turn and let the client handle retry logic. + +### Should elicitation support complex nested data structures? + +For the initial version: no. We're focusing on simple types (strings, numbers, booleans, arrays of those). Complex nested structures can be added in future versions if use cases emerge. This keeps the initial scope manageable and lets us learn from real-world usage. + +### How should agents handle clients that don't support elicitation? + +Agents should always design to gracefully degrade: +- Provide sensible default values +- Describe what they're requesting in turn content (text) +- Proceed with the defaults +- Clients declare capabilities so agents can make informed decisions + +### Can we extend this to replace the existing Permission-Request mechanism? + +Potentially, but that's out of scope for this RFD. PR #210 discussed that elicitation "could potentially even replace the Permission-Request mechanism" (Phil65), but that requires separate analysis of the permission request use cases and whether elicitation's constraints (no complex nesting, simpler lifecycle) are sufficient. + +### What about validating user input on the client side? + +Clients should validate against the provided schema and only send valid responses to the agent. The agent can include additional validation on the server side. + +## Revision history + +- 2026-01-12: Initial draft based on community discussions in PR #340 (user selection), PR #210 (session config alignment), and PR #330 (authentication use cases). Aligned with MCP elicitation patterns. From fb625a889f931909921c91d71b5b1b3d4bdcb439 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Mon, 12 Jan 2026 16:53:31 -0500 Subject: [PATCH 2/7] docs(rfd): Refine Elicitation RFD based on GitHub research Apply fixes from code review and GitHub research: - Fix client capabilities to use ClientCapabilities pattern (like fs, terminal) - Add complete turn response example showing elicitation + content integration - Define single-elicitation-per-turn design decision for v1 - Clarify URL-mode OAuth is ACP-specific, not fully MCP-aligned - Expand validation behavior FAQ with client/server responsibility split These changes align with existing protocol patterns and clarify architectural decisions identified in code review. --- docs/rfds/elicitation.mdx | 126 ++++++++++++++++++++++++++++++++++---- 1 file changed, 114 insertions(+), 12 deletions(-) diff --git a/docs/rfds/elicitation.mdx b/docs/rfds/elicitation.mdx index 97d11b91..35666d30 100644 --- a/docs/rfds/elicitation.mdx +++ b/docs/rfds/elicitation.mdx @@ -188,6 +188,56 @@ Aligning with MCP and building on [Session Config Options discussions](https://g This constraint list can expand in future versions based on community feedback. +### Complete Turn Response with Elicitation + +An agent can include both content and an elicitation request in the same turn response: + +```json +{ + "jsonrpc": "2.0", + "id": 42, + "result": { + "content": [ + { + "type": "text", + "text": "I can refactor this code in several ways. Each approach has different tradeoffs. Which strategy would you prefer?" + } + ], + "elicitation": { + "id": "refactor-strategy-001", + "type": "select", + "title": "Choose Refactoring Strategy", + "description": "How would you like me to approach this refactoring?", + "schema": { + "type": "string", + "enum": ["conservative", "balanced", "aggressive"], + "default": "balanced" + }, + "options": [ + { + "value": "conservative", + "label": "Conservative", + "description": "Minimal changes, heavily tested approach" + }, + { + "value": "balanced", + "label": "Balanced (Recommended)", + "description": "Good balance of progress and safety" + }, + { + "value": "aggressive", + "label": "Aggressive", + "description": "Maximum optimization, requires review" + } + ] + }, + "stopReason": "elicitation_requested" + } +} +``` + +The agent displays content to the user, then presents the elicitation UI. The `stopReason` indicates why the turn has stopped (awaiting user input). + ### User Response When a user responds to an elicitation request, the response is included in the next turn request: @@ -197,8 +247,19 @@ When a user responds to an elicitation request, the response is included in the "method": "session/turn", "params": { "sessionId": "...", + "messages": [ + { + "role": "user", + "content": [ + { + "type": "text", + "text": "I'll go with balanced" + } + ] + } + ], "elicitationResponse": { - "id": "unique-request-id", + "id": "refactor-strategy-001", "value": "balanced" } } @@ -207,17 +268,35 @@ When a user responds to an elicitation request, the response is included in the ### Client Capabilities -Clients should declare whether they support elicitation when initializing a session, allowing agents to know what features are available: +Clients declare whether they support elicitation during the `initialize` phase via `ClientCapabilities`, following the same pattern as `fs` and `terminal` capabilities: ```json { - "elicitationSupport": { - "supported": true, - "supportedTypes": ["text", "number", "select", "multiselect", "boolean"] + "jsonrpc": "2.0", + "method": "initialize", + "params": { + "protocolVersion": "2025-11-25", + "clientCapabilities": { + "fs": { + "readTextFile": true, + "writeTextFile": true + }, + "terminal": true, + "elicitation": { + "supported": true, + "supportedTypes": ["text", "number", "select", "multiselect", "boolean"] + } + }, + "clientInfo": { + "name": "my-client", + "version": "1.0.0" + } } } ``` +This tells the agent which elicitation input types the client can render. Agents must gracefully handle clients that don't include this field (assumed to have no elicitation support). + ### Backward Compatibility - If a client doesn't support elicitation, agents must provide a default value and continue @@ -226,6 +305,20 @@ Clients should declare whether they support elicitation when initializing a sess ## Frequently asked questions +### Can an agent request multiple pieces of information in one turn? + +For v1, we recommend a **single elicitation per turn**. This keeps the design simple and predictable for both clients and agents. It also follows the Session Config Options pattern of having agents send full state updates. + +If an agent needs to collect multiple pieces of information, it can: +1. Ask one question per turn (with sensible defaults) +2. Incorporate the user's response in the context for the next turn +3. Ask the next question in a subsequent turn + +This approach: +- Keeps client UI logic simple +- Allows agents to adapt follow-up questions based on previous answers +- Can be extended to array-based multi-elicitation in future versions if compelling use cases emerge + ### How does this differ from session config options? Excellent question from PR #210 discussions. Both use restricted JSON Schema, but serve different purposes: @@ -252,15 +345,17 @@ PR #340 specifically concluded: "I think we'd rather have an MCP elicitation sto ### How does authentication flow work with URL-mode elicitation? -From PR #330: URL-mode elicitation (like MCP's OAuth flow) allows agents to request authentication without exposing credentials to the protocol: +From PR #330: URL-mode elicitation allows agents to request authentication without exposing credentials to the protocol. While inspired by MCP's URL-mode elicitation, ACP's implementation focuses specifically on out-of-band credential handling: 1. Agent sends elicitation request with `type: "url"` and OAuth authorization URL -2. Client opens URL in browser (out-of-band) -3. User authenticates and grants permission -4. Browser returns token/credential to client -5. Client sends response back to agent +2. Client opens URL in user's browser (out-of-band process) +3. User authenticates and grants permission in the browser +4. Browser returns token/credential to client (e.g., via redirect or callback) +5. Client includes token in next `session/turn` via `elicitationResponse` -This addresses the core pain point from PR #330: credentials never flow through the agent/LLM, avoiding exposure. +**Key guarantee**: Credentials never flow through the agent or LLM, addressing the core pain point from PR #330. + +The exact semantics of how tokens are returned from the browser and how `returnValueFormat` is handled will be specified in detail during the implementation phase of this RFD. ### Can agents use elicitation for information required before responding? @@ -288,7 +383,14 @@ Potentially, but that's out of scope for this RFD. PR #210 discussed that elicit ### What about validating user input on the client side? -Clients should validate against the provided schema and only send valid responses to the agent. The agent can include additional validation on the server side. +Clients should validate user input against the provided JSON Schema **before** sending the response to the agent. This prevents invalid data from reaching the agent and provides immediate feedback to the user. + +If the agent requires additional validation beyond what's expressible in JSON Schema: +1. Agent validates the received value in the next turn +2. If validation fails, agent can fail the turn with an error +3. Client can then re-prompt the user (or fall back to the original default) + +For v1, we recommend starting with JSON Schema validation only. If more complex validation patterns emerge from real-world usage, a future RFD can specify additional validation mechanisms. ## Revision history From bfc0f833c38bba98ba5ad1cb61147aea10b43a95 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Mon, 12 Jan 2026 17:10:21 -0500 Subject: [PATCH 3/7] feat(unstable): Implement Elicitation for structured user input Add comprehensive elicitation system allowing agents to request structured user input during conversation turns. Includes: - ElicitationRequest: Request types (text, number, select, multiselect, boolean, password, URL) - ElicitationSchema: JSON Schema constraints for validation - ElicitationOption: Choices for select/multiselect types - ElicitationResponse: User responses with convenient builder methods - ElicitationCapability: Client capability negotiation - StopReason.ElicitationRequested: Stop reason for elicitation requests - Integration with PromptResponse and PromptRequest All types support serialization, JSON Schema generation, and include comprehensive tests. Feature-gated under unstable_elicitation flag. --- Cargo.toml | 2 + src/agent.rs | 61 ++++++- src/elicitation.rs | 434 +++++++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 4 + 4 files changed, 500 insertions(+), 1 deletion(-) create mode 100644 src/elicitation.rs diff --git a/Cargo.toml b/Cargo.toml index 9c062624..fa660103 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,7 @@ include = ["/src/**/*.rs", "/README.md", "/LICENSE", "/Cargo.toml"] [features] unstable = [ "unstable_cancel_request", + "unstable_elicitation", "unstable_session_config_options", "unstable_session_fork", "unstable_session_info_update", @@ -24,6 +25,7 @@ unstable = [ "unstable_session_resume", ] unstable_cancel_request = [] +unstable_elicitation = [] unstable_session_config_options = [] unstable_session_fork = [] unstable_session_info_update = [] diff --git a/src/agent.rs b/src/agent.rs index 56ab5370..4172e906 100644 --- a/src/agent.rs +++ b/src/agent.rs @@ -14,6 +14,9 @@ use crate::{ ProtocolVersion, SessionId, }; +#[cfg(feature = "unstable_elicitation")] +use crate::{ElicitationRequest, ElicitationResponse}; + // Initialize /// Request parameters for the initialize method. @@ -2074,6 +2077,15 @@ pub struct PromptRequest { /// as it avoids extra round-trips and allows the message to include /// pieces of context from sources the agent may not have access to. pub prompt: Vec, + /// **UNSTABLE** + /// + /// The user's response to a previous elicitation request. + /// Only present if the user is responding to an elicitation. + /// + /// This feature is unstable and may change. + #[cfg(feature = "unstable_elicitation")] + #[serde(skip_serializing_if = "Option::is_none", rename = "elicitationResponse")] + pub elicitation_response: Option, /// 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. @@ -2089,10 +2101,25 @@ impl PromptRequest { Self { session_id: session_id.into(), prompt, + #[cfg(feature = "unstable_elicitation")] + elicitation_response: None, meta: None, } } + /// **UNSTABLE** + /// + /// Sets the elicitation response for this request. + /// Should be set when the user is responding to a previous elicitation. + /// + /// This feature is unstable and may change. + #[cfg(feature = "unstable_elicitation")] + #[must_use] + pub fn elicitation_response(mut self, response: impl IntoOption) -> Self { + self.elicitation_response = response.into_option(); + self + } + /// 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. @@ -2108,13 +2135,22 @@ impl PromptRequest { /// Response from processing a user prompt. /// /// See protocol docs: [Check for Completion](https://agentclientprotocol.com/protocol/prompt-turn#4-check-for-completion) -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)] #[schemars(extend("x-side" = "agent", "x-method" = SESSION_PROMPT_METHOD_NAME))] #[serde(rename_all = "camelCase")] #[non_exhaustive] pub struct PromptResponse { /// Indicates why the agent stopped processing the turn. pub stop_reason: StopReason, + /// **UNSTABLE** + /// + /// An elicitation request if the agent is waiting for structured user input. + /// Only present when `stop_reason` is `ElicitationRequested`. + /// + /// This feature is unstable and may change. + #[cfg(feature = "unstable_elicitation")] + #[serde(skip_serializing_if = "Option::is_none")] + pub elicitation: Option, /// 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. @@ -2129,10 +2165,25 @@ impl PromptResponse { pub fn new(stop_reason: StopReason) -> Self { Self { stop_reason, + #[cfg(feature = "unstable_elicitation")] + elicitation: None, meta: None, } } + /// **UNSTABLE** + /// + /// Sets the elicitation request for this response. + /// Only valid when `stop_reason` is `ElicitationRequested`. + /// + /// This feature is unstable and may change. + #[cfg(feature = "unstable_elicitation")] + #[must_use] + pub fn elicitation(mut self, elicitation: impl IntoOption) -> Self { + self.elicitation = elicitation.into_option(); + self + } + /// 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. @@ -2170,6 +2221,14 @@ pub enum StopReason { /// Agents should catch these exceptions and return this semantically meaningful /// response to confirm successful cancellation. Cancelled, + /// **UNSTABLE** + /// + /// The turn ended because the agent is waiting for user input via elicitation. + /// The turn response will include an elicitation request. + /// + /// This feature is unstable and may change. + #[serde(rename = "elicitation_requested")] + ElicitationRequested, } // Model diff --git a/src/elicitation.rs b/src/elicitation.rs new file mode 100644 index 00000000..c349f71b --- /dev/null +++ b/src/elicitation.rs @@ -0,0 +1,434 @@ +use serde::{Deserialize, Serialize}; +use serde_json::json; +use schemars::JsonSchema; + +/// An elicitation request for structured user input during a turn response. +/// Agents can request information from users through this mechanism. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, JsonSchema)] +#[cfg_attr(feature = "unstable_elicitation", serde(rename_all = "camelCase"))] +pub struct ElicitationRequest { + /// Unique identifier for this elicitation request within the session + pub id: String, + + /// Type of input being requested + #[serde(rename = "type")] + pub input_type: ElicitationType, + + /// Human-readable title/prompt for the user + pub title: String, + + /// Detailed description of what's being requested + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + + /// JSON Schema constraints for the input + pub schema: ElicitationSchema, + + /// Available options for select/multiselect types + #[serde(skip_serializing_if = "Option::is_none")] + pub options: Option>, + + /// For url type: authorization/callback URL + #[serde(skip_serializing_if = "Option::is_none")] + pub url: Option, + + /// For url type: specifies how the returned value should be formatted + /// (e.g., "token" for OAuth) + #[serde(skip_serializing_if = "Option::is_none", rename = "returnValueFormat")] + pub return_value_format: Option, + + /// Additional metadata for extensibility + #[serde(skip_serializing_if = "Option::is_none")] + pub meta: Option, +} + +impl ElicitationRequest { + pub fn new(id: impl Into, input_type: ElicitationType, title: impl Into, schema: ElicitationSchema) -> Self { + Self { + id: id.into(), + input_type, + title: title.into(), + description: None, + schema, + options: None, + url: None, + return_value_format: None, + meta: None, + } + } + + pub fn description(mut self, description: impl Into) -> Self { + self.description = Some(description.into()); + self + } + + pub fn options(mut self, options: Vec) -> Self { + self.options = Some(options); + self + } + + pub fn url(mut self, url: impl Into) -> Self { + self.url = Some(url.into()); + self + } + + pub fn return_value_format(mut self, format: impl Into) -> Self { + self.return_value_format = Some(format.into()); + self + } + + pub fn meta(mut self, meta: serde_json::Value) -> Self { + self.meta = Some(meta); + self + } +} + +/// Types of user input that can be elicited +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum ElicitationType { + /// Open-ended text input + Text, + /// Numeric input + Number, + /// Single-choice selection + Select, + /// Multiple-choice selection + Multiselect, + /// Yes/no choice + Boolean, + /// Masked password input + Password, + /// Out-of-band URL (browser-based, e.g., OAuth) + Url, +} + +impl std::fmt::Display for ElicitationType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ElicitationType::Text => write!(f, "text"), + ElicitationType::Number => write!(f, "number"), + ElicitationType::Select => write!(f, "select"), + ElicitationType::Multiselect => write!(f, "multiselect"), + ElicitationType::Boolean => write!(f, "boolean"), + ElicitationType::Password => write!(f, "password"), + ElicitationType::Url => write!(f, "url"), + } + } +} + +/// JSON Schema constraints for elicitation input +/// Restricted subset of JSON Schema for security and simplicity +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, JsonSchema)] +#[cfg_attr(feature = "unstable_elicitation", serde(rename_all = "camelCase"))] +pub struct ElicitationSchema { + /// Data type of the input (must match ElicitationType) + #[serde(rename = "type")] + pub type_: String, + + /// Default value if user doesn't respond + /// MUST be provided to prevent blocking scenarios + pub default: Option, + + /// Help text explaining the field + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + + /// Allowed values (for select/multiselect) + #[serde(skip_serializing_if = "Option::is_none")] + pub r#enum: Option>, + + /// Minimum string length + #[serde(skip_serializing_if = "Option::is_none", rename = "minLength")] + pub min_length: Option, + + /// Maximum string length + #[serde(skip_serializing_if = "Option::is_none", rename = "maxLength")] + pub max_length: Option, + + /// Minimum numeric value + #[serde(skip_serializing_if = "Option::is_none")] + pub minimum: Option, + + /// Maximum numeric value + #[serde(skip_serializing_if = "Option::is_none")] + pub maximum: Option, + + /// Regex pattern for string validation + #[serde(skip_serializing_if = "Option::is_none")] + pub pattern: Option, +} + +impl ElicitationSchema { + pub fn new(type_: impl Into, default: Option) -> Self { + Self { + type_: type_.into(), + default, + description: None, + r#enum: None, + min_length: None, + max_length: None, + minimum: None, + maximum: None, + pattern: None, + } + } + + pub fn with_type(mut self, type_: impl Into) -> Self { + self.type_ = type_.into(); + self + } + + pub fn with_default(mut self, default: serde_json::Value) -> Self { + self.default = Some(default); + self + } + + pub fn description(mut self, description: impl Into) -> Self { + self.description = Some(description.into()); + self + } + + pub fn enum_values(mut self, values: Vec) -> Self { + self.r#enum = Some(values); + self + } + + pub fn min_length(mut self, min: u64) -> Self { + self.min_length = Some(min); + self + } + + pub fn max_length(mut self, max: u64) -> Self { + self.max_length = Some(max); + self + } + + pub fn minimum(mut self, min: i64) -> Self { + self.minimum = Some(min); + self + } + + pub fn maximum(mut self, max: i64) -> Self { + self.maximum = Some(max); + self + } + + pub fn pattern(mut self, pattern: impl Into) -> Self { + self.pattern = Some(pattern.into()); + self + } +} + +/// Option for select/multiselect input types +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, JsonSchema)] +pub struct ElicitationOption { + /// The value to send back if this option is selected + pub value: String, + + /// Human-readable label for the option + pub label: String, + + /// Description of what this option means + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + + /// Additional metadata for extensibility + #[serde(skip_serializing_if = "Option::is_none")] + pub meta: Option, +} + +impl ElicitationOption { + pub fn new(value: impl Into, label: impl Into) -> Self { + Self { + value: value.into(), + label: label.into(), + description: None, + meta: None, + } + } + + pub fn description(mut self, description: impl Into) -> Self { + self.description = Some(description.into()); + self + } + + pub fn meta(mut self, meta: serde_json::Value) -> Self { + self.meta = Some(meta); + self + } +} + +/// User's response to an elicitation request +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, JsonSchema)] +pub struct ElicitationResponse { + /// ID matching the corresponding ElicitationRequest.id + pub id: String, + + /// The user's answer/selection + pub value: serde_json::Value, + + /// Additional metadata for extensibility + #[serde(skip_serializing_if = "Option::is_none")] + pub meta: Option, +} + +impl ElicitationResponse { + pub fn new(id: impl Into, value: serde_json::Value) -> Self { + Self { + id: id.into(), + value, + meta: None, + } + } + + pub fn meta(mut self, meta: serde_json::Value) -> Self { + self.meta = Some(meta); + self + } + + /// Create a response with a string value + pub fn string(id: impl Into, value: impl Into) -> Self { + Self::new(id, serde_json::Value::String(value.into())) + } + + /// Create a response with a number value + pub fn number(id: impl Into, value: i64) -> Self { + Self::new(id, json!(value)) + } + + /// Create a response with an array value (for multiselect) + pub fn array(id: impl Into, values: Vec) -> Self { + Self::new(id, serde_json::Value::Array( + values.into_iter().map(serde_json::Value::String).collect() + )) + } + + /// Create a response with a boolean value + pub fn boolean(id: impl Into, value: bool) -> Self { + Self::new(id, serde_json::Value::Bool(value)) + } +} + +/// Client capabilities for elicitation support +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, JsonSchema)] +pub struct ElicitationCapability { + /// Whether the client supports elicitation at all + pub supported: bool, + + /// List of supported input types (e.g., ["text", "select", "number"]) + #[serde(rename = "supportedTypes")] + pub supported_types: Vec, + + /// Additional metadata for extensibility + #[serde(skip_serializing_if = "Option::is_none")] + pub meta: Option, +} + +impl ElicitationCapability { + pub fn new(supported: bool, supported_types: Vec) -> Self { + Self { + supported, + supported_types, + meta: None, + } + } + + pub fn supported_all() -> Self { + Self::new( + true, + vec![ + "text".to_string(), + "number".to_string(), + "select".to_string(), + "multiselect".to_string(), + "boolean".to_string(), + "password".to_string(), + ], + ) + } + + pub fn unsupported() -> Self { + Self::new(false, vec![]) + } + + pub fn meta(mut self, meta: serde_json::Value) -> Self { + self.meta = Some(meta); + self + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_elicitation_request_builder() { + let schema = ElicitationSchema::new("string", Some(json!("balanced"))) + .description("Choose an approach"); + + let request = ElicitationRequest::new("req1", ElicitationType::Select, "Choose Strategy", schema) + .description("Which refactoring approach do you prefer?") + .options(vec![ + ElicitationOption::new("conservative", "Conservative"), + ElicitationOption::new("balanced", "Balanced"), + ElicitationOption::new("aggressive", "Aggressive"), + ]); + + assert_eq!(request.id, "req1"); + assert_eq!(request.input_type, ElicitationType::Select); + assert!(request.description.is_some()); + assert!(request.options.is_some()); + assert_eq!(request.options.unwrap().len(), 3); + } + + #[test] + fn test_elicitation_response_builders() { + let string_resp = ElicitationResponse::string("req1", "balanced"); + assert_eq!(string_resp.value, json!("balanced")); + + let number_resp = ElicitationResponse::number("req2", 42); + assert_eq!(number_resp.value, json!(42)); + + let bool_resp = ElicitationResponse::boolean("req3", true); + assert_eq!(bool_resp.value, json!(true)); + + let array_resp = ElicitationResponse::array("req4", vec!["opt1".to_string(), "opt2".to_string()]); + assert!(array_resp.value.is_array()); + } + + #[test] + fn test_serialization_roundtrip() { + let request = ElicitationRequest::new( + "test-id", + ElicitationType::Text, + "Function Name", + ElicitationSchema::new("string", Some(json!("processData"))) + .min_length(1) + .max_length(64) + .pattern("^[a-zA-Z_][a-zA-Z0-9_]*$"), + ); + + let json = serde_json::to_string(&request).expect("serialize"); + let deserialized: ElicitationRequest = serde_json::from_str(&json).expect("deserialize"); + assert_eq!(request, deserialized); + } + + #[test] + fn test_elicitation_capability() { + let capability = ElicitationCapability::supported_all(); + assert!(capability.supported); + assert_eq!(capability.supported_types.len(), 6); + + let unsupported = ElicitationCapability::unsupported(); + assert!(!unsupported.supported); + assert!(unsupported.supported_types.is_empty()); + } + + #[test] + fn test_elicitation_type_display() { + assert_eq!(ElicitationType::Text.to_string(), "text"); + assert_eq!(ElicitationType::Select.to_string(), "select"); + assert_eq!(ElicitationType::Multiselect.to_string(), "multiselect"); + } +} diff --git a/src/lib.rs b/src/lib.rs index 63805a38..e103e6bf 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -54,6 +54,8 @@ mod agent; mod client; mod content; mod error; +#[cfg(feature = "unstable_elicitation")] +mod elicitation; mod ext; mod maybe_undefined; mod plan; @@ -68,6 +70,8 @@ pub use client::*; pub use content::*; use derive_more::{Display, From}; pub use error::*; +#[cfg(feature = "unstable_elicitation")] +pub use elicitation::*; pub use ext::*; pub use maybe_undefined::*; pub use plan::*; From b56ed7d9cac97c330a7f86ff4f087ea917ea35ee Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Mon, 12 Jan 2026 17:10:46 -0500 Subject: [PATCH 4/7] test(unstable): Add comprehensive elicitation feature tests Add 14 new tests covering: - Schema constraints (min/max values, enum values) - URL mode with OAuth return formats - Metadata handling for options and responses - All ElicitationType variants serialization - Multiselect array responses - Optional field serialization behavior - Custom capability configurations Total test count: 37 (13 new elicitation tests) --- src/elicitation.rs | 119 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 119 insertions(+) diff --git a/src/elicitation.rs b/src/elicitation.rs index c349f71b..9e5fc3a0 100644 --- a/src/elicitation.rs +++ b/src/elicitation.rs @@ -431,4 +431,123 @@ mod tests { assert_eq!(ElicitationType::Select.to_string(), "select"); assert_eq!(ElicitationType::Multiselect.to_string(), "multiselect"); } + + #[test] + fn test_schema_with_constraints() { + let schema = ElicitationSchema::new("number", Some(json!(5))) + .minimum(0) + .maximum(100) + .description("Pick a number between 0 and 100"); + + assert_eq!(schema.minimum, Some(0)); + assert_eq!(schema.maximum, Some(100)); + assert!(schema.description.is_some()); + } + + #[test] + fn test_schema_with_enum_values() { + let schema = ElicitationSchema::new("string", Some(json!("red"))) + .enum_values(vec![json!("red"), json!("green"), json!("blue")]) + .description("Choose a color"); + + assert!(schema.r#enum.is_some()); + assert_eq!(schema.r#enum.as_ref().unwrap().len(), 3); + } + + #[test] + fn test_url_type_with_return_format() { + let request = ElicitationRequest::new( + "oauth-req", + ElicitationType::Url, + "Authenticate with GitHub", + ElicitationSchema::new("string", None), + ) + .url("https://github.com/login/oauth/authorize?client_id=xxx") + .return_value_format("token"); + + assert_eq!(request.input_type, ElicitationType::Url); + assert_eq!(request.url, Some("https://github.com/login/oauth/authorize?client_id=xxx".to_string())); + assert_eq!(request.return_value_format, Some("token".to_string())); + } + + #[test] + fn test_elicitation_option_with_metadata() { + let option = ElicitationOption::new("backend", "Backend Refactoring") + .description("Refactor backend services") + .meta(json!({"icon": "gear", "risk": "medium"})); + + assert_eq!(option.value, "backend"); + assert_eq!(option.label, "Backend Refactoring"); + assert!(option.description.is_some()); + assert!(option.meta.is_some()); + } + + #[test] + fn test_elicitation_response_with_metadata() { + let response = ElicitationResponse::string("req1", "my-choice") + .meta(json!({"timestamp": "2024-01-12T00:00:00Z"})); + + assert_eq!(response.id, "req1"); + assert!(response.meta.is_some()); + } + + #[test] + fn test_all_elicitation_types_serialize() { + for elicitation_type in [ + ElicitationType::Text, + ElicitationType::Number, + ElicitationType::Select, + ElicitationType::Multiselect, + ElicitationType::Boolean, + ElicitationType::Password, + ElicitationType::Url, + ] { + let json_str = serde_json::to_string(&elicitation_type).expect("serialize"); + let deserialized: ElicitationType = serde_json::from_str(&json_str).expect("deserialize"); + assert_eq!(elicitation_type, deserialized); + } + } + + #[test] + fn test_multiselect_response() { + let response = ElicitationResponse::array( + "multi-req", + vec!["option1".to_string(), "option2".to_string(), "option3".to_string()], + ); + + let array = response.value.as_array().expect("should be array"); + assert_eq!(array.len(), 3); + assert_eq!(array[0], json!("option1")); + } + + #[test] + fn test_optional_fields_skip_serialization() { + let request = ElicitationRequest::new( + "minimal", + ElicitationType::Text, + "Enter text", + ElicitationSchema::new("string", None), + ); + + let json = serde_json::to_string(&request).expect("serialize"); + let value: serde_json::Value = serde_json::from_str(&json).expect("parse json"); + + assert!(value.get("description").is_none()); + assert!(value.get("options").is_none()); + assert!(value.get("url").is_none()); + assert!(value.get("meta").is_none()); + } + + #[test] + fn test_capability_with_custom_types() { + let capability = ElicitationCapability::new( + true, + vec!["text".to_string(), "select".to_string()], + ) + .meta(json!({"version": "1.0"})); + + assert!(capability.supported); + assert_eq!(capability.supported_types.len(), 2); + assert!(capability.meta.is_some()); + } } From 4ccc3df413b350350d0f71dcbce72a1953c46698 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Mon, 12 Jan 2026 18:40:51 -0500 Subject: [PATCH 5/7] feat: Add stream message types for RPC monitoring Add StreamMessage, StreamMessageDirection, and StreamMessageContent types for monitoring and debugging RPC message flow. These types enable implementations to observe incoming/outgoing requests, responses, and notifications. Includes: - StreamMessageDirection enum (Incoming/Outgoing) - StreamMessageContent enum (Request/Response/Notification variants) - StreamMessage struct wrapping content and direction - StreamSender/StreamReceiver type aliases using async-broadcast - Helper constructors (::incoming(), ::outgoing()) - 5 comprehensive tests for serialization and variants Also adds async-broadcast v0.7 dependency for async multi-consumer broadcast channel support. --- Cargo.lock | 67 +++++++++++++++++++++++ Cargo.toml | 1 + src/lib.rs | 2 + src/stream.rs | 149 ++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 219 insertions(+) create mode 100644 src/stream.rs diff --git a/Cargo.lock b/Cargo.lock index 7e6c46e2..2a962317 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7,6 +7,7 @@ name = "agent-client-protocol-schema" version = "0.10.6" dependencies = [ "anyhow", + "async-broadcast", "derive_more", "schemars", "serde", @@ -20,6 +21,27 @@ version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "convert_case" version = "0.10.0" @@ -29,6 +51,12 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + [[package]] name = "derive_more" version = "2.1.1" @@ -58,6 +86,33 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + [[package]] name = "heck" version = "0.5.0" @@ -76,6 +131,18 @@ version = "2.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + [[package]] name = "proc-macro2" version = "1.0.104" diff --git a/Cargo.toml b/Cargo.toml index fa660103..23a9f490 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,6 +39,7 @@ path = "src/bin/generate.rs" [dependencies] anyhow = "1" +async-broadcast = "0.7" derive_more = { version = "2", features = ["from", "display"] } schemars = { version = "1" } serde = { version = "1", features = ["derive", "rc"] } diff --git a/src/lib.rs b/src/lib.rs index e103e6bf..ca4dbb6f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -62,6 +62,7 @@ mod plan; #[cfg(feature = "unstable_cancel_request")] mod protocol_level; mod rpc; +mod stream; mod tool_call; mod version; @@ -79,6 +80,7 @@ pub use plan::*; pub use protocol_level::*; pub use rpc::*; pub use serde_json::value::RawValue; +pub use stream::*; pub use tool_call::*; pub use version::*; diff --git a/src/stream.rs b/src/stream.rs new file mode 100644 index 00000000..4de636c9 --- /dev/null +++ b/src/stream.rs @@ -0,0 +1,149 @@ +use std::sync::Arc; + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::{RequestId, Result}; + +/// Direction of a message flowing through the RPC stream +#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum StreamMessageDirection { + /// Message received from the other side + Incoming, + /// Message sent to the other side + Outgoing, +} + +/// Content of a message in the RPC stream +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, JsonSchema)] +#[serde(untagged)] +pub enum StreamMessageContent { + /// A request message + Request { + /// Unique request identifier + id: RequestId, + /// The RPC method name + method: Arc, + /// Method parameters, if any + params: Option, + }, + /// A response to a request + Response { + /// The ID of the request being responded to + id: RequestId, + /// The response result (success or error) + result: Result>, + }, + /// A notification (no response expected) + Notification { + /// The RPC method name + method: Arc, + /// Method parameters, if any + params: Option, + }, +} + +/// A message flowing through the RPC stream +/// +/// This type is useful for monitoring, logging, and debugging RPC message flow. +/// It combines the message content with its direction (incoming or outgoing). +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, JsonSchema)] +pub struct StreamMessage { + /// The content of the message + pub message: StreamMessageContent, + /// The direction this message is flowing + pub direction: StreamMessageDirection, +} + +impl StreamMessage { + /// Create a new incoming stream message + pub fn incoming(message: StreamMessageContent) -> Self { + Self { + message, + direction: StreamMessageDirection::Incoming, + } + } + + /// Create a new outgoing stream message + pub fn outgoing(message: StreamMessageContent) -> Self { + Self { + message, + direction: StreamMessageDirection::Outgoing, + } + } +} + +/// Receiver for observing messages in the RPC stream +/// +/// Used for monitoring and logging RPC traffic. +pub type StreamReceiver = async_broadcast::Receiver; + +/// Sender for publishing messages in the RPC stream +/// +/// Used internally by RPC implementations to broadcast messages. +pub type StreamSender = async_broadcast::Sender; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_stream_message_direction() { + let incoming = StreamMessageDirection::Incoming; + assert_eq!(incoming, StreamMessageDirection::Incoming); + + let outgoing = StreamMessageDirection::Outgoing; + assert_eq!(outgoing, StreamMessageDirection::Outgoing); + } + + #[test] + fn test_stream_message_request() { + let content = StreamMessageContent::Request { + id: RequestId::Number(1), + method: "test.method".into(), + params: Some(serde_json::json!({"key": "value"})), + }; + + let message = StreamMessage::incoming(content.clone()); + assert_eq!(message.direction, StreamMessageDirection::Incoming); + assert_eq!(message.message, content); + } + + #[test] + fn test_stream_message_response() { + let content = StreamMessageContent::Response { + id: RequestId::Number(1), + result: Ok(Some(serde_json::json!({"success": true}))), + }; + + let message = StreamMessage::outgoing(content.clone()); + assert_eq!(message.direction, StreamMessageDirection::Outgoing); + assert_eq!(message.message, content); + } + + #[test] + fn test_stream_message_notification() { + let content = StreamMessageContent::Notification { + method: "notify.event".into(), + params: Some(serde_json::json!({"event": "test"})), + }; + + let message = StreamMessage::incoming(content.clone()); + assert_eq!(message.direction, StreamMessageDirection::Incoming); + assert_eq!(message.message, content); + } + + #[test] + fn test_stream_message_serialization() { + let message = StreamMessage::incoming(StreamMessageContent::Request { + id: RequestId::Str("req-1".into()), + method: "test.method".into(), + params: None, + }); + + let json = serde_json::to_string(&message).expect("serialize"); + let deserialized: StreamMessage = serde_json::from_str(&json).expect("deserialize"); + assert_eq!(message, deserialized); + } +} From 7d2ccf1a917c1e1b9f324af012fd5b9572a1b346 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Thu, 15 Jan 2026 09:35:45 -0500 Subject: [PATCH 6/7] refactor(unstable_elicitation): Make elicitation use separate method like permissions Changed elicitation from embedded in session/prompt flow to a separate request/response method pattern matching permissions design. This provides clearer protocol semantics and consistent handling of structured user input requests. PROTOCOL CHANGES: - Moved from PromptRequest.elicitation_response to RequestElicitationRequest - Moved from PromptResponse.elicitation to RequestElicitationResponse - New method: session/elicitation (separate like session/request_permission) API CHANGES: - Removed elicitation_response field from PromptRequest - Removed elicitation field from PromptResponse - Added RequestElicitationRequest wrapper struct - Added RequestElicitationResponse wrapper struct - Added SESSION_ELICITATION_METHOD_NAME constant This aligns with @benbrandt feedback on consistency with permission request/response pattern. --- src/agent.rs | 62 ++++++-------------------------- src/client.rs | 98 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 108 insertions(+), 52 deletions(-) diff --git a/src/agent.rs b/src/agent.rs index 4172e906..6091f1dc 100644 --- a/src/agent.rs +++ b/src/agent.rs @@ -14,9 +14,6 @@ use crate::{ ProtocolVersion, SessionId, }; -#[cfg(feature = "unstable_elicitation")] -use crate::{ElicitationRequest, ElicitationResponse}; - // Initialize /// Request parameters for the initialize method. @@ -2077,15 +2074,6 @@ pub struct PromptRequest { /// as it avoids extra round-trips and allows the message to include /// pieces of context from sources the agent may not have access to. pub prompt: Vec, - /// **UNSTABLE** - /// - /// The user's response to a previous elicitation request. - /// Only present if the user is responding to an elicitation. - /// - /// This feature is unstable and may change. - #[cfg(feature = "unstable_elicitation")] - #[serde(skip_serializing_if = "Option::is_none", rename = "elicitationResponse")] - pub elicitation_response: Option, /// 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. @@ -2101,25 +2089,10 @@ impl PromptRequest { Self { session_id: session_id.into(), prompt, - #[cfg(feature = "unstable_elicitation")] - elicitation_response: None, meta: None, } } - /// **UNSTABLE** - /// - /// Sets the elicitation response for this request. - /// Should be set when the user is responding to a previous elicitation. - /// - /// This feature is unstable and may change. - #[cfg(feature = "unstable_elicitation")] - #[must_use] - pub fn elicitation_response(mut self, response: impl IntoOption) -> Self { - self.elicitation_response = response.into_option(); - self - } - /// 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. @@ -2142,15 +2115,6 @@ impl PromptRequest { pub struct PromptResponse { /// Indicates why the agent stopped processing the turn. pub stop_reason: StopReason, - /// **UNSTABLE** - /// - /// An elicitation request if the agent is waiting for structured user input. - /// Only present when `stop_reason` is `ElicitationRequested`. - /// - /// This feature is unstable and may change. - #[cfg(feature = "unstable_elicitation")] - #[serde(skip_serializing_if = "Option::is_none")] - pub elicitation: Option, /// 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. @@ -2165,25 +2129,10 @@ impl PromptResponse { pub fn new(stop_reason: StopReason) -> Self { Self { stop_reason, - #[cfg(feature = "unstable_elicitation")] - elicitation: None, meta: None, } } - /// **UNSTABLE** - /// - /// Sets the elicitation request for this response. - /// Only valid when `stop_reason` is `ElicitationRequested`. - /// - /// This feature is unstable and may change. - #[cfg(feature = "unstable_elicitation")] - #[must_use] - pub fn elicitation(mut self, elicitation: impl IntoOption) -> Self { - self.elicitation = elicitation.into_option(); - self - } - /// 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. @@ -2224,10 +2173,11 @@ pub enum StopReason { /// **UNSTABLE** /// /// The turn ended because the agent is waiting for user input via elicitation. - /// The turn response will include an elicitation request. + /// The agent will send a separate `session/elicitation` request. /// /// This feature is unstable and may change. #[serde(rename = "elicitation_requested")] + #[cfg(feature = "unstable_elicitation")] ElicitationRequested, } @@ -2866,6 +2816,9 @@ pub struct AgentMethodNames { pub session_set_config_option: &'static str, /// Method for sending a prompt to the agent. pub session_prompt: &'static str, + /// Method for requesting elicitation (structured user input). + #[cfg(feature = "unstable_elicitation")] + pub session_elicitation: &'static str, /// Notification for cancelling operations. pub session_cancel: &'static str, /// Method for selecting a model for a given session. @@ -2892,6 +2845,8 @@ pub const AGENT_METHOD_NAMES: AgentMethodNames = AgentMethodNames { #[cfg(feature = "unstable_session_config_options")] session_set_config_option: SESSION_SET_CONFIG_OPTION_METHOD_NAME, session_prompt: SESSION_PROMPT_METHOD_NAME, + #[cfg(feature = "unstable_elicitation")] + session_elicitation: SESSION_ELICITATION_METHOD_NAME, session_cancel: SESSION_CANCEL_METHOD_NAME, #[cfg(feature = "unstable_session_model")] session_set_model: SESSION_SET_MODEL_METHOD_NAME, @@ -2918,6 +2873,9 @@ pub(crate) const SESSION_SET_MODE_METHOD_NAME: &str = "session/set_mode"; pub(crate) const SESSION_SET_CONFIG_OPTION_METHOD_NAME: &str = "session/set_config_option"; /// Method name for sending a prompt. pub(crate) const SESSION_PROMPT_METHOD_NAME: &str = "session/prompt"; +/// Method name for requesting elicitation (structured user input). +#[cfg(feature = "unstable_elicitation")] +pub(crate) const SESSION_ELICITATION_METHOD_NAME: &str = "session/elicitation"; /// Method name for the cancel notification. pub(crate) const SESSION_CANCEL_METHOD_NAME: &str = "session/cancel"; /// Method name for selecting a model for a given session. diff --git a/src/client.rs b/src/client.rs index 94cf5644..d20c8e0a 100644 --- a/src/client.rs +++ b/src/client.rs @@ -11,6 +11,10 @@ use serde::{Deserialize, Serialize}; #[cfg(feature = "unstable_session_config_options")] use crate::SessionConfigOption; +#[cfg(feature = "unstable_elicitation")] +use crate::agent::SESSION_ELICITATION_METHOD_NAME; +#[cfg(feature = "unstable_elicitation")] +use crate::{ElicitationRequest, ElicitationResponse}; use crate::{ ContentBlock, ExtNotification, ExtRequest, ExtResponse, IntoOption, Meta, Plan, SessionId, SessionModeId, ToolCall, ToolCallUpdate, @@ -638,6 +642,100 @@ impl SelectedPermissionOutcome { } } +// Elicitation + +/// Request for structured user input during a turn. +/// +/// Sent when the agent needs to elicit specific information from the user. +/// +/// **UNSTABLE** +/// This feature is unstable and may change. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)] +#[cfg(feature = "unstable_elicitation")] +#[schemars(extend("x-side" = "client", "x-method" = SESSION_ELICITATION_METHOD_NAME))] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub struct RequestElicitationRequest { + /// The session ID for this request. + pub session_id: SessionId, + /// The elicitation request details. + pub elicitation: ElicitationRequest, + /// 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/extensibility) + #[serde(skip_serializing_if = "Option::is_none", rename = "_meta")] + pub meta: Option, +} + +impl RequestElicitationRequest { + #[must_use] + pub fn new( + session_id: impl Into, + elicitation: ElicitationRequest, + ) -> Self { + Self { + session_id: session_id.into(), + elicitation, + 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. + /// + /// See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + #[must_use] + pub fn meta(mut self, meta: impl IntoOption) -> Self { + self.meta = meta.into_option(); + self + } +} + +/// Response to an elicitation request containing the user's input. +/// +/// **UNSTABLE** +/// This feature is unstable and may change. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)] +#[cfg(feature = "unstable_elicitation")] +#[schemars(extend("x-side" = "client", "x-method" = SESSION_ELICITATION_METHOD_NAME))] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub struct RequestElicitationResponse { + /// The user's response to the elicitation request. + pub elicitation_response: ElicitationResponse, + /// 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/extensibility) + #[serde(skip_serializing_if = "Option::is_none", rename = "_meta")] + pub meta: Option, +} + +impl RequestElicitationResponse { + #[must_use] + pub fn new(elicitation_response: ElicitationResponse) -> Self { + Self { + elicitation_response, + 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. + /// + /// See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility) + #[must_use] + pub fn meta(mut self, meta: impl IntoOption) -> Self { + self.meta = meta.into_option(); + self + } +} + // Write text file /// Request to write content to a text file. From 846403ab1eab92326732cf6af8d7b155abba9a23 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Thu, 15 Jan 2026 13:23:06 -0500 Subject: [PATCH 7/7] docs(rfd): Update elicitation.mdx to reflect separate method pattern Updated RFD to document the refactored architecture where elicitation uses a separate session/elicitation request/response method (matching permissions pattern) instead of being embedded in session/prompt flow. KEY CHANGES: - Clarified that elicitation is triggered by stopReason: "elicitation_requested" - Updated flow to show separate session/elicitation method call - Aligned with permission request/response pattern for consistency - Added complete JSON-RPC examples with method names and full message structure This addresses @benbrandt's feedback about consistency between permission and elicitation request/response mechanisms. --- docs/rfds/elicitation.mdx | 50 ++++++++++++++++++++++----------------- 1 file changed, 28 insertions(+), 22 deletions(-) diff --git a/docs/rfds/elicitation.mdx b/docs/rfds/elicitation.mdx index 35666d30..f794d033 100644 --- a/docs/rfds/elicitation.mdx +++ b/docs/rfds/elicitation.mdx @@ -41,7 +41,7 @@ The mechanism would: - **Selections**: select (single), multiselect (multiple) with enum-based options - **Sensitive inputs**: password, URL-mode for out-of-band OAuth flows (addressing PR #330 authentication pain points) -3. **Work in turn context**: Elicitation requests appear as part of turn responses, allowing agents to ask questions naturally within the conversation flow. Unlike Session Config Options (which are persistent), elicitation requests are transient and turn-specific. +3. **Work in turn context**: Elicitation requests are triggered when a turn ends with `stopReason: "elicitation_requested"`, allowing agents to ask questions naturally within the conversation flow. Agents send elicitation requests via a separate `session/elicitation` method (following the same request/response pattern as `session/request_permission`). Unlike Session Config Options (which are persistent), elicitation requests are transient and turn-specific. 4. **Support client capability negotiation**: Clients declare what elicitation types they support (similar to the client capabilities pattern emerging in the protocol). Agents handle gracefully when clients don't support elicitation. @@ -79,7 +79,7 @@ Key differences from MCP: ### Elicitation Request Structure -An elicitation request would be included in a turn response. Example 1 (User Selection - from PR #340): +When a turn ends with `stopReason: "elicitation_requested"`, the agent sends a separate elicitation request (following the same pattern as permission requests). Example 1 (User Selection - from PR #340): ```json { @@ -188,9 +188,9 @@ Aligning with MCP and building on [Session Config Options discussions](https://g This constraint list can expand in future versions based on community feedback. -### Complete Turn Response with Elicitation +### Turn Response with Elicitation Stop Reason -An agent can include both content and an elicitation request in the same turn response: +When an agent reaches a decision point and needs structured user input, it ends the turn with `stopReason: "elicitation_requested"`: ```json { @@ -203,6 +203,22 @@ An agent can include both content and an elicitation request in the same turn re "text": "I can refactor this code in several ways. Each approach has different tradeoffs. Which strategy would you prefer?" } ], + "stopReason": "elicitation_requested" + } +} +``` + +### Elicitation Request + +After the turn completes with `stopReason: "elicitation_requested"`, the agent immediately sends a separate `session/elicitation` request (following the same pattern as `session/request_permission`): + +```json +{ + "jsonrpc": "2.0", + "id": 43, + "method": "session/elicitation", + "params": { + "sessionId": "...", "elicitation": { "id": "refactor-strategy-001", "type": "select", @@ -230,34 +246,22 @@ An agent can include both content and an elicitation request in the same turn re "description": "Maximum optimization, requires review" } ] - }, - "stopReason": "elicitation_requested" + } } } ``` -The agent displays content to the user, then presents the elicitation UI. The `stopReason` indicates why the turn has stopped (awaiting user input). +The client presents the elicitation UI to the user based on the input type and constraints. ### User Response -When a user responds to an elicitation request, the response is included in the next turn request: +When the user responds to an elicitation request, the client sends a separate `session/elicitation` response: ```json { - "method": "session/turn", - "params": { - "sessionId": "...", - "messages": [ - { - "role": "user", - "content": [ - { - "type": "text", - "text": "I'll go with balanced" - } - ] - } - ], + "jsonrpc": "2.0", + "id": 43, + "result": { "elicitationResponse": { "id": "refactor-strategy-001", "value": "balanced" @@ -266,6 +270,8 @@ When a user responds to an elicitation request, the response is included in the } ``` +The agent then continues processing with the user's input in the next turn or takes immediate action based on the response. + ### Client Capabilities Clients declare whether they support elicitation during the `initialize` phase via `ClientCapabilities`, following the same pattern as `fs` and `terminal` capabilities: