diff --git a/crates/goose/src/agents/agent.rs b/crates/goose/src/agents/agent.rs index ecee17267..551d30dab 100644 --- a/crates/goose/src/agents/agent.rs +++ b/crates/goose/src/agents/agent.rs @@ -93,6 +93,9 @@ const COMPACTION_PROGRESS_TEXT: &str = "goose is compacting the conversation..." const MAX_EMPTY_TURN_RETRIES: u32 = 3; const EMPTY_TURN_MESSAGE: &str = "The model returned an empty response. Please resend your message to continue."; +const PROVIDER_REFUSED_MESSAGE: &str = "The provider refused this request."; +const PROVIDER_ERROR_PREFIX: &str = "Ran into this error:"; +const NETWORK_ERROR_SUFFIX: &str = "Please resend your message to try again."; /// Classifies the assistant notices the loop emits when it stops on a guard, /// so HTTP clients can report why a reply ended instead of a generic "stop". @@ -100,9 +103,26 @@ pub fn loop_stop_reason(message: &Message) -> Option<&'static str> { if message.role != rmcp::model::Role::Assistant { return None; } + if message.error_kind().is_some() { + return Some("provider_error"); + } + let credits_exhausted = message.content.iter().any(|content| { + content + .as_system_notification() + .is_some_and(|notification| { + notification.notification_type == SystemNotificationType::CreditsExhausted + }) + }); + if credits_exhausted { + return Some("credits_exhausted"); + } match message.as_concat_text().trim() { text if text == MAX_TURNS_MESSAGE => Some("max_turns"), text if text == EMPTY_TURN_MESSAGE => Some("empty_response"), + text if text.starts_with(PROVIDER_REFUSED_MESSAGE) => Some("provider_refused"), + text if text.starts_with(PROVIDER_ERROR_PREFIX) || text.ends_with(NETWORK_ERROR_SUFFIX) => { + Some("provider_error") + } _ => None, } } @@ -3280,7 +3300,7 @@ impl Agent { let category = category.as_deref().map(|c| format!("\n\nCategory: {c}")).unwrap_or_default(); yield AgentEvent::Message(Message::assistant().with_text(format!( - "The provider refused this request.\n\n{details}{category}\n\nPlease start a new session to continue — resending this conversation is likely to be refused again." + "{PROVIDER_REFUSED_MESSAGE}\n\n{details}{category}\n\nPlease start a new session to continue — resending this conversation is likely to be refused again." ))); // A refusal is terminal: skip goal/grind nudges and // recipe retry_config, which would resend the same @@ -3310,7 +3330,7 @@ impl Agent { error!("Error: {}", provider_err); yield AgentEvent::Message( Message::assistant().with_text( - format!("{provider_err}\n\nPlease resend your message to try again.") + format!("{provider_err}\n\n{NETWORK_ERROR_SUFFIX}") ) ); break; @@ -3320,9 +3340,11 @@ impl Agent { #[cfg(feature = "telemetry")] crate::posthog::emit_error(provider_err.telemetry_type(), &provider_err.to_string()); error!("Error: {}", provider_err); + // Portal matches this text (session-reply-wait providerReplyError) to + // classify and recover poisoned sessions; keep it as plain text. yield AgentEvent::Message( Message::assistant().with_text( - format!("Ran into this error: {provider_err}.\n\nPlease retry if you think this is a transient or recoverable error.") + format!("{PROVIDER_ERROR_PREFIX} {provider_err}.\n\nPlease retry if you think this is a transient or recoverable error.") ) ); break; @@ -4268,6 +4290,47 @@ mod tests { ); } + #[test] + fn loop_stop_reason_classifies_provider_failures() { + let auth = ProviderError::Authentication("Invalid API key".to_string()); + assert_eq!( + loop_stop_reason(&Message::from_provider_error(&auth)), + Some("provider_error") + ); + assert_eq!( + loop_stop_reason(&Message::assistant().with_text(format!( + "{PROVIDER_ERROR_PREFIX} boom.\n\nPlease retry if you think this is a transient or recoverable error." + ))), + Some("provider_error") + ); + assert_eq!( + loop_stop_reason( + &Message::assistant().with_text(format!("timeout\n\n{NETWORK_ERROR_SUFFIX}")) + ), + Some("provider_error") + ); + assert_eq!( + loop_stop_reason( + &Message::assistant().with_text(format!("{PROVIDER_REFUSED_MESSAGE}\n\nunsafe")) + ), + Some("provider_refused") + ); + assert_eq!( + loop_stop_reason(&Message::assistant().with_system_notification_with_data( + SystemNotificationType::CreditsExhausted, + "add credits", + serde_json::json!({}), + )), + Some("credits_exhausted") + ); + assert_eq!( + loop_stop_reason( + &Message::assistant().with_text("Ran into this error trying to compact") + ), + None + ); + } + fn persisted_builtin(name: &str) -> ExtensionConfig { ExtensionConfig::Builtin { name: name.to_string(),