agent_ui: Replace raw error messages with user-friendly copy in the agent panel (#53099)
Replaces raw provider error strings in the agent panel with specific,
user-friendly error callouts. Each error now has a clear title,
actionable copy, and appropriate buttons instead of the generic "An
Error Happened" fallback.
Error variants added:
Variant | Title | Body | Trigger |
|---|---|---|---|
| `RateLimitExceeded` | Rate Limit Reached | {Provider}'s rate limit was
reached. Zed will retry automatically. You can also wait a moment and
try again. | Provider rate limit exhausted after retries |
| `ServerOverloaded` | Provider Unavailable | {Provider}'s servers are
temporarily unavailable. Zed will retry automatically. If the problem
persists, check the provider's status page. | Provider server overloaded
or internal server error |
| `PromptTooLarge` | Context Too Large | This conversation is too long
for the model's context window. Start a new thread or remove some
attached files to continue. | Conversation exceeds model's context
window |
| `NoApiKey` | API Key Missing | No API key is configured for
{Provider}. Add your key via the Agent Panel settings to continue. | No
API key configured for a direct provider |
| `StreamError` | Connection Interrupted | The connection to
{Provider}'s API was interrupted. Zed will retry automatically. If the
problem persists, check your network connection. | Stream dropped or I/O
error during generation |
| `InvalidApiKey` | Invalid API Key | The API key for {Provider} is
invalid or has expired. Update your key via the Agent Panel settings to
continue. | API key present but invalid or expired |
| `PermissionDenied` | Permission Denied | {Provider}'s API rejected the
request due to insufficient permissions. Check that your API key has
access to this model. | API key lacks access to the requested model |
| `RequestFailed` | Request Failed | The request could not be completed
after multiple attempts. Try again in a moment. | Upstream provider
unreachable after retries |
| `MaxOutputTokens` | Output Limit Reached | The model stopped because
it reached its maximum output length. You can ask it to continue where
it left off. | Model hit its maximum output token budget |
| `NoModelSelected` | No Model Selected | Select a model from the model
picker below to get started. | No model configured when a message is
sent |
| `ApiError` | API Error | {Provider}'s API returned an unexpected
error. If the problem persists, try switching models or restarting Zed.
## Approach
- Added typed errors (`NoModelConfiguredError`, `MaxOutputTokensError`)
where previously raw strings were used, so they can be reliably downcast
- Extended `From<anyhow::Error> for ThreadError` to downcast
`LanguageModelCompletionError` variants before falling through to the
generic `Other` case
- Each variant has a dedicated `render_*` function with appropriate
buttons (retry icon, New Thread, or none)
- Telemetry events updated with specific `kind` labels for each new
variant
Release Notes:
- Improved error messages in the agent panel to show specific,
actionable copy instead of raw provider error strings
Self-Review Checklist:
- [ x] I've reviewed my own diff for quality, security, and reliability
- [n/a] Unsafe blocks (if any) have justifying comments
- [ x] The content is consistent with the [UI/UX
checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
- [n/a ] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable
Release Notes:
Improved error messages in the agent panel to show specific, actionable
copy instead of raw provider error strings
This commit is contained in:
1 parent
c14a48042a
commit
1f317acbf7
4 files changed
+346
-27
No files matched your search
@@ -36,6 +36,18 @@ use util::path_list::PathList;
|
||||
use util::{ResultExt, get_default_system_shell_preferring_bash, paths::PathStyle};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Returned when the model stops because it exhausted its output token budget.
|
||||
#[derive(Debug)]
|
||||
pub struct MaxOutputTokensError;
|
||||
|
||||
impl std::fmt::Display for MaxOutputTokensError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "output token limit reached")
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for MaxOutputTokensError {}
|
||||
|
||||
/// Key used in ACP ToolCall meta to store the tool's programmatic name.
|
||||
/// This is a workaround since ACP's ToolCall doesn't have a dedicated name field.
|
||||
pub const TOOL_NAME_META_KEY: &str = "tool_name";
|
||||
@@ -2272,17 +2284,15 @@ impl AcpThread {
|
||||
.is_some_and(|max| u.output_tokens >= max)
|
||||
});
|
||||
|
||||
let message = if exceeded_max_output_tokens {
|
||||
if exceeded_max_output_tokens {
|
||||
log::error!(
|
||||
"Max output tokens reached. Usage: {:?}",
|
||||
this.token_usage
|
||||
);
|
||||
"Maximum output tokens reached"
|
||||
} else {
|
||||
log::error!("Max tokens reached. Usage: {:?}", this.token_usage);
|
||||
"Maximum tokens reached"
|
||||
};
|
||||
return Err(anyhow!(message));
|
||||
}
|
||||
return Err(anyhow!(MaxOutputTokensError));
|
||||
}
|
||||
|
||||
let canceled = matches!(r.stop_reason, acp::StopReason::Cancelled);
|
||||
|
||||
@@ -64,6 +64,18 @@ const TOOL_CANCELED_MESSAGE: &str = "Tool canceled by user";
|
||||
pub const MAX_TOOL_NAME_LENGTH: usize = 64;
|
||||
pub const MAX_SUBAGENT_DEPTH: u8 = 1;
|
||||
|
||||
/// Returned when a turn is attempted but no language model has been selected.
|
||||
#[derive(Debug)]
|
||||
pub struct NoModelConfiguredError;
|
||||
|
||||
impl std::fmt::Display for NoModelConfiguredError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "no language model configured")
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for NoModelConfiguredError {}
|
||||
|
||||
/// Context passed to a subagent thread for lifecycle management
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct SubagentContext {
|
||||
@@ -1772,7 +1784,9 @@ impl Thread {
|
||||
&mut self,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Result<mpsc::UnboundedReceiver<Result<ThreadEvent>>> {
|
||||
let model = self.model().context("No language model configured")?;
|
||||
let model = self
|
||||
.model()
|
||||
.ok_or_else(|| anyhow!(NoModelConfiguredError))?;
|
||||
|
||||
log::info!("Thread::send called with model: {}", model.name().0);
|
||||
self.advance_prompt_id();
|
||||
@@ -1896,7 +1910,10 @@ impl Thread {
|
||||
// mid-turn changes (e.g. the user switches model, toggles tools,
|
||||
// or changes profile) take effect between tool-call rounds.
|
||||
let (model, request) = this.update(cx, |this, cx| {
|
||||
let model = this.model.clone().context("No language model configured")?;
|
||||
let model = this
|
||||
.model
|
||||
.clone()
|
||||
.ok_or_else(|| anyhow!(NoModelConfiguredError))?;
|
||||
this.refresh_turn_tools(cx);
|
||||
let request = this.build_completion_request(intent, cx)?;
|
||||
anyhow::Ok((model, request))
|
||||
@@ -2742,7 +2759,9 @@ impl Thread {
|
||||
completion_intent
|
||||
};
|
||||
|
||||
let model = self.model().context("No language model configured")?;
|
||||
let model = self
|
||||
.model()
|
||||
.ok_or_else(|| anyhow!(NoModelConfiguredError))?;
|
||||
let tools = if let Some(turn) = self.running_turn.as_ref() {
|
||||
turn.tools
|
||||
.iter()
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
use acp_thread::{
|
||||
AcpThread, AcpThreadEvent, AgentSessionInfo, AgentThreadEntry, AssistantMessage,
|
||||
AssistantMessageChunk, AuthRequired, LoadError, MentionUri, PermissionOptionChoice,
|
||||
PermissionOptions, PermissionPattern, RetryStatus, SelectedPermissionOutcome, ThreadStatus,
|
||||
ToolCall, ToolCallContent, ToolCallStatus, UserMessageId,
|
||||
AssistantMessageChunk, AuthRequired, LoadError, MaxOutputTokensError, MentionUri,
|
||||
PermissionOptionChoice, PermissionOptions, PermissionPattern, RetryStatus,
|
||||
SelectedPermissionOutcome, ThreadStatus, ToolCall, ToolCallContent, ToolCallStatus,
|
||||
UserMessageId,
|
||||
};
|
||||
use acp_thread::{AgentConnection, Plan};
|
||||
use action_log::{ActionLog, ActionLogTelemetry, DiffStats};
|
||||
use agent::{NativeAgentServer, NativeAgentSessionList, SharedThread, ThreadStore};
|
||||
use agent::{
|
||||
NativeAgentServer, NativeAgentSessionList, NoModelConfiguredError, SharedThread, ThreadStore,
|
||||
};
|
||||
use agent_client_protocol as acp;
|
||||
#[cfg(test)]
|
||||
use agent_servers::AgentServerDelegate;
|
||||
@@ -34,7 +37,7 @@ use gpui::{
|
||||
list, point, pulsating_between,
|
||||
};
|
||||
use language::Buffer;
|
||||
use language_model::LanguageModelRegistry;
|
||||
use language_model::{LanguageModelCompletionError, LanguageModelRegistry};
|
||||
use markdown::{Markdown, MarkdownElement, MarkdownFont, MarkdownStyle};
|
||||
use parking_lot::RwLock;
|
||||
use project::{AgentId, AgentServerStore, Project, ProjectEntryId};
|
||||
@@ -113,6 +116,31 @@ pub(crate) enum ThreadError {
|
||||
PaymentRequired,
|
||||
Refusal,
|
||||
AuthenticationRequired(SharedString),
|
||||
RateLimitExceeded {
|
||||
provider: SharedString,
|
||||
},
|
||||
ServerOverloaded {
|
||||
provider: SharedString,
|
||||
},
|
||||
PromptTooLarge,
|
||||
NoApiKey {
|
||||
provider: SharedString,
|
||||
},
|
||||
StreamError {
|
||||
provider: SharedString,
|
||||
},
|
||||
InvalidApiKey {
|
||||
provider: SharedString,
|
||||
},
|
||||
PermissionDenied {
|
||||
provider: SharedString,
|
||||
},
|
||||
RequestFailed,
|
||||
MaxOutputTokens,
|
||||
NoModelSelected,
|
||||
ApiError {
|
||||
provider: SharedString,
|
||||
},
|
||||
Other {
|
||||
message: SharedString,
|
||||
acp_error_code: Option<SharedString>,
|
||||
@@ -121,12 +149,57 @@ pub(crate) enum ThreadError {
|
||||
|
||||
impl From<anyhow::Error> for ThreadError {
|
||||
fn from(error: anyhow::Error) -> Self {
|
||||
if error.is::<language_model::PaymentRequiredError>() {
|
||||
if error.is::<MaxOutputTokensError>() {
|
||||
Self::MaxOutputTokens
|
||||
} else if error.is::<NoModelConfiguredError>() {
|
||||
Self::NoModelSelected
|
||||
} else if error.is::<language_model::PaymentRequiredError>() {
|
||||
Self::PaymentRequired
|
||||
} else if let Some(acp_error) = error.downcast_ref::<acp::Error>()
|
||||
&& acp_error.code == acp::ErrorCode::AuthRequired
|
||||
{
|
||||
Self::AuthenticationRequired(acp_error.message.clone().into())
|
||||
} else if let Some(lm_error) = error.downcast_ref::<LanguageModelCompletionError>() {
|
||||
use LanguageModelCompletionError::*;
|
||||
match lm_error {
|
||||
RateLimitExceeded { provider, .. } => Self::RateLimitExceeded {
|
||||
provider: provider.to_string().into(),
|
||||
},
|
||||
ServerOverloaded { provider, .. } | ApiInternalServerError { provider, .. } => {
|
||||
Self::ServerOverloaded {
|
||||
provider: provider.to_string().into(),
|
||||
}
|
||||
}
|
||||
PromptTooLarge { .. } => Self::PromptTooLarge,
|
||||
NoApiKey { provider } => Self::NoApiKey {
|
||||
provider: provider.to_string().into(),
|
||||
},
|
||||
StreamEndedUnexpectedly { provider }
|
||||
| ApiReadResponseError { provider, .. }
|
||||
| DeserializeResponse { provider, .. }
|
||||
| HttpSend { provider, .. } => Self::StreamError {
|
||||
provider: provider.to_string().into(),
|
||||
},
|
||||
AuthenticationError { provider, .. } => Self::InvalidApiKey {
|
||||
provider: provider.to_string().into(),
|
||||
},
|
||||
PermissionError { provider, .. } => Self::PermissionDenied {
|
||||
provider: provider.to_string().into(),
|
||||
},
|
||||
UpstreamProviderError { .. } => Self::RequestFailed,
|
||||
BadRequestFormat { provider, .. }
|
||||
| HttpResponseError { provider, .. }
|
||||
| ApiEndpointNotFound { provider } => Self::ApiError {
|
||||
provider: provider.to_string().into(),
|
||||
},
|
||||
_ => {
|
||||
let message: SharedString = format!("{:#}", error).into();
|
||||
Self::Other {
|
||||
message,
|
||||
acp_error_code: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let message: SharedString = format!("{:#}", error).into();
|
||||
|
||||
@@ -6625,19 +6698,11 @@ pub(crate) mod tests {
|
||||
conversation_view.read_with(cx, |conversation_view, cx| {
|
||||
let state = conversation_view.active_thread().unwrap();
|
||||
let error = &state.read(cx).thread_error;
|
||||
match error {
|
||||
Some(ThreadError::Other { message, .. }) => {
|
||||
assert!(
|
||||
message.contains("Maximum tokens reached"),
|
||||
"Expected 'Maximum tokens reached' error, got: {}",
|
||||
message
|
||||
);
|
||||
}
|
||||
other => panic!(
|
||||
"Expected ThreadError::Other with 'Maximum tokens reached', got: {:?}",
|
||||
other.is_some()
|
||||
),
|
||||
}
|
||||
assert!(
|
||||
matches!(error, Some(ThreadError::MaxOutputTokens)),
|
||||
"Expected ThreadError::MaxOutputTokens, got: {:?}",
|
||||
error.is_some()
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1259,6 +1259,62 @@ impl ThreadView {
|
||||
ThreadError::AuthenticationRequired(message) => {
|
||||
("authentication_required", None, message.clone())
|
||||
}
|
||||
ThreadError::RateLimitExceeded { provider } => (
|
||||
"rate_limit_exceeded",
|
||||
None,
|
||||
format!("{provider}'s rate limit was reached.").into(),
|
||||
),
|
||||
ThreadError::ServerOverloaded { provider } => (
|
||||
"server_overloaded",
|
||||
None,
|
||||
format!("{provider}'s servers are temporarily unavailable.").into(),
|
||||
),
|
||||
ThreadError::PromptTooLarge => (
|
||||
"prompt_too_large",
|
||||
None,
|
||||
"Context too large for the model's context window.".into(),
|
||||
),
|
||||
ThreadError::NoApiKey { provider } => (
|
||||
"no_api_key",
|
||||
None,
|
||||
format!("No API key configured for {provider}.").into(),
|
||||
),
|
||||
ThreadError::StreamError { provider } => (
|
||||
"stream_error",
|
||||
None,
|
||||
format!("Connection to {provider}'s API was interrupted.").into(),
|
||||
),
|
||||
ThreadError::InvalidApiKey { provider } => (
|
||||
"invalid_api_key",
|
||||
None,
|
||||
format!("Invalid or expired API key for {provider}.").into(),
|
||||
),
|
||||
ThreadError::PermissionDenied { provider } => (
|
||||
"permission_denied",
|
||||
None,
|
||||
format!(
|
||||
"{provider}'s API rejected the request due to insufficient permissions."
|
||||
)
|
||||
.into(),
|
||||
),
|
||||
ThreadError::RequestFailed => (
|
||||
"request_failed",
|
||||
None,
|
||||
"Request could not be completed after multiple attempts.".into(),
|
||||
),
|
||||
ThreadError::MaxOutputTokens => (
|
||||
"max_output_tokens",
|
||||
None,
|
||||
"Model reached its maximum output length.".into(),
|
||||
),
|
||||
ThreadError::NoModelSelected => {
|
||||
("no_model_selected", None, "No model selected.".into())
|
||||
}
|
||||
ThreadError::ApiError { provider } => (
|
||||
"api_error",
|
||||
None,
|
||||
format!("{provider}'s API returned an unexpected error.").into(),
|
||||
),
|
||||
ThreadError::Other {
|
||||
acp_error_code,
|
||||
message,
|
||||
@@ -8088,6 +8144,109 @@ impl ThreadView {
|
||||
self.render_authentication_required_error(error.clone(), cx)
|
||||
}
|
||||
ThreadError::PaymentRequired => self.render_payment_required_error(cx),
|
||||
ThreadError::RateLimitExceeded { provider } => self.render_error_callout(
|
||||
"Rate Limit Reached",
|
||||
format!(
|
||||
"{provider}'s rate limit was reached. Zed will retry automatically. \
|
||||
You can also wait a moment and try again."
|
||||
)
|
||||
.into(),
|
||||
true,
|
||||
true,
|
||||
cx,
|
||||
),
|
||||
ThreadError::ServerOverloaded { provider } => self.render_error_callout(
|
||||
"Provider Unavailable",
|
||||
format!(
|
||||
"{provider}'s servers are temporarily unavailable. Zed will retry \
|
||||
automatically. If the problem persists, check the provider's status page."
|
||||
)
|
||||
.into(),
|
||||
true,
|
||||
true,
|
||||
cx,
|
||||
),
|
||||
ThreadError::PromptTooLarge => self.render_prompt_too_large_error(cx),
|
||||
ThreadError::NoApiKey { provider } => self.render_error_callout(
|
||||
"API Key Missing",
|
||||
format!(
|
||||
"No API key is configured for {provider}. \
|
||||
Add your key via the Agent Panel settings to continue."
|
||||
)
|
||||
.into(),
|
||||
false,
|
||||
true,
|
||||
cx,
|
||||
),
|
||||
ThreadError::StreamError { provider } => self.render_error_callout(
|
||||
"Connection Interrupted",
|
||||
format!(
|
||||
"The connection to {provider}'s API was interrupted. Zed will retry \
|
||||
automatically. If the problem persists, check your network connection."
|
||||
)
|
||||
.into(),
|
||||
true,
|
||||
true,
|
||||
cx,
|
||||
),
|
||||
ThreadError::InvalidApiKey { provider } => self.render_error_callout(
|
||||
"Invalid API Key",
|
||||
format!(
|
||||
"The API key for {provider} is invalid or has expired. \
|
||||
Update your key via the Agent Panel settings to continue."
|
||||
)
|
||||
.into(),
|
||||
false,
|
||||
false,
|
||||
cx,
|
||||
),
|
||||
ThreadError::PermissionDenied { provider } => self.render_error_callout(
|
||||
"Permission Denied",
|
||||
format!(
|
||||
"{provider}'s API rejected the request due to insufficient permissions. \
|
||||
Check that your API key has access to this model."
|
||||
)
|
||||
.into(),
|
||||
false,
|
||||
false,
|
||||
cx,
|
||||
),
|
||||
ThreadError::RequestFailed => self.render_error_callout(
|
||||
"Request Failed",
|
||||
"The request could not be completed after multiple attempts. \
|
||||
Try again in a moment."
|
||||
.into(),
|
||||
true,
|
||||
false,
|
||||
cx,
|
||||
),
|
||||
ThreadError::MaxOutputTokens => self.render_error_callout(
|
||||
"Output Limit Reached",
|
||||
"The model stopped because it reached its maximum output length. \
|
||||
You can ask it to continue where it left off."
|
||||
.into(),
|
||||
false,
|
||||
false,
|
||||
cx,
|
||||
),
|
||||
ThreadError::NoModelSelected => self.render_error_callout(
|
||||
"No Model Selected",
|
||||
"Select a model from the model picker below to get started.".into(),
|
||||
false,
|
||||
false,
|
||||
cx,
|
||||
),
|
||||
ThreadError::ApiError { provider } => self.render_error_callout(
|
||||
"API Error",
|
||||
format!(
|
||||
"{provider}'s API returned an unexpected error. \
|
||||
If the problem persists, try switching models or restarting Zed."
|
||||
)
|
||||
.into(),
|
||||
true,
|
||||
true,
|
||||
cx,
|
||||
),
|
||||
};
|
||||
|
||||
Some(div().child(content))
|
||||
@@ -8148,6 +8307,72 @@ impl ThreadView {
|
||||
.dismiss_action(self.dismiss_error_button(cx))
|
||||
}
|
||||
|
||||
fn render_error_callout(
|
||||
&self,
|
||||
title: &'static str,
|
||||
message: SharedString,
|
||||
show_retry: bool,
|
||||
show_copy: bool,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Callout {
|
||||
let can_resume = show_retry && self.thread.read(cx).can_retry(cx);
|
||||
let show_actions = can_resume || show_copy;
|
||||
|
||||
Callout::new()
|
||||
.severity(Severity::Error)
|
||||
.icon(IconName::XCircle)
|
||||
.title(title)
|
||||
.description(message.clone())
|
||||
.when(show_actions, |callout| {
|
||||
callout.actions_slot(
|
||||
h_flex()
|
||||
.gap_0p5()
|
||||
.when(can_resume, |this| this.child(self.retry_button(cx)))
|
||||
.when(show_copy, |this| {
|
||||
this.child(self.create_copy_button(message.clone()))
|
||||
}),
|
||||
)
|
||||
})
|
||||
.dismiss_action(self.dismiss_error_button(cx))
|
||||
}
|
||||
|
||||
fn render_prompt_too_large_error(&self, cx: &mut Context<Self>) -> Callout {
|
||||
const MESSAGE: &str = "This conversation is too long for the model's context window. \
|
||||
Start a new thread or remove some attached files to continue.";
|
||||
|
||||
Callout::new()
|
||||
.severity(Severity::Error)
|
||||
.icon(IconName::XCircle)
|
||||
.title("Context Too Large")
|
||||
.description(MESSAGE)
|
||||
.actions_slot(
|
||||
h_flex()
|
||||
.gap_0p5()
|
||||
.child(self.new_thread_button(cx))
|
||||
.child(self.create_copy_button(MESSAGE)),
|
||||
)
|
||||
.dismiss_action(self.dismiss_error_button(cx))
|
||||
}
|
||||
|
||||
fn retry_button(&self, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
Button::new("retry", "Retry")
|
||||
.label_size(LabelSize::Small)
|
||||
.style(ButtonStyle::Filled)
|
||||
.on_click(cx.listener(|this, _, _, cx| {
|
||||
this.retry_generation(cx);
|
||||
}))
|
||||
}
|
||||
|
||||
fn new_thread_button(&self, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
Button::new("new_thread", "New Thread")
|
||||
.label_size(LabelSize::Small)
|
||||
.style(ButtonStyle::Filled)
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
this.clear_thread_error(cx);
|
||||
window.dispatch_action(NewThread.boxed_clone(), cx);
|
||||
}))
|
||||
}
|
||||
|
||||
fn upgrade_button(&self, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
Button::new("upgrade", "Upgrade")
|
||||
.label_size(LabelSize::Small)
|
||||
|
||||
Reference in new issue
Block a user