fix(agents): classify provider failures in the tkmind Finish reason
Unused Dependencies / machete (push) Has been cancelled
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:
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user