Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions assets/settings/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -1487,6 +1487,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.
//
Expand Down
52 changes: 52 additions & 0 deletions crates/acp_thread/src/acp_thread.rs
Original file line number Diff line number Diff line change
Expand Up @@ -893,6 +893,33 @@ 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(&current) {
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<gpui::Image>, Option<gpui::Size<u32>>)> {
Expand Down Expand Up @@ -1061,6 +1088,17 @@ impl ToolCallContent {
terminals: &HashMap<acp::TerminalId, Entity<Terminal>>,
cx: &mut App,
) -> Result<bool> {
// 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(
Expand Down Expand Up @@ -1261,6 +1299,20 @@ pub struct RetryStatus {
pub max_attempts: usize,
pub started_at: Instant,
pub duration: Duration,
pub meta: Option<acp::Meta>,
}

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<acp::Meta>) -> Option<SharedString> {
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 {
Expand Down
30 changes: 29 additions & 1 deletion crates/agent/src/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,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<Arc<dyn LanguageModel>> {
self.models.get(model_id).cloned()
}
Expand Down Expand Up @@ -1637,6 +1641,7 @@ impl NativeAgent {
NativeAgentConnection::handle_thread_events(
events,
acp_thread.downgrade(),
None,
cx,
)
})
Expand Down Expand Up @@ -1854,10 +1859,12 @@ impl NativeAgent {
}
})?;

let connection = this.upgrade().map(NativeAgentConnection);
cx.update(|cx| {
NativeAgentConnection::handle_thread_events(
response_stream,
acp_thread.downgrade(),
connection,
cx,
)
})
Expand Down Expand Up @@ -1887,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,
)
})
Expand Down Expand Up @@ -1989,10 +1998,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,
)
})
Expand Down Expand Up @@ -2084,12 +2095,18 @@ 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<Result<ThreadEvent>>,
acp_thread: WeakEntity<AcpThread>,
connection: Option<NativeAgentConnection>,
cx: &App,
) -> Task<Result<acp::PromptResponse>> {
cx.spawn(async move |cx| {
Expand Down Expand Up @@ -2163,6 +2180,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)
})?;
Expand Down
92 changes: 90 additions & 2 deletions crates/agent/src/thread.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1433,6 +1433,13 @@ impl Thread {
stream: &ThreadEventStream,
cx: &mut Context<Self>,
) {
// 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());
Expand Down Expand Up @@ -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<Vec<acp::ToolCallContent>> {
Expand Down Expand Up @@ -2385,6 +2404,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<Arc<dyn LanguageModel>> = None;
loop {
if cx.update(|cx| cx.has_flag::<HandoffFeatureFlag>()) {
match Self::perform_compaction_if_needed(
Expand Down Expand Up @@ -2424,10 +2445,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)?;
Expand Down Expand Up @@ -2457,6 +2479,7 @@ impl Thread {
FuturesUnordered::new();
let mut early_tool_results: Vec<LanguageModelToolResult> = 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! {
Expand Down Expand Up @@ -2538,6 +2561,14 @@ impl Thread {

tool_results.extend(batch_result.0);
if let Some(err) = batch_result.1 {
let is_refusal = err
.downcast_ref::<CompletionError>()
.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;
}
Expand All @@ -2563,6 +2594,59 @@ impl Thread {
}
})?;

if had_refusal {
let maybe_fallback = this.update(cx, |this, cx| -> Option<Arc<dyn LanguageModel>> {
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 {
Expand Down Expand Up @@ -2862,6 +2946,7 @@ impl Thread {
max_attempts: max_attempts as usize,
started_at: Instant::now(),
duration: delay,
meta: None,
})
}

Expand Down Expand Up @@ -4012,6 +4097,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,
Expand Down
17 changes: 17 additions & 0 deletions crates/agent_ui/src/conversation_view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ enum ThreadFeedback {
#[derive(Debug)]
pub(crate) enum ThreadError {
PaymentRequired,
DataRetentionConsentRequired,
Refusal,
AuthenticationRequired(SharedString),
RateLimitExceeded {
Expand Down Expand Up @@ -196,6 +197,7 @@ impl From<anyhow::Error> for ThreadError {
provider: provider.to_string().into(),
},
UpstreamProviderError { .. } => Self::RequestFailed,
DataRetentionConsentRequired { .. } => Self::DataRetentionConsentRequired,
BadRequestFormat { provider, .. }
| HttpResponseError { provider, .. }
| ApiEndpointNotFound { provider } => Self::ApiError {
Expand Down Expand Up @@ -3445,6 +3447,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);
Expand Down
Loading
Loading