fix(agents): classify provider failures in the tkmind Finish reason
Unused Dependencies / machete (push) Has been cancelled

Provider errors ended the reply with Finish{reason: stop}. loop_stop_reason
now reports provider_error, provider_refused and credits_exhausted. Classic
error messages keep their plain-text wording because Portal matches it to
recover poisoned sessions.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-09-23 15:48:14 +08:00
parent 4aea5abf29
commit 508df26f5f
+66 -3
View File
@@ -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(),