Detailed changes
@@ -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)