From d2bbf4a8359a1d18cadcafa21025077e633b3244 Mon Sep 17 00:00:00 2001 From: Ethan Williams Date: Mon, 17 Aug 2026 00:36:02 +0000 Subject: [PATCH] agent: Add ask_user tool using elicitation forms (#61497) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Adds a first-party `ask_user` tool to Zed's native agent (`crates/agent`) that lets the agent ask the user a question and get an answer back through a proper **elicitation form** rather than free-form chat text. This is a step towards the interactive-question experience requested in [discussion #48784](https://github.com/zed-industries/zed/discussions/48784) (implement the ACP elicitation spec for agent questions). Zed already renders elicitation forms client-side; this wires the native agent up to that existing rail so the agent can drive single-select and/or free-text prompts. ## What it does The tool takes: - `question` — the prompt shown to the user - `options` — optional list of selectable choices (single-select) - `allow_free_text` — whether the user may type a custom answer instead of picking an option The agent decides the shape of each question: | `options` | `allow_free_text` | Result | | --- | --- | --- | | 2+ | `false` | Single-select only | | 2+ | `true` | Single-select with a free-text "other" field | | empty | `true` | Free-text only | A typed answer takes precedence over a selected option, and the tool always resolves (a cancelled or failed elicitation is reported back to the agent rather than hanging). ## Demo https://github.com/user-attachments/assets/9d438d87-af65-4fa2-b500-b1232007d0a0 ## Implementation - `thread.rs` — new `ThreadEvent::Elicitation(ElicitationRequest)` variant plus `ToolCallEventStream::request_elicitation`, mirroring the existing `prompt_for_decision` permission rail. - `agent.rs` — handles the elicitation event by building a session-scoped `acp::CreateElicitationRequest` and routing it through `AcpThread::request_elicitation`, piping the response back to the tool. - `tools/ask_user_tool.rs` — the tool itself, including schema construction and response extraction. - Registered in the `write` and `ask` profiles and excluded from the tool-permissions setup UI. No client-side UI changes were needed — the existing elicitation form rendering handles it. ## Tests `cargo test -p agent ask_user` — 5 unit tests covering schema construction, validation, and accept/decline/cancel handling. Release Notes: - Added an `ask_user` tool that lets the agent ask the user a question with selectable options and/or free-text input, rendered as an elicitation form --- assets/settings/default.json | 2 + crates/agent/src/agent.rs | 43 ++ crates/agent/src/thread.rs | 76 +++- crates/agent/src/tools.rs | 3 + crates/agent/src/tools/ask_user_tool.rs | 385 ++++++++++++++++++ .../src/pages/tool_permissions_setup.rs | 3 + 6 files changed, 505 insertions(+), 7 deletions(-) create mode 100644 crates/agent/src/tools/ask_user_tool.rs diff --git a/assets/settings/default.json b/assets/settings/default.json index 4ca1d26f36..36c0ecfd74 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -1191,6 +1191,7 @@ "delete_path": true, "diagnostics": true, "apply_code_action": true, + "ask_user": true, "edit_file": true, "write_file": true, "fetch": true, @@ -1217,6 +1218,7 @@ "tools": { "create_thread": true, "diagnostics": true, + "ask_user": true, "fetch": true, "list_agents_and_models": true, "list_directory": true, diff --git a/crates/agent/src/agent.rs b/crates/agent/src/agent.rs index 594032852e..af52932447 100644 --- a/crates/agent/src/agent.rs +++ b/crates/agent/src/agent.rs @@ -2235,6 +2235,49 @@ impl NativeAgentConnection { thread.authorize_tool_call(tool_call_id, outcome, cx); })?; } + ThreadEvent::Elicitation(ElicitationRequest { + tool_call_id, + message, + schema, + response, + }) => { + let request_result = acp_thread.update(cx, |thread, cx| { + let scope = acp::ElicitationSessionScope::new( + thread.session_id().clone(), + ) + .tool_call_id(tool_call_id); + let request = acp::CreateElicitationRequest::new( + acp::ElicitationFormMode::new(scope, schema), + message, + ); + thread.request_elicitation(request, cx) + })?; + match request_result { + Ok(response_task) => { + cx.background_spawn(async move { + let elicitation_response = response_task.await; + response + .send(elicitation_response) + .map_err(|_| { + anyhow!("elicitation receiver was dropped") + }) + .log_err(); + }) + .detach(); + } + Err(error) => { + log::error!("Failed to request elicitation: {error:?}"); + // Resolve the tool's pending request so it + // doesn't hang waiting on a form that will + // never render. + response + .send(acp::CreateElicitationResponse::new( + acp::ElicitationAction::Cancel, + )) + .ok(); + } + } + } ThreadEvent::ToolCall(tool_call) => { acp_thread.update(cx, |thread, cx| { thread.upsert_tool_call(tool_call, cx) diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index 0ef3e0c534..f2e34495c1 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -1,11 +1,11 @@ use crate::{ - ApplyCodeActionTool, CodeActionStore, ContextServerRegistry, CopyPathTool, CreateDirectoryTool, - CreateThreadTool, DbLanguageModel, DbThread, DeletePathTool, DiagnosticsTool, EditFileTool, - FetchTool, FindPathTool, FindReferencesTool, GetCodeActionsTool, GoToDefinitionTool, GrepTool, - ListAgentsAndModelsTool, ListDirectoryTool, MovePathTool, ProjectSnapshot, ReadFileTool, - RenameTool, SandboxedTerminalTool, SpawnAgentTool, SystemPromptTemplate, Template, Templates, - TerminalTool, ToolPermissionDecision, WebSearchTool, WriteFileTool, - decide_permission_from_settings, + ApplyCodeActionTool, AskUserTool, CodeActionStore, ContextServerRegistry, CopyPathTool, + CreateDirectoryTool, CreateThreadTool, DbLanguageModel, DbThread, DeletePathTool, + DiagnosticsTool, EditFileTool, FetchTool, FindPathTool, FindReferencesTool, GetCodeActionsTool, + GoToDefinitionTool, GrepTool, ListAgentsAndModelsTool, ListDirectoryTool, MovePathTool, + ProjectSnapshot, ReadFileTool, RenameTool, SandboxedTerminalTool, SpawnAgentTool, + SystemPromptTemplate, Template, Templates, TerminalTool, ToolPermissionDecision, WebSearchTool, + WriteFileTool, decide_permission_from_settings, }; use acp_thread::{ClientUserMessageId, MentionUri}; use action_log::ActionLog; @@ -880,6 +880,7 @@ pub enum ThreadEvent { tool_call_id: acp::ToolCallId, outcome: acp_thread::SelectedPermissionOutcome, }, + Elicitation(ElicitationRequest), SubagentSpawned(acp::SessionId), Retry(acp_thread::RetryStatus), ContextCompaction(acp_thread::ContextCompaction), @@ -1166,6 +1167,19 @@ fn ensure_tool_call_authorization_not_interrupted( } } +/// A request from a tool to elicit structured input from the user via a form. +/// +/// The scope (session id) is filled in by the connection layer when the request +/// is handed to the [`AcpThread`], so tools only supply the form schema and the +/// message to display. +#[derive(Debug)] +pub struct ElicitationRequest { + pub tool_call_id: acp::ToolCallId, + pub message: String, + pub schema: acp::ElicitationSchema, + pub response: oneshot::Sender, +} + fn auto_resolve_permission_outcome( options: &acp_thread::PermissionOptions, is_allow: bool, @@ -2151,6 +2165,8 @@ impl Thread { )); self.add_tool(WebSearchTool); + self.add_tool(AskUserTool); + self.add_tool(DiagnosticsTool::new(self.project.clone())); let code_action_store: CodeActionStore = cx.new(|_cx| None); @@ -6376,6 +6392,43 @@ impl ToolCallEventStream { }) } + /// Requests structured input from the user via an elicitation form and + /// returns the raw response (accept with content, decline, or cancel). + /// + /// Unlike [`Self::prompt_for_decision`], which presents a fixed set of + /// buttons, this renders a form from the provided JSON schema — so callers + /// can offer free-text fields, single-select enums, and typed inputs. The + /// session scope is attached by the connection layer. + pub fn request_elicitation( + &self, + message: String, + schema: acp::ElicitationSchema, + cx: &mut App, + ) -> Task> { + let stream = self.stream.clone(); + let tool_use_id = self.tool_use_id.clone(); + cx.spawn(async move |_cx| { + let (response_tx, response_rx) = oneshot::channel(); + if let Err(error) = + stream + .0 + .unbounded_send(Ok(ThreadEvent::Elicitation(ElicitationRequest { + tool_call_id: acp::ToolCallId::new(tool_use_id.to_string()), + message, + schema, + response: response_tx, + }))) + { + log::error!("Failed to send elicitation request: {error}"); + return Err(anyhow!("Failed to send elicitation request: {error}")); + } + + response_rx + .await + .map_err(|_| anyhow!("elicitation channel closed")) + }) + } + /// Prompts the user for authorization. /// /// When `check_settings` is `Some`, this gate is settings-driven: the @@ -6634,6 +6687,15 @@ impl ToolCallEventStreamReceiver { } } + pub async fn expect_elicitation(&mut self) -> ElicitationRequest { + let event = self.0.next().await; + if let Some(Ok(ThreadEvent::Elicitation(request))) = event { + request + } else { + panic!("Expected Elicitation but got: {:?}", event); + } + } + pub async fn expect_update_fields(&mut self) -> acp::ToolCallUpdateFields { let event = self.0.next().await; if let Some(Ok(ThreadEvent::ToolCallUpdate(acp_thread::ToolCallUpdate::UpdateFields( diff --git a/crates/agent/src/tools.rs b/crates/agent/src/tools.rs index 89211e0ac6..7630d2e3f4 100644 --- a/crates/agent/src/tools.rs +++ b/crates/agent/src/tools.rs @@ -1,4 +1,5 @@ mod apply_code_action_tool; +mod ask_user_tool; mod context_server_registry; mod copy_path_tool; mod create_directory_tool; @@ -70,6 +71,7 @@ where } pub use apply_code_action_tool::*; +pub use ask_user_tool::*; pub use context_server_registry::*; pub use copy_path_tool::*; pub use create_directory_tool::*; @@ -194,6 +196,7 @@ macro_rules! tools { // it never offers a tool the agent can't actually use. tools! { ApplyCodeActionTool, + AskUserTool, CopyPathTool, CreateDirectoryTool, CreateThreadTool, diff --git a/crates/agent/src/tools/ask_user_tool.rs b/crates/agent/src/tools/ask_user_tool.rs new file mode 100644 index 0000000000..50b3f2debc --- /dev/null +++ b/crates/agent/src/tools/ask_user_tool.rs @@ -0,0 +1,385 @@ +use std::collections::BTreeMap; +use std::sync::Arc; + +use crate::{AgentTool, ToolCallEventStream, ToolInput}; +use agent_client_protocol::schema::v1 as acp; +use anyhow::Result; +use gpui::{App, SharedString, Task}; +use language_model::LanguageModelToolResultContent; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +/// The single-select field key used when presenting a fixed list of options. +const CHOICE_FIELD: &str = "choice"; +/// The free-text field key used when the user may type their own answer. +const OTHER_FIELD: &str = "other"; + +/// Ask the user a question, presenting selectable options and/or a free-text +/// field. +/// +/// Use this when you need a decision or a piece of information from the user +/// mid-task — for example choosing between implementation approaches, confirming +/// an assumption, or supplying a value you can't infer. The question is rendered +/// as a small form in the conversation, and the user's answer is returned to you +/// verbatim. +/// +/// You control the shape of the answer: +/// - Provide `options` (two or more) to present clickable choices. +/// - Set `allow_free_text` to `true` to let the user type their own answer. Do +/// this when the listed options might not be exhaustive, or when you want a +/// free-form reply with no preset options at all. +/// +/// You must provide either at least two `options`, or set `allow_free_text` to +/// `true` (or both). If both are supplied, the user may pick an option or type +/// their own answer, and a typed answer takes precedence. +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +pub struct AskUserToolInput { + /// The question to ask the user. Keep it short and specific. + pub question: String, + /// The answer choices to present as selectable options. Each entry is the + /// exact label shown, and is returned verbatim when chosen. Provide at + /// least two options, or leave empty and set `allow_free_text` to `true`. + #[serde(default)] + pub options: Vec, + /// Whether the user may type their own free-form answer instead of (or in + /// addition to) picking an option. Set to `true` when the options may not + /// be exhaustive, or when you want a free-form reply. + #[serde(default)] + pub allow_free_text: bool, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(untagged)] +pub enum AskUserToolOutput { + Answered { selected: String }, + Error { error: String }, +} + +impl From for LanguageModelToolResultContent { + fn from(value: AskUserToolOutput) -> Self { + match value { + AskUserToolOutput::Answered { selected } => { + format!("The user selected: {selected}").into() + } + AskUserToolOutput::Error { error } => error.into(), + } + } +} + +pub struct AskUserTool; + +impl AgentTool for AskUserTool { + type Input = AskUserToolInput; + type Output = AskUserToolOutput; + + const NAME: &'static str = "ask_user"; + + fn kind() -> acp::ToolKind { + acp::ToolKind::Other + } + + fn initial_title( + &self, + input: Result, + _cx: &mut App, + ) -> SharedString { + match input { + Ok(input) if !input.question.is_empty() => SharedString::from(input.question), + _ => "Asking a question".into(), + } + } + + fn run( + self: Arc, + input: ToolInput, + event_stream: ToolCallEventStream, + cx: &mut App, + ) -> Task> { + cx.spawn(async move |cx| { + let input = input + .recv() + .await + .map_err(|error| AskUserToolOutput::Error { + error: error.to_string(), + })?; + + if !input.allow_free_text && input.options.len() < 2 { + return Err(AskUserToolOutput::Error { + error: "The `ask_user` tool needs at least two `options`, or \ + `allow_free_text` set to true. Ask an open-ended question \ + in prose instead." + .to_string(), + }); + } + + let schema = build_schema(&input.options, input.allow_free_text); + + let prompt = cx + .update(|cx| event_stream.request_elicitation(input.question.clone(), schema, cx)); + let response = prompt.await.map_err(|error| AskUserToolOutput::Error { + error: error.to_string(), + })?; + + let selected = match response.action { + acp::ElicitationAction::Accept(accept) => { + let content = accept.content.unwrap_or_default(); + // A typed answer takes precedence over a picked option. + string_field(&content, OTHER_FIELD) + .or_else(|| string_field(&content, CHOICE_FIELD)) + .ok_or_else(|| AskUserToolOutput::Error { + error: "The user submitted the form without providing an answer." + .to_string(), + })? + } + acp::ElicitationAction::Decline => { + return Err(AskUserToolOutput::Error { + error: "The user declined to answer the question.".to_string(), + }); + } + acp::ElicitationAction::Cancel => { + return Err(AskUserToolOutput::Error { + error: "The user cancelled the question without answering.".to_string(), + }); + } + _ => { + return Err(AskUserToolOutput::Error { + error: "The question was dismissed without an answer.".to_string(), + }); + } + }; + + event_stream.update_fields( + acp::ToolCallUpdateFields::new().title(format!("Answered: {selected}")), + ); + + Ok(AskUserToolOutput::Answered { selected }) + }) + } +} + +/// Builds the elicitation form schema for a question. +/// +/// - Presenting a fixed list of options adds a required single-select field, +/// unless free text is also allowed (in which case picking is optional). +/// - Allowing free text adds a text field, required only when there are no +/// options to choose from. +fn build_schema(options: &[String], allow_free_text: bool) -> acp::ElicitationSchema { + let mut schema = acp::ElicitationSchema::new(); + + if !options.is_empty() { + let enum_options = options + .iter() + .map(|label| acp::EnumOption::new(label.clone(), label.clone())) + .collect::>(); + schema = schema.property( + CHOICE_FIELD, + acp::StringPropertySchema::new() + .title("Choose an option") + .one_of(enum_options), + !allow_free_text, + ); + } + + if allow_free_text { + let title = if options.is_empty() { + "Your answer" + } else { + "Or type your own answer" + }; + schema = schema.property( + OTHER_FIELD, + acp::StringPropertySchema::new().title(title), + options.is_empty(), + ); + } + + schema +} + +/// Extracts a non-empty string value for `key` from an elicitation response. +fn string_field( + content: &BTreeMap, + key: &str, +) -> Option { + match content.get(key) { + Some(acp::ElicitationContentValue::String(value)) if !value.is_empty() => { + Some(value.clone()) + } + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use gpui::TestAppContext; + + fn accept_with( + entries: impl IntoIterator, + ) -> acp::CreateElicitationResponse { + let content: BTreeMap = entries + .into_iter() + .map(|(key, value)| { + ( + key.to_string(), + acp::ElicitationContentValue::String(value.to_string()), + ) + }) + .collect(); + acp::CreateElicitationResponse::new(acp::ElicitationAction::Accept( + acp::ElicitationAcceptAction::new().content(content), + )) + } + + #[gpui::test] + async fn test_ask_user_returns_selected_option(cx: &mut TestAppContext) { + let (event_stream, mut event_rx) = ToolCallEventStream::test(); + let tool = Arc::new(AskUserTool); + let task = cx.update(|cx| { + tool.run( + ToolInput::resolved(AskUserToolInput { + question: "Which approach?".into(), + options: vec!["Approach A".into(), "Approach B".into()], + allow_free_text: false, + }), + event_stream, + cx, + ) + }); + + let request = event_rx.expect_elicitation().await; + assert_eq!(request.message, "Which approach?"); + assert!(request.schema.properties.contains_key(CHOICE_FIELD)); + assert!(!request.schema.properties.contains_key(OTHER_FIELD)); + + request + .response + .send(accept_with([(CHOICE_FIELD, "Approach B")])) + .unwrap(); + + match task.await { + Ok(AskUserToolOutput::Answered { selected }) => assert_eq!(selected, "Approach B"), + other => panic!("expected an answer, got {other:?}"), + } + } + + #[gpui::test] + async fn test_ask_user_free_text_answer(cx: &mut TestAppContext) { + let (event_stream, mut event_rx) = ToolCallEventStream::test(); + let tool = Arc::new(AskUserTool); + let task = cx.update(|cx| { + tool.run( + ToolInput::resolved(AskUserToolInput { + question: "What should I name it?".into(), + options: vec![], + allow_free_text: true, + }), + event_stream, + cx, + ) + }); + + let request = event_rx.expect_elicitation().await; + assert!(request.schema.properties.contains_key(OTHER_FIELD)); + assert!(!request.schema.properties.contains_key(CHOICE_FIELD)); + + request + .response + .send(accept_with([(OTHER_FIELD, "widget")])) + .unwrap(); + + match task.await { + Ok(AskUserToolOutput::Answered { selected }) => assert_eq!(selected, "widget"), + other => panic!("expected an answer, got {other:?}"), + } + } + + #[gpui::test] + async fn test_ask_user_free_text_takes_precedence(cx: &mut TestAppContext) { + let (event_stream, mut event_rx) = ToolCallEventStream::test(); + let tool = Arc::new(AskUserTool); + let task = cx.update(|cx| { + tool.run( + ToolInput::resolved(AskUserToolInput { + question: "Pick or type".into(), + options: vec!["A".into(), "B".into()], + allow_free_text: true, + }), + event_stream, + cx, + ) + }); + + let request = event_rx.expect_elicitation().await; + assert!(request.schema.properties.contains_key(CHOICE_FIELD)); + assert!(request.schema.properties.contains_key(OTHER_FIELD)); + + request + .response + .send(accept_with([(CHOICE_FIELD, "A"), (OTHER_FIELD, "custom")])) + .unwrap(); + + match task.await { + Ok(AskUserToolOutput::Answered { selected }) => assert_eq!(selected, "custom"), + other => panic!("expected the typed answer, got {other:?}"), + } + } + + #[gpui::test] + async fn test_ask_user_decline_is_an_error(cx: &mut TestAppContext) { + let (event_stream, mut event_rx) = ToolCallEventStream::test(); + let tool = Arc::new(AskUserTool); + let task = cx.update(|cx| { + tool.run( + ToolInput::resolved(AskUserToolInput { + question: "Proceed?".into(), + options: vec!["Yes".into(), "No".into()], + allow_free_text: false, + }), + event_stream, + cx, + ) + }); + + let request = event_rx.expect_elicitation().await; + request + .response + .send(acp::CreateElicitationResponse::new( + acp::ElicitationAction::Decline, + )) + .unwrap(); + + match task.await { + Err(AskUserToolOutput::Error { error }) => { + assert!(error.contains("declined"), "got: {error}"); + } + other => panic!("expected an error, got {other:?}"), + } + } + + #[gpui::test] + async fn test_ask_user_requires_options_or_free_text(cx: &mut TestAppContext) { + let (event_stream, _event_rx) = ToolCallEventStream::test(); + let tool = Arc::new(AskUserTool); + let result = cx + .update(|cx| { + tool.run( + ToolInput::resolved(AskUserToolInput { + question: "Proceed?".into(), + options: vec!["Only one".into()], + allow_free_text: false, + }), + event_stream, + cx, + ) + }) + .await; + + match result { + Err(AskUserToolOutput::Error { error }) => { + assert!(error.contains("at least two"), "got: {error}"); + } + other => panic!("expected an error, got {other:?}"), + } + } +} diff --git a/crates/settings_ui/src/pages/tool_permissions_setup.rs b/crates/settings_ui/src/pages/tool_permissions_setup.rs index d76f83cd9e..d01dd1701d 100644 --- a/crates/settings_ui/src/pages/tool_permissions_setup.rs +++ b/crates/settings_ui/src/pages/tool_permissions_setup.rs @@ -1404,6 +1404,9 @@ mod tests { const EXCLUDED_TOOLS: &[&str] = &[ // Read-only / low-risk tools that don't call decide_permission_from_settings "apply_code_action", + // Interactive prompt: always asks the user via prompt_for_decision, + // so it has no settings-driven permission rules. + "ask_user", "diagnostics", "find_path", "find_references",