From f7f370b182a8803ddaa79c2506ec6b5616f1cc42 Mon Sep 17 00:00:00 2001 From: Mikayla Maki Date: Tue, 2 Jun 2026 23:05:51 -0700 Subject: [PATCH 1/4] Fable support Adds the send_to_user tool plus refusal-fallback model support, gated on the Fable model id prefix. --- assets/settings/default.json | 6 +- crates/acp_thread/src/acp_thread.rs | 56 +++++++++ crates/agent/src/agent.rs | 23 +++- crates/agent/src/thread.rs | 97 ++++++++++++++- crates/agent/src/tools.rs | 2 + crates/agent/src/tools/send_to_user_tool.rs | 115 ++++++++++++++++++ .../src/conversation_view/thread_view.rs | 82 ++++++++++++- crates/anthropic/src/anthropic.rs | 3 + crates/language_model/src/language_model.rs | 11 ++ .../language_models/src/provider/anthropic.rs | 12 ++ .../src/language_models_cloud.rs | 12 ++ 11 files changed, 410 insertions(+), 9 deletions(-) create mode 100644 crates/agent/src/tools/send_to_user_tool.rs diff --git a/assets/settings/default.json b/assets/settings/default.json index 326182ff5195ad..ef2512eabfa2bf 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -1125,6 +1125,7 @@ "rename_symbol": true, "read_file": true, "grep": true, + "send_to_user": true, "skill": true, "spawn_agent": true, "terminal": true, @@ -1147,6 +1148,7 @@ "go_to_definition": true, "read_file": true, "grep": true, + "send_to_user": true, "skill": true, "spawn_agent": true, "search_web": true, @@ -1155,7 +1157,9 @@ "minimal": { "name": "Minimal", "enable_all_context_servers": false, - "tools": {}, + "tools": { + "send_to_user": true, + }, }, }, // Where to show notifications when the agent has either completed diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs index e3fcf7ac4fa625..fd2fa435f7d5fb 100644 --- a/crates/acp_thread/src/acp_thread.rs +++ b/crates/acp_thread/src/acp_thread.rs @@ -835,6 +835,37 @@ impl ContentBlock { } } + /// Updates a Markdown block in place from a streaming text `block`, reusing + /// the existing `Markdown` entity rather than recreating it. Appends only the + /// new suffix when the update is a continuation (the common streaming case), + /// otherwise re-sets the source. Returns `false` when an in-place update isn't + /// applicable, so the caller can fall back to replacing the block wholesale. + /// + /// Recreating the entity on every streamed snapshot causes the rendered + /// element to tear down and rebuild, which flickers badly. + pub fn update_text_in_place( + &mut self, + block: &acp::ContentBlock, + cx: &mut App, + ) -> bool { + let ContentBlock::Markdown { markdown } = self else { + return false; + }; + let acp::ContentBlock::Text(text_content) = block else { + return false; + }; + let new_content = &text_content.text; + markdown.update(cx, |markdown, cx| { + let current = markdown.source().to_string(); + match new_content.strip_prefix(¤t) { + Some("") => {} + Some(suffix) => markdown.append(suffix, cx), + None => markdown.reset(new_content.clone().into(), cx), + } + }); + true + } + fn decode_image( image_content: &acp::ImageContent, ) -> Option<(Arc, Option>)> { @@ -1003,6 +1034,17 @@ impl ToolCallContent { terminals: &HashMap>, cx: &mut App, ) -> Result { + // Update streaming text in place so the rendered markdown element is + // reused across snapshots instead of being recreated (which flickers). + if let ( + Self::ContentBlock(block), + acp::ToolCallContent::Content(acp::Content { content, .. }), + ) = (&mut *self, &new) + && block.update_text_in_place(content, cx) + { + return Ok(true); + } + let needs_update = match (&self, &new) { (Self::Diff(old_diff), acp::ToolCallContent::Diff(new_diff)) => { old_diff.read(cx).needs_update( @@ -1203,6 +1245,20 @@ pub struct RetryStatus { pub max_attempts: usize, pub started_at: Instant, pub duration: Duration, + pub meta: Option, +} + +pub const REFUSAL_FALLBACK_MODEL_META_KEY: &str = "refusal_fallback_model"; + +pub fn meta_with_refusal_fallback(model_name: &str) -> acp::Meta { + acp::Meta::from_iter([(REFUSAL_FALLBACK_MODEL_META_KEY.into(), model_name.into())]) +} + +pub fn refusal_fallback_model_from_meta(meta: &Option) -> Option { + meta.as_ref() + .and_then(|m| m.get(REFUSAL_FALLBACK_MODEL_META_KEY)) + .and_then(|v| v.as_str()) + .map(|s| SharedString::from(s.to_owned())) } struct RunningTurn { diff --git a/crates/agent/src/agent.rs b/crates/agent/src/agent.rs index 6b4b81e7426f77..885b256974448a 100644 --- a/crates/agent/src/agent.rs +++ b/crates/agent/src/agent.rs @@ -267,6 +267,10 @@ impl LanguageModels { self.refresh_models_rx.clone() } + pub fn notify_model_selection_changed(&mut self) { + self.refresh_models_tx.send(()).ok(); + } + pub fn model_from_id(&self, model_id: &AgentModelId) -> Option> { self.models.get(model_id).cloned() } @@ -1604,6 +1608,7 @@ impl NativeAgent { NativeAgentConnection::handle_thread_events( events, acp_thread.downgrade(), + None, cx, ) }) @@ -1821,10 +1826,12 @@ impl NativeAgent { } })?; + let connection = this.upgrade().map(NativeAgentConnection); cx.update(|cx| { NativeAgentConnection::handle_thread_events( response_stream, acp_thread.downgrade(), + connection, cx, ) }) @@ -1923,10 +1930,12 @@ impl NativeAgent { let response_stream = thread.update(cx, |thread, cx| thread.send_existing(cx))?; + let connection = this.upgrade().map(NativeAgentConnection); cx.update(|cx| { NativeAgentConnection::handle_thread_events( response_stream, acp_thread.downgrade(), + connection, cx, ) }) @@ -2018,12 +2027,13 @@ impl NativeAgentConnection { Ok(stream) => stream, Err(err) => return Task::ready(Err(err)), }; - Self::handle_thread_events(response_stream, acp_thread.downgrade(), cx) + Self::handle_thread_events(response_stream, acp_thread.downgrade(), Some(self.clone()), cx) } fn handle_thread_events( mut events: mpsc::UnboundedReceiver>, acp_thread: WeakEntity, + connection: Option, cx: &App, ) -> Task> { cx.spawn(async move |cx| { @@ -2097,6 +2107,17 @@ impl NativeAgentConnection { })?; } ThreadEvent::Retry(status) => { + if acp_thread::refusal_fallback_model_from_meta(&status.meta) + .is_some() + { + if let Some(connection) = &connection { + cx.update(|cx| { + connection.0.update(cx, |agent, _| { + agent.models.notify_model_selection_changed(); + }); + }); + } + } acp_thread.update(cx, |thread, cx| { thread.update_retry_status(status, cx) })?; diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index b84065479820b8..a855df8b0db8df 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -3,8 +3,8 @@ use crate::{ 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, + RenameTool, SandboxedTerminalTool, SendToUserTool, SpawnAgentTool, SystemPromptTemplate, + Template, Templates, TerminalTool, ToolPermissionDecision, WebSearchTool, WriteFileTool, decide_permission_from_settings, }; use acp_thread::{MentionUri, UserMessageId}; @@ -1433,6 +1433,13 @@ impl Thread { stream: &ThreadEventStream, cx: &mut Context, ) { + // A tool call left only with the canceled sentinel produced nothing useful + // (the sentinel is model-facing only, and is inserted exactly when a tool + // had no real result). Don't replay it into the UI at all. + if tool_result.is_some_and(Self::is_canceled_tool_result) { + return; + } + let output = tool_result .as_ref() .and_then(|result| result.output.clone()); @@ -1530,6 +1537,18 @@ impl Thread { ); } + /// A canceled tool result carries only the model-facing `TOOL_CANCELED_MESSAGE` + /// sentinel (inserted exactly when a tool had no real result). It's never + /// meaningful to the user, so we detect it to skip replaying the tool call. + fn is_canceled_tool_result(tool_result: &LanguageModelToolResult) -> bool { + tool_result.is_error + && matches!( + tool_result.content.as_slice(), + [LanguageModelToolResultContent::Text(text)] + if text.as_ref() == TOOL_CANCELED_MESSAGE + ) + } + fn tool_result_content_for_replay( tool_result: &LanguageModelToolResult, ) -> Option> { @@ -1909,6 +1928,7 @@ impl Thread { environment.clone(), )); self.add_tool(WebSearchTool); + self.add_tool(SendToUserTool); self.add_tool(DiagnosticsTool::new(self.project.clone())); @@ -2310,6 +2330,8 @@ impl Thread { ) -> Result<()> { let mut attempt = 0; let mut intent = CompletionIntent::UserPrompt; + // Set when a refusal fallback occurs so subsequent iterations use the fallback model. + let mut refusal_fallback_model: Option> = None; loop { if cx.update(|cx| cx.has_flag::()) { match Self::perform_compaction_if_needed( @@ -2349,10 +2371,11 @@ impl Thread { // Re-read the model and refresh tools on each iteration so that // mid-turn changes (e.g. the user switches model, toggles tools, // or changes profile) take effect between tool-call rounds. + // If a refusal fallback is active, use that model instead. let (model, request) = this.update(cx, |this, cx| { - let model = this - .model + let model = refusal_fallback_model .clone() + .or_else(|| this.model.clone()) .ok_or_else(|| anyhow!(NoModelConfiguredError))?; this.refresh_turn_tools(cx); let request = this.build_completion_request(intent, cx)?; @@ -2382,6 +2405,7 @@ impl Thread { FuturesUnordered::new(); let mut early_tool_results: Vec = Vec::new(); let mut cancelled = false; + let mut had_refusal = false; loop { // Race between getting the first event, tool completion, and cancellation. let first_event = futures::select! { @@ -2463,6 +2487,14 @@ impl Thread { tool_results.extend(batch_result.0); if let Some(err) = batch_result.1 { + let is_refusal = err + .downcast_ref::() + .is_some_and(|e| matches!(e, CompletionError::Refusal)); + if is_refusal { + log::info!("Model refused request; checking for fallback model"); + had_refusal = true; + break; + } error = Some(err.downcast()?); break; } @@ -2488,6 +2520,59 @@ impl Thread { } })?; + if had_refusal { + let maybe_fallback = this.update(cx, |this, cx| -> Option> { + let current_model = refusal_fallback_model.as_ref().or(this.model.as_ref())?; + let fallback_id = match current_model.refusal_fallback_model_id() { + Some(id) => id, + None => { + log::info!( + "Refusal fallback: no fallback configured for model {} (provider {})", + current_model.id().0, + current_model.provider_id() + ); + return None; + } + }; + let provider_id = current_model.provider_id(); + let found = LanguageModelRegistry::global(cx) + .read(cx) + .available_models(cx) + .find(|m| { + m.provider_id() == provider_id && m.id().0.as_ref() == fallback_id + }); + if found.is_none() { + log::info!( + "Refusal fallback: fallback model {}/{} not found in available models", + provider_id, + fallback_id + ); + } + found + })?; + + if let Some(fallback) = maybe_fallback { + log::info!("Refusal fallback: retrying with {}", fallback.id().0); + let fallback_name = fallback.name().0.clone(); + this.update(cx, |this, cx| { + this.pending_message = None; + this.set_model(fallback.clone(), cx); + })?; + event_stream.send_retry(acp_thread::RetryStatus { + last_error: "Safety filter triggered".into(), + attempt: 1, + max_attempts: 1, + started_at: Instant::now(), + duration: Duration::MAX, + meta: Some(acp_thread::meta_with_refusal_fallback(&fallback_name)), + }); + refusal_fallback_model = Some(fallback); + continue; + } + log::info!("Request refused with no fallback model available"); + return Err(CompletionError::Refusal.into()); + } + let end_turn = tool_results.is_empty() && early_tool_results.is_empty(); for tool_result in early_tool_results { @@ -2750,6 +2835,7 @@ impl Thread { max_attempts: max_attempts as usize, started_at: Instant::now(), duration: delay, + meta: None, }) } @@ -3470,6 +3556,9 @@ impl Thread { } }) .filter(|(tool_name, _)| crate::tools::tool_feature_flag_enabled(tool_name, cx)) + .filter(|(tool_name, _)| { + tool_name.as_ref() != SendToUserTool::NAME || model.supports_send_to_user_tool() + }) .collect::>(); let mut context_server_tools = Vec::new(); diff --git a/crates/agent/src/tools.rs b/crates/agent/src/tools.rs index f2e92dd28570fd..d166ec90ab3677 100644 --- a/crates/agent/src/tools.rs +++ b/crates/agent/src/tools.rs @@ -20,6 +20,7 @@ mod list_directory_tool; mod move_path_tool; mod read_file_tool; mod rename_tool; +mod send_to_user_tool; mod skill_tool; mod spawn_agent_tool; mod symbol_locator; @@ -81,6 +82,7 @@ pub use list_directory_tool::*; pub use move_path_tool::*; pub use read_file_tool::*; pub use rename_tool::*; +pub use send_to_user_tool::*; pub use skill_tool::*; pub use spawn_agent_tool::*; pub use symbol_locator::*; diff --git a/crates/agent/src/tools/send_to_user_tool.rs b/crates/agent/src/tools/send_to_user_tool.rs new file mode 100644 index 00000000000000..6192b3b26c3475 --- /dev/null +++ b/crates/agent/src/tools/send_to_user_tool.rs @@ -0,0 +1,115 @@ +use std::sync::Arc; + +use agent_client_protocol::schema as acp; +use futures::FutureExt as _; +use gpui::{App, Task}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use ui::SharedString; + +use crate::{AgentTool, ToolCallEventStream, ToolInput, ToolInputPayload}; + +/// Displays a message directly to the user, bypassing summarization. +/// Use this for deliverables, progress updates, or any content the user must see verbatim. +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +pub struct SendToUserToolInput { + /// The message to display to the user. + pub message: String, +} + +/// Partial form of [`SendToUserToolInput`], used to surface the message as it +/// streams in so the UI can render it incrementally. +#[derive(Debug, Deserialize)] +struct SendToUserToolPartialInput { + message: Option, +} + +pub struct SendToUserTool; + +impl SendToUserTool { + fn message_content(message: &str) -> acp::ToolCallUpdateFields { + acp::ToolCallUpdateFields::new().content(vec![acp::ToolCallContent::Content( + acp::Content::new(acp::ContentBlock::Text(acp::TextContent::new( + message.to_string(), + ))), + )]) + } +} + +impl AgentTool for SendToUserTool { + type Input = SendToUserToolInput; + type Output = String; + + const NAME: &'static str = "send_to_user"; + + fn supports_input_streaming() -> bool { + true + } + + fn kind() -> acp::ToolKind { + acp::ToolKind::Other + } + + fn initial_title( + &self, + _input: Result, + _cx: &mut App, + ) -> SharedString { + "Message to user".into() + } + + fn run( + self: Arc, + mut input: ToolInput, + event_stream: ToolCallEventStream, + cx: &mut App, + ) -> Task> { + cx.spawn(async move |_cx| { + // This tool only surfaces content the model already produced, so it must + // never be discarded on interruption. Whatever has streamed in is already + // on screen, so we report success on cancellation (or if the input stream + // closes mid-turn) rather than erroring out and dropping the message. + loop { + futures::select! { + payload = input.next().fuse() => { + match payload { + // Stream the message as it arrives so the UI renders it incrementally. + Ok(ToolInputPayload::Partial(partial)) => { + if let Ok(parsed) = + serde_json::from_value::(partial) + && let Some(message) = parsed.message + { + event_stream.update_fields(Self::message_content(&message)); + } + } + Ok(ToolInputPayload::Full(input)) => { + event_stream.update_fields(Self::message_content(&input.message)); + return Ok("ok".to_string()); + } + Ok(ToolInputPayload::InvalidJson { error_message }) => { + return Err(error_message); + } + // Input stream closed (e.g. the turn was canceled mid-stream): + // keep what already streamed and succeed. + Err(_) => return Ok("ok".to_string()), + } + } + _ = event_stream.cancelled_by_user().fuse() => { + return Ok("ok".to_string()); + } + } + } + }) + } + + fn replay( + &self, + input: Self::Input, + _output: Self::Output, + event_stream: ToolCallEventStream, + _cx: &mut App, + ) -> anyhow::Result<()> { + event_stream.update_fields(Self::message_content(&input.message)); + Ok(()) + } +} diff --git a/crates/agent_ui/src/conversation_view/thread_view.rs b/crates/agent_ui/src/conversation_view/thread_view.rs index 0e5570e06ec7d6..d2d9a798393f91 100644 --- a/crates/agent_ui/src/conversation_view/thread_view.rs +++ b/crates/agent_ui/src/conversation_view/thread_view.rs @@ -2621,9 +2621,28 @@ impl ThreadView { } } - pub fn render_thread_retry_status_callout(&self) -> Option { + pub fn render_thread_retry_status_callout(&self, cx: &mut Context) -> Option { let state = self.thread_retry_status.as_ref()?; + if let Some(fallback_model) = acp_thread::refusal_fallback_model_from_meta(&state.meta) { + return Some( + Callout::new() + .icon(IconName::Warning) + .severity(Severity::Warning) + .title(state.last_error.clone()) + .description(format!("Retrying with {fallback_model}")) + .dismiss_action( + IconButton::new("dismiss-refusal-fallback", IconName::Close) + .icon_size(IconSize::Small) + .tooltip(Tooltip::text("Dismiss")) + .on_click(cx.listener(|this, _, _, cx| { + this.thread_retry_status = None; + cx.notify(); + })), + ), + ); + } + let next_attempt_in = state .duration .saturating_sub(Instant::now().saturating_duration_since(state.started_at)); @@ -5558,6 +5577,28 @@ impl ThreadView { } } AgentThreadEntry::ToolCall(tool_call) => { + // A canceled tool call that produced visible output is still worth + // showing, but one that was canceled before producing anything just + // renders as a useless "Canceled" card — hide those entirely. + if matches!(tool_call.status, ToolCallStatus::Canceled) { + let has_visible_content = + tool_call.content.iter().any(|content| match content { + ToolCallContent::ContentBlock(block) => match block { + ContentBlock::Empty => false, + ContentBlock::Markdown { markdown } => { + !markdown.read(cx).source().trim().is_empty() + } + ContentBlock::ResourceLink { .. } | ContentBlock::Image { .. } => { + true + } + }, + ToolCallContent::Diff(_) | ToolCallContent::Terminal(_) => true, + }); + if !has_visible_content { + return Empty.into_any(); + } + } + let tool_call = self.render_any_tool_call( self.thread.read(cx).session_id(), entry_ix, @@ -7037,7 +7078,9 @@ impl ThreadView { let has_terminals = tool_call.terminals().next().is_some(); div().w_full().map(|this| { - if tool_call.is_subagent() { + if tool_call.tool_name.as_deref() == Some("send_to_user") { + this.child(self.render_send_to_user_tool_call(tool_call, window, cx)) + } else if tool_call.is_subagent() { this.child( self.render_subagent_tool_call( active_session_id, @@ -7079,6 +7122,39 @@ impl ThreadView { }) } + /// `send_to_user` delivers content the user must see verbatim, so we render + /// its message as a first-class markdown block (streaming in as it arrives) + /// rather than tucking it inside a collapsible tool-call card. + fn render_send_to_user_tool_call( + &self, + tool_call: &ToolCall, + window: &Window, + cx: &Context, + ) -> Div { + let style = MarkdownStyle::themed(MarkdownFont::Agent, window, cx); + let messages = tool_call.content.iter().filter_map(|content| { + let ToolCallContent::ContentBlock(block) = content else { + return None; + }; + let markdown = block.markdown()?; + if markdown.read(cx).source().trim().is_empty() { + return None; + } + Some( + self.render_markdown(markdown.clone(), style.clone(), cx) + .into_any_element(), + ) + }); + + v_flex() + .px_5() + .py_1p5() + .w_full() + .gap_2() + .text_ui(cx) + .children(messages) + } + fn render_tool_call( &self, active_session_id: &acp::SessionId, @@ -10437,7 +10513,7 @@ impl Render for ThreadView { this.child(self.render_codex_windows_warning(cx)) }) .children(self.render_skill_loading_issues(cx)) - .children(self.render_thread_retry_status_callout()) + .children(self.render_thread_retry_status_callout(cx)) .children(self.render_thread_error(window, cx)) .when_some( match has_messages { diff --git a/crates/anthropic/src/anthropic.rs b/crates/anthropic/src/anthropic.rs index b8d4253d859fe2..3e0c17ac788295 100644 --- a/crates/anthropic/src/anthropic.rs +++ b/crates/anthropic/src/anthropic.rs @@ -20,6 +20,9 @@ pub mod completion; pub const ANTHROPIC_API_URL: &str = "https://api.anthropic.com"; const FAST_MODE_BETA_HEADER: &str = "fast-mode-2026-02-01"; +pub const FABLE_MODEL_ID_PREFIX: &str = "claude-fable"; +pub const FABLE_FALLBACK_MODEL_ID: &str = "claude-opus-4-8"; + #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)] pub enum AnthropicModelMode { diff --git a/crates/language_model/src/language_model.rs b/crates/language_model/src/language_model.rs index 7dc237a65dd768..a3b49d068321c0 100644 --- a/crates/language_model/src/language_model.rs +++ b/crates/language_model/src/language_model.rs @@ -112,6 +112,17 @@ pub trait LanguageModel: Send + Sync { false } + /// When this model refuses a request, the model ID to fall back to (same provider). + fn refusal_fallback_model_id(&self) -> Option<&'static str> { + None + } + + /// Whether this model supports the send_to_user tool, which bypasses summarization + /// to deliver content to the user verbatim. + fn supports_send_to_user_tool(&self) -> bool { + false + } + fn tool_input_format(&self) -> LanguageModelToolSchemaFormat { LanguageModelToolSchemaFormat::JsonSchema } diff --git a/crates/language_models/src/provider/anthropic.rs b/crates/language_models/src/provider/anthropic.rs index cb7f8b7aa114fb..590f2c32be4496 100644 --- a/crates/language_models/src/provider/anthropic.rs +++ b/crates/language_models/src/provider/anthropic.rs @@ -463,6 +463,18 @@ impl LanguageModel for AnthropicModel { self.model.supports_speed } + fn refusal_fallback_model_id(&self) -> Option<&'static str> { + if self.model.id.starts_with(anthropic::FABLE_MODEL_ID_PREFIX) { + Some(anthropic::FABLE_FALLBACK_MODEL_ID) + } else { + None + } + } + + fn supports_send_to_user_tool(&self) -> bool { + self.model.id.starts_with(anthropic::FABLE_MODEL_ID_PREFIX) + } + fn supported_effort_levels(&self) -> Vec { self.model .supported_effort_levels diff --git a/crates/language_models_cloud/src/language_models_cloud.rs b/crates/language_models_cloud/src/language_models_cloud.rs index 940eb51dc37d10..7467dce0280acd 100644 --- a/crates/language_models_cloud/src/language_models_cloud.rs +++ b/crates/language_models_cloud/src/language_models_cloud.rs @@ -343,6 +343,18 @@ impl LanguageModel for CloudLanguageModel Option<&'static str> { + if self.id.0.as_ref().starts_with(anthropic::FABLE_MODEL_ID_PREFIX) { + Some(anthropic::FABLE_FALLBACK_MODEL_ID) + } else { + None + } + } + + fn supports_send_to_user_tool(&self) -> bool { + self.id.0.as_ref().starts_with(anthropic::FABLE_MODEL_ID_PREFIX) + } + fn telemetry_id(&self) -> String { format!("zed.dev/{}", self.model.id) } From db3a44c717c3d673f0cb6dfb738da553615b0103 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Mon, 8 Jun 2026 16:41:18 -0400 Subject: [PATCH 2/4] Remove send_to_user tool --- assets/settings/default.json | 6 +- crates/agent/src/thread.rs | 8 +- crates/agent/src/tools.rs | 2 - crates/agent/src/tools/send_to_user_tool.rs | 115 ------------------ .../src/conversation_view/thread_view.rs | 37 +----- crates/language_model/src/language_model.rs | 6 - .../language_models/src/provider/anthropic.rs | 4 - .../src/language_models_cloud.rs | 11 +- 8 files changed, 10 insertions(+), 179 deletions(-) delete mode 100644 crates/agent/src/tools/send_to_user_tool.rs diff --git a/assets/settings/default.json b/assets/settings/default.json index ef2512eabfa2bf..326182ff5195ad 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -1125,7 +1125,6 @@ "rename_symbol": true, "read_file": true, "grep": true, - "send_to_user": true, "skill": true, "spawn_agent": true, "terminal": true, @@ -1148,7 +1147,6 @@ "go_to_definition": true, "read_file": true, "grep": true, - "send_to_user": true, "skill": true, "spawn_agent": true, "search_web": true, @@ -1157,9 +1155,7 @@ "minimal": { "name": "Minimal", "enable_all_context_servers": false, - "tools": { - "send_to_user": true, - }, + "tools": {}, }, }, // Where to show notifications when the agent has either completed diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index a855df8b0db8df..ae5038a322279d 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -3,8 +3,8 @@ use crate::{ CreateThreadTool, DbLanguageModel, DbThread, DeletePathTool, DiagnosticsTool, EditFileTool, FetchTool, FindPathTool, FindReferencesTool, GetCodeActionsTool, GoToDefinitionTool, GrepTool, ListAgentsAndModelsTool, ListDirectoryTool, MovePathTool, ProjectSnapshot, ReadFileTool, - RenameTool, SandboxedTerminalTool, SendToUserTool, SpawnAgentTool, SystemPromptTemplate, - Template, Templates, TerminalTool, ToolPermissionDecision, WebSearchTool, WriteFileTool, + RenameTool, SandboxedTerminalTool, SpawnAgentTool, SystemPromptTemplate, Template, Templates, + TerminalTool, ToolPermissionDecision, WebSearchTool, WriteFileTool, decide_permission_from_settings, }; use acp_thread::{MentionUri, UserMessageId}; @@ -1928,7 +1928,6 @@ impl Thread { environment.clone(), )); self.add_tool(WebSearchTool); - self.add_tool(SendToUserTool); self.add_tool(DiagnosticsTool::new(self.project.clone())); @@ -3556,9 +3555,6 @@ impl Thread { } }) .filter(|(tool_name, _)| crate::tools::tool_feature_flag_enabled(tool_name, cx)) - .filter(|(tool_name, _)| { - tool_name.as_ref() != SendToUserTool::NAME || model.supports_send_to_user_tool() - }) .collect::>(); let mut context_server_tools = Vec::new(); diff --git a/crates/agent/src/tools.rs b/crates/agent/src/tools.rs index d166ec90ab3677..f2e92dd28570fd 100644 --- a/crates/agent/src/tools.rs +++ b/crates/agent/src/tools.rs @@ -20,7 +20,6 @@ mod list_directory_tool; mod move_path_tool; mod read_file_tool; mod rename_tool; -mod send_to_user_tool; mod skill_tool; mod spawn_agent_tool; mod symbol_locator; @@ -82,7 +81,6 @@ pub use list_directory_tool::*; pub use move_path_tool::*; pub use read_file_tool::*; pub use rename_tool::*; -pub use send_to_user_tool::*; pub use skill_tool::*; pub use spawn_agent_tool::*; pub use symbol_locator::*; diff --git a/crates/agent/src/tools/send_to_user_tool.rs b/crates/agent/src/tools/send_to_user_tool.rs deleted file mode 100644 index 6192b3b26c3475..00000000000000 --- a/crates/agent/src/tools/send_to_user_tool.rs +++ /dev/null @@ -1,115 +0,0 @@ -use std::sync::Arc; - -use agent_client_protocol::schema as acp; -use futures::FutureExt as _; -use gpui::{App, Task}; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; -use ui::SharedString; - -use crate::{AgentTool, ToolCallEventStream, ToolInput, ToolInputPayload}; - -/// Displays a message directly to the user, bypassing summarization. -/// Use this for deliverables, progress updates, or any content the user must see verbatim. -#[derive(Debug, Serialize, Deserialize, JsonSchema)] -pub struct SendToUserToolInput { - /// The message to display to the user. - pub message: String, -} - -/// Partial form of [`SendToUserToolInput`], used to surface the message as it -/// streams in so the UI can render it incrementally. -#[derive(Debug, Deserialize)] -struct SendToUserToolPartialInput { - message: Option, -} - -pub struct SendToUserTool; - -impl SendToUserTool { - fn message_content(message: &str) -> acp::ToolCallUpdateFields { - acp::ToolCallUpdateFields::new().content(vec![acp::ToolCallContent::Content( - acp::Content::new(acp::ContentBlock::Text(acp::TextContent::new( - message.to_string(), - ))), - )]) - } -} - -impl AgentTool for SendToUserTool { - type Input = SendToUserToolInput; - type Output = String; - - const NAME: &'static str = "send_to_user"; - - fn supports_input_streaming() -> bool { - true - } - - fn kind() -> acp::ToolKind { - acp::ToolKind::Other - } - - fn initial_title( - &self, - _input: Result, - _cx: &mut App, - ) -> SharedString { - "Message to user".into() - } - - fn run( - self: Arc, - mut input: ToolInput, - event_stream: ToolCallEventStream, - cx: &mut App, - ) -> Task> { - cx.spawn(async move |_cx| { - // This tool only surfaces content the model already produced, so it must - // never be discarded on interruption. Whatever has streamed in is already - // on screen, so we report success on cancellation (or if the input stream - // closes mid-turn) rather than erroring out and dropping the message. - loop { - futures::select! { - payload = input.next().fuse() => { - match payload { - // Stream the message as it arrives so the UI renders it incrementally. - Ok(ToolInputPayload::Partial(partial)) => { - if let Ok(parsed) = - serde_json::from_value::(partial) - && let Some(message) = parsed.message - { - event_stream.update_fields(Self::message_content(&message)); - } - } - Ok(ToolInputPayload::Full(input)) => { - event_stream.update_fields(Self::message_content(&input.message)); - return Ok("ok".to_string()); - } - Ok(ToolInputPayload::InvalidJson { error_message }) => { - return Err(error_message); - } - // Input stream closed (e.g. the turn was canceled mid-stream): - // keep what already streamed and succeed. - Err(_) => return Ok("ok".to_string()), - } - } - _ = event_stream.cancelled_by_user().fuse() => { - return Ok("ok".to_string()); - } - } - } - }) - } - - fn replay( - &self, - input: Self::Input, - _output: Self::Output, - event_stream: ToolCallEventStream, - _cx: &mut App, - ) -> anyhow::Result<()> { - event_stream.update_fields(Self::message_content(&input.message)); - Ok(()) - } -} diff --git a/crates/agent_ui/src/conversation_view/thread_view.rs b/crates/agent_ui/src/conversation_view/thread_view.rs index d2d9a798393f91..9e637ad887c74a 100644 --- a/crates/agent_ui/src/conversation_view/thread_view.rs +++ b/crates/agent_ui/src/conversation_view/thread_view.rs @@ -7078,9 +7078,7 @@ impl ThreadView { let has_terminals = tool_call.terminals().next().is_some(); div().w_full().map(|this| { - if tool_call.tool_name.as_deref() == Some("send_to_user") { - this.child(self.render_send_to_user_tool_call(tool_call, window, cx)) - } else if tool_call.is_subagent() { + if tool_call.is_subagent() { this.child( self.render_subagent_tool_call( active_session_id, @@ -7122,39 +7120,6 @@ impl ThreadView { }) } - /// `send_to_user` delivers content the user must see verbatim, so we render - /// its message as a first-class markdown block (streaming in as it arrives) - /// rather than tucking it inside a collapsible tool-call card. - fn render_send_to_user_tool_call( - &self, - tool_call: &ToolCall, - window: &Window, - cx: &Context, - ) -> Div { - let style = MarkdownStyle::themed(MarkdownFont::Agent, window, cx); - let messages = tool_call.content.iter().filter_map(|content| { - let ToolCallContent::ContentBlock(block) = content else { - return None; - }; - let markdown = block.markdown()?; - if markdown.read(cx).source().trim().is_empty() { - return None; - } - Some( - self.render_markdown(markdown.clone(), style.clone(), cx) - .into_any_element(), - ) - }); - - v_flex() - .px_5() - .py_1p5() - .w_full() - .gap_2() - .text_ui(cx) - .children(messages) - } - fn render_tool_call( &self, active_session_id: &acp::SessionId, diff --git a/crates/language_model/src/language_model.rs b/crates/language_model/src/language_model.rs index a3b49d068321c0..88d043bc15533b 100644 --- a/crates/language_model/src/language_model.rs +++ b/crates/language_model/src/language_model.rs @@ -117,12 +117,6 @@ pub trait LanguageModel: Send + Sync { None } - /// Whether this model supports the send_to_user tool, which bypasses summarization - /// to deliver content to the user verbatim. - fn supports_send_to_user_tool(&self) -> bool { - false - } - fn tool_input_format(&self) -> LanguageModelToolSchemaFormat { LanguageModelToolSchemaFormat::JsonSchema } diff --git a/crates/language_models/src/provider/anthropic.rs b/crates/language_models/src/provider/anthropic.rs index 590f2c32be4496..6ac0dd18f8b813 100644 --- a/crates/language_models/src/provider/anthropic.rs +++ b/crates/language_models/src/provider/anthropic.rs @@ -471,10 +471,6 @@ impl LanguageModel for AnthropicModel { } } - fn supports_send_to_user_tool(&self) -> bool { - self.model.id.starts_with(anthropic::FABLE_MODEL_ID_PREFIX) - } - fn supported_effort_levels(&self) -> Vec { self.model .supported_effort_levels diff --git a/crates/language_models_cloud/src/language_models_cloud.rs b/crates/language_models_cloud/src/language_models_cloud.rs index 7467dce0280acd..23fc133ad51cc2 100644 --- a/crates/language_models_cloud/src/language_models_cloud.rs +++ b/crates/language_models_cloud/src/language_models_cloud.rs @@ -344,17 +344,18 @@ impl LanguageModel for CloudLanguageModel Option<&'static str> { - if self.id.0.as_ref().starts_with(anthropic::FABLE_MODEL_ID_PREFIX) { + if self + .id + .0 + .as_ref() + .starts_with(anthropic::FABLE_MODEL_ID_PREFIX) + { Some(anthropic::FABLE_FALLBACK_MODEL_ID) } else { None } } - fn supports_send_to_user_tool(&self) -> bool { - self.id.0.as_ref().starts_with(anthropic::FABLE_MODEL_ID_PREFIX) - } - fn telemetry_id(&self) -> String { format!("zed.dev/{}", self.model.id) } From 8b7c19366a7caa85d12bb04804ca9eef30664413 Mon Sep 17 00:00:00 2001 From: Mikayla Maki Date: Tue, 9 Jun 2026 13:08:47 -0400 Subject: [PATCH 3/4] Gate Claude Fable behind Anthropic data retention consent Claude Fable cannot be offered with Zero Data Retention; Anthropic retains inference logs for 30 days. This adds: - A telemetry.anthropic_retention setting (default false), surfaced in the settings UI Privacy section. - A requires_data_retention() query on the LanguageModel trait, true for cloud models with the claude-fable-5 id prefix. - A hard gate in CloudLanguageModel::stream_completion that raises a typed DataRetentionConsentRequiredError when consent is missing, which is non-retryable. - An agent panel callout for that error with Switch to Opus 4.8 / Accept actions; both resume the failed turn after acting, so the user's message continues without retyping. --- assets/settings/default.json | 3 + crates/agent/src/thread.rs | 3 + crates/agent_ui/src/conversation_view.rs | 17 +++ .../src/conversation_view/thread_view.rs | 131 +++++++++++++++++- crates/anthropic/src/anthropic.rs | 2 +- crates/client/src/client.rs | 7 +- crates/language_model/src/language_model.rs | 17 ++- .../src/language_model_core.rs | 8 ++ crates/language_models/src/provider/cloud.rs | 12 +- .../src/language_models_cloud.rs | 47 +++++-- crates/settings/src/vscode_import.rs | 1 + .../settings_content/src/settings_content.rs | 6 + crates/settings_ui/src/page_data.rs | 24 +++- 13 files changed, 252 insertions(+), 26 deletions(-) diff --git a/assets/settings/default.json b/assets/settings/default.json index 326182ff5195ad..ee6c6b9541f302 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -1471,6 +1471,9 @@ "diagnostics": true, // Send anonymized usage data like what languages you're using Zed with. "metrics": true, + // Allow sending requests to Anthropic models that cannot be offered with + // Zero Data Retention + "anthropic_retention": false, }, // Whether to disable all AI features in Zed. // diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index ae5038a322279d..5d0e75a22f29f0 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -3976,6 +3976,9 @@ impl Thread { // Retrying won't help for Payment Required errors. None } + // Retrying won't help until the user consents to data retention + // or switches models. + DataRetentionConsentRequired { .. } => None, // Conservatively assume that any other errors are non-retryable HttpResponseError { .. } | Other(..) => Some(RetryStrategy::Fixed { delay: BASE_RETRY_DELAY, diff --git a/crates/agent_ui/src/conversation_view.rs b/crates/agent_ui/src/conversation_view.rs index 7fc0a59f0602e3..c1f473a69987b1 100644 --- a/crates/agent_ui/src/conversation_view.rs +++ b/crates/agent_ui/src/conversation_view.rs @@ -123,6 +123,7 @@ enum ThreadFeedback { #[derive(Debug)] pub(crate) enum ThreadError { PaymentRequired, + DataRetentionConsentRequired, Refusal, AuthenticationRequired(SharedString), RateLimitExceeded { @@ -196,6 +197,7 @@ impl From for ThreadError { provider: provider.to_string().into(), }, UpstreamProviderError { .. } => Self::RequestFailed, + DataRetentionConsentRequired { .. } => Self::DataRetentionConsentRequired, BadRequestFormat { provider, .. } | HttpResponseError { provider, .. } | ApiEndpointNotFound { provider } => Self::ApiError { @@ -3443,6 +3445,21 @@ pub(crate) mod tests { use super::*; + #[test] + fn test_data_retention_error_maps_from_provider_error() { + // The agent wraps the provider error in a fresh `anyhow::Error`, so + // the mapping must downcast to `LanguageModelCompletionError` rather + // than matching on the anyhow error directly. + let provider_error = LanguageModelCompletionError::DataRetentionConsentRequired { + model_name: "Claude Fable 5".to_string(), + }; + let error = ThreadError::from(anyhow!(provider_error)); + assert!( + matches!(error, ThreadError::DataRetentionConsentRequired), + "expected ThreadError::DataRetentionConsentRequired, got: {error:?}" + ); + } + #[gpui::test] async fn test_drop(cx: &mut TestAppContext) { init_test(cx); diff --git a/crates/agent_ui/src/conversation_view/thread_view.rs b/crates/agent_ui/src/conversation_view/thread_view.rs index 9e637ad887c74a..c8041757002ebc 100644 --- a/crates/agent_ui/src/conversation_view/thread_view.rs +++ b/crates/agent_ui/src/conversation_view/thread_view.rs @@ -23,10 +23,10 @@ use gpui::Stateful; use gpui::TaskExt; use heapless::Vec as ArrayVec; use language_model::{ - FastModeConfirmation, LanguageModelEffortLevel, LanguageModelId, LanguageModelProviderId, - LanguageModelRegistry, Speed, + FastModeConfirmation, LanguageModel, LanguageModelEffortLevel, LanguageModelId, + LanguageModelProviderId, LanguageModelRegistry, Speed, }; -use settings::update_settings_file; +use settings::{update_settings_file, update_settings_file_with_completion}; use ui::{ ButtonLike, CalloutBorderPosition, SpinnerLabel, SpinnerVariant, SplitButton, SplitButtonStyle, Tab, @@ -36,6 +36,9 @@ use workspace::{OpenOptions, SERIALIZATION_THROTTLE_TIME}; use super::*; +// TODO: Replace with the final link explaining Anthropic's data retention policy. +const DATA_RETENTION_LEARN_MORE_URL: &str = "#link-tbd"; + #[derive(Default)] struct ThreadFeedbackState { feedback: Option, @@ -1714,6 +1717,13 @@ impl ThreadView { ); ("refusal", None, message.into()) } + ThreadError::DataRetentionConsentRequired => { + let message = format!( + "{} is not available with Zero Data Retention.", + self.current_model_name(cx) + ); + ("data_retention_consent_required", None, message.into()) + } ThreadError::AuthenticationRequired(message) => { ("authentication_required", None, message.clone()) } @@ -9348,6 +9358,9 @@ impl ThreadView { self.render_any_thread_error(message.clone(), window, cx) } ThreadError::Refusal => self.render_refusal_error(cx), + ThreadError::DataRetentionConsentRequired => { + self.render_data_retention_consent_error(cx) + } ThreadError::AuthenticationRequired(error) => { self.render_authentication_required_error(error.clone(), cx) } @@ -10119,6 +10132,118 @@ impl ThreadView { ) } + /// Returns the model to offer as a downgrade target when the current model + /// requires data retention consent (e.g. Opus 4.8 for Fable). + fn data_retention_fallback_model(&self, cx: &App) -> Option> { + let thread = self.as_native_thread(cx)?; + let model = thread.read(cx).model()?.clone(); + let fallback_id = model.refusal_fallback_model_id()?; + LanguageModelRegistry::read_global(cx) + .available_models(cx) + .find(|fallback| { + fallback.provider_id() == model.provider_id() + && fallback.id().0.as_ref() == fallback_id + }) + } + + fn render_data_retention_consent_error(&self, cx: &mut Context) -> Callout { + let fallback_model = self.data_retention_fallback_model(cx); + + Callout::new() + .severity(Severity::Warning) + .icon(IconName::Warning) + .title(format!( + "Note: {} cannot be offered with Zero Data Retention.", + self.current_model_name(cx) + )) + .description_slot( + h_flex() + .gap_1() + .child( + Label::new("Anthropic will retain inference logs.") + .size(LabelSize::Small) + .color(Color::Muted), + ) + .child( + Button::new("data-retention-learn-more", "Learn More") + .label_size(LabelSize::Small) + .on_click(|_, _, cx| { + cx.open_url(DATA_RETENTION_LEARN_MORE_URL); + }), + ), + ) + .actions_slot( + h_flex() + .gap_0p5() + .when_some(fallback_model, |this, fallback| { + this.child( + Button::new( + "switch-data-retention-fallback", + format!("Switch to {}", fallback.name().0), + ) + .label_size(LabelSize::Small) + .on_click(cx.listener(|this, _, _, cx| { + this.switch_to_data_retention_fallback_and_resend(cx); + })), + ) + }) + .child( + Button::new("accept-data-retention", "Accept") + .label_size(LabelSize::Small) + .style(ButtonStyle::Tinted(TintColor::Warning)) + .on_click(cx.listener(|this, _, _, cx| { + this.accept_data_retention_and_resend(cx); + })), + ), + ) + .dismiss_action(self.dismiss_error_button(cx)) + } + + fn accept_data_retention_and_resend(&mut self, cx: &mut Context) { + let fs = self.thread.read(cx).project().read(cx).fs().clone(); + // Resume the failed turn only once the in-memory settings reflect + // consent, otherwise the resent request would be rejected again. + let completion = update_settings_file_with_completion(fs, cx, |settings, _| { + settings + .telemetry + .get_or_insert_default() + .anthropic_retention = Some(true); + }); + cx.spawn(async move |this, cx| { + completion.await??; + this.update(cx, |this, cx| this.retry_generation(cx))?; + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + } + + fn switch_to_data_retention_fallback_and_resend(&mut self, cx: &mut Context) { + let Some(fallback) = self.data_retention_fallback_model(cx) else { + return; + }; + let model_id = acp_thread::AgentModelId::new(format!( + "{}/{}", + fallback.provider_id().0, + fallback.id().0 + )); + let session_id = self.thread.read(cx).session_id().clone(); + let Some(selector) = self + .thread + .read(cx) + .connection() + .model_selector(&session_id) + else { + return; + }; + let select = selector.select_model(model_id, cx); + cx.spawn(async move |this, cx| { + select.await?; + this.update(cx, |this, cx| this.retry_generation(cx))?; + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + } + fn open_permission_dropdown( &mut self, _: &crate::OpenPermissionDropdown, diff --git a/crates/anthropic/src/anthropic.rs b/crates/anthropic/src/anthropic.rs index 3e0c17ac788295..90b1a841809170 100644 --- a/crates/anthropic/src/anthropic.rs +++ b/crates/anthropic/src/anthropic.rs @@ -20,7 +20,7 @@ pub mod completion; pub const ANTHROPIC_API_URL: &str = "https://api.anthropic.com"; const FAST_MODE_BETA_HEADER: &str = "fast-mode-2026-02-01"; -pub const FABLE_MODEL_ID_PREFIX: &str = "claude-fable"; +pub const FABLE_MODEL_ID_PREFIX: &str = "claude-fable-5"; pub const FABLE_FALLBACK_MODEL_ID: &str = "claude-opus-4-8"; #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] diff --git a/crates/client/src/client.rs b/crates/client/src/client.rs index 36c5a49602a916..e586d4e2904ce2 100644 --- a/crates/client/src/client.rs +++ b/crates/client/src/client.rs @@ -540,13 +540,16 @@ impl Drop for PendingEntitySubscription { pub struct TelemetrySettings { pub diagnostics: bool, pub metrics: bool, + pub anthropic_retention: bool, } impl settings::Settings for TelemetrySettings { fn from_settings(content: &SettingsContent) -> Self { + let telemetry = content.telemetry.as_ref().unwrap(); Self { - diagnostics: content.telemetry.as_ref().unwrap().diagnostics.unwrap(), - metrics: content.telemetry.as_ref().unwrap().metrics.unwrap(), + diagnostics: telemetry.diagnostics.unwrap(), + metrics: telemetry.metrics.unwrap(), + anthropic_retention: telemetry.anthropic_retention.unwrap(), } } } diff --git a/crates/language_model/src/language_model.rs b/crates/language_model/src/language_model.rs index 88d043bc15533b..5cf1a6ea087b40 100644 --- a/crates/language_model/src/language_model.rs +++ b/crates/language_model/src/language_model.rs @@ -60,6 +60,18 @@ pub trait LanguageModel: Send + Sync { false } + /// Whether requests to this model require the user to consent to the + /// upstream provider retaining inference logs (i.e. the model cannot be + /// offered with Zero Data Retention). + fn requires_data_retention(&self) -> bool { + false + } + + /// When this model refuses a request, the model ID to fall back to (same provider). + fn refusal_fallback_model_id(&self) -> Option<&'static str> { + None + } + fn telemetry_id(&self) -> String; fn api_key(&self, _cx: &App) -> Option { @@ -112,11 +124,6 @@ pub trait LanguageModel: Send + Sync { false } - /// When this model refuses a request, the model ID to fall back to (same provider). - fn refusal_fallback_model_id(&self) -> Option<&'static str> { - None - } - fn tool_input_format(&self) -> LanguageModelToolSchemaFormat { LanguageModelToolSchemaFormat::JsonSchema } diff --git a/crates/language_model_core/src/language_model_core.rs b/crates/language_model_core/src/language_model_core.rs index c2b7011ab2bdf1..3dd8330f71cbb0 100644 --- a/crates/language_model_core/src/language_model_core.rs +++ b/crates/language_model_core/src/language_model_core.rs @@ -88,6 +88,14 @@ impl LanguageModelCompletionEvent { pub enum LanguageModelCompletionError { #[error("prompt too large for context window")] PromptTooLarge { tokens: Option }, + /// The model requires the user to consent to the upstream provider + /// retaining inference logs (see `LanguageModel::requires_data_retention`) + /// and that consent has not been given. + #[error( + "{model_name} cannot be offered with Zero Data Retention. \ + Anthropic will retain inference logs." + )] + DataRetentionConsentRequired { model_name: String }, #[error("missing {provider} API key")] NoApiKey { provider: LanguageModelProviderName }, #[error("{provider}'s API rate limit exceeded")] diff --git a/crates/language_models/src/provider/cloud.rs b/crates/language_models/src/provider/cloud.rs index c8e85652e1af16..53caf82780c8f8 100644 --- a/crates/language_models/src/provider/cloud.rs +++ b/crates/language_models/src/provider/cloud.rs @@ -1,6 +1,8 @@ use ai_onboarding::YoungAccountBanner; use anyhow::Result; -use client::{Client, RefreshLlmTokenListener, UserStore, global_llm_token, zed_urls}; +use client::{ + Client, RefreshLlmTokenListener, TelemetrySettings, UserStore, global_llm_token, zed_urls, +}; use cloud_api_client::LlmApiToken; use cloud_api_types::OrganizationId; use cloud_api_types::Plan; @@ -70,6 +72,14 @@ impl CloudLlmTokenProvider for ClientTokenProvider { .await }) } + + fn has_data_retention_consent(&self, cx: &impl AppContext) -> bool { + cx.read_global(|settings_store: &SettingsStore, _| { + settings_store + .get::(None) + .anthropic_retention + }) + } } #[derive(Default, Clone, Debug, PartialEq)] diff --git a/crates/language_models_cloud/src/language_models_cloud.rs b/crates/language_models_cloud/src/language_models_cloud.rs index 23fc133ad51cc2..c129042dec6b51 100644 --- a/crates/language_models_cloud/src/language_models_cloud.rs +++ b/crates/language_models_cloud/src/language_models_cloud.rs @@ -55,6 +55,11 @@ pub trait CloudLlmTokenProvider: Send + Sync { fn auth_context(&self, cx: &impl AppContext) -> Self::AuthContext; fn cached_token(&self, auth_context: Self::AuthContext) -> BoxFuture<'static, Result>; fn refresh_token(&self, auth_context: Self::AuthContext) -> BoxFuture<'static, Result>; + + /// Whether the user has consented to upstream providers retaining + /// inference logs for models that require it (see + /// [`LanguageModel::requires_data_retention`]). + fn has_data_retention_consent(&self, cx: &impl AppContext) -> bool; } /// Sends an authenticated request to the Zed LLM service, retrying once with @@ -298,6 +303,27 @@ impl LanguageModel for CloudLanguageModel bool { + // Anthropic cannot offer Fable models with Zero Data Retention + self.id + .0 + .as_ref() + .starts_with(anthropic::FABLE_MODEL_ID_PREFIX) + } + + fn refusal_fallback_model_id(&self) -> Option<&'static str> { + if self + .id + .0 + .as_ref() + .starts_with(anthropic::FABLE_MODEL_ID_PREFIX) + { + Some(anthropic::FABLE_FALLBACK_MODEL_ID) + } else { + None + } + } + fn supports_tools(&self) -> bool { self.model.supports_tools } @@ -343,19 +369,6 @@ impl LanguageModel for CloudLanguageModel Option<&'static str> { - if self - .id - .0 - .as_ref() - .starts_with(anthropic::FABLE_MODEL_ID_PREFIX) - { - Some(anthropic::FABLE_FALLBACK_MODEL_ID) - } else { - None - } - } - fn telemetry_id(&self) -> String { format!("zed.dev/{}", self.model.id) } @@ -392,6 +405,14 @@ impl LanguageModel for CloudLanguageModel, > { + if self.requires_data_retention() && !self.token_provider.has_data_retention_consent(cx) { + let model_name = self.model.display_name.clone(); + return async move { + Err(LanguageModelCompletionError::DataRetentionConsentRequired { model_name }) + } + .boxed(); + } + let thread_id = request.thread_id.clone(); let prompt_id = request.prompt_id.clone(); let app_version = self.app_version.clone(); diff --git a/crates/settings/src/vscode_import.rs b/crates/settings/src/vscode_import.rs index e4053bd2b75711..c2a9ec74c26de6 100644 --- a/crates/settings/src/vscode_import.rs +++ b/crates/settings/src/vscode_import.rs @@ -862,6 +862,7 @@ impl VsCodeSettings { Some(TelemetrySettingsContent { metrics: Some(metrics), diagnostics: Some(diagnostics), + anthropic_retention: None, }) }) } diff --git a/crates/settings_content/src/settings_content.rs b/crates/settings_content/src/settings_content.rs index adb6fa884ec255..ec1ab6f08bde33 100644 --- a/crates/settings_content/src/settings_content.rs +++ b/crates/settings_content/src/settings_content.rs @@ -521,6 +521,11 @@ pub struct TelemetrySettingsContent { /// /// Default: true pub metrics: Option, + /// Allow sending requests to Anthropic models that cannot be offered with + /// Zero Data Retention. + /// + /// Default: false + pub anthropic_retention: Option, } impl Default for TelemetrySettingsContent { @@ -528,6 +533,7 @@ impl Default for TelemetrySettingsContent { Self { diagnostics: Some(true), metrics: Some(true), + anthropic_retention: Some(false), } } } diff --git a/crates/settings_ui/src/page_data.rs b/crates/settings_ui/src/page_data.rs index e8fc1f7248ff43..8f36d20505cda7 100644 --- a/crates/settings_ui/src/page_data.rs +++ b/crates/settings_ui/src/page_data.rs @@ -365,7 +365,7 @@ fn general_page(cx: &App) -> SettingsPage { ] } - fn privacy_section() -> [SettingsPageItem; 3] { + fn privacy_section() -> [SettingsPageItem; 4] { [ SettingsPageItem::SectionHeader("Privacy"), SettingsPageItem::SettingItem(SettingItem { @@ -409,6 +409,28 @@ fn general_page(cx: &App) -> SettingsPage { metadata: None, files: USER, }), + SettingsPageItem::SettingItem(SettingItem { + title: "Anthropic Data Retention", + description: "Allow sending requests to Anthropic models that cannot be offered with Zero Data Retention.", + field: Box::new(SettingField { + organization_override: None, + json_path: Some("telemetry.anthropic_retention"), + pick: |settings_content| { + settings_content + .telemetry + .as_ref() + .and_then(|telemetry| telemetry.anthropic_retention.as_ref()) + }, + write: |settings_content, value, _| { + settings_content + .telemetry + .get_or_insert_default() + .anthropic_retention = value; + }, + }), + metadata: None, + files: USER, + }), ] } From 293cd41c3a3883b48ae2e0b9574d5219727c8b8d Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Tue, 9 Jun 2026 13:30:51 -0400 Subject: [PATCH 4/4] Fix formatting and merge fallout from data retention changes --- crates/acp_thread/src/acp_thread.rs | 6 +----- crates/agent/src/agent.rs | 9 ++++++++- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs index a6a1dc4ffd15d5..466447d33fe945 100644 --- a/crates/acp_thread/src/acp_thread.rs +++ b/crates/acp_thread/src/acp_thread.rs @@ -901,11 +901,7 @@ impl ContentBlock { /// /// Recreating the entity on every streamed snapshot causes the rendered /// element to tear down and rebuild, which flickers badly. - pub fn update_text_in_place( - &mut self, - block: &acp::ContentBlock, - cx: &mut App, - ) -> bool { + pub fn update_text_in_place(&mut self, block: &acp::ContentBlock, cx: &mut App) -> bool { let ContentBlock::Markdown { markdown } = self else { return false; }; diff --git a/crates/agent/src/agent.rs b/crates/agent/src/agent.rs index 636d83366384fa..c02711d5dd9328 100644 --- a/crates/agent/src/agent.rs +++ b/crates/agent/src/agent.rs @@ -1894,10 +1894,12 @@ impl NativeAgent { acp_thread.update_token_usage(None, cx); }); + let connection = this.upgrade().map(NativeAgentConnection); cx.update(|cx| { NativeAgentConnection::handle_thread_events( response_stream, acp_thread.downgrade(), + connection, cx, ) }) @@ -2093,7 +2095,12 @@ impl NativeAgentConnection { Ok(stream) => stream, Err(err) => return Task::ready(Err(err)), }; - Self::handle_thread_events(response_stream, acp_thread.downgrade(), Some(self.clone()), cx) + Self::handle_thread_events( + response_stream, + acp_thread.downgrade(), + Some(self.clone()), + cx, + ) } fn handle_thread_events(