fix(observability): classify web-channel empty-provider-response re-report as expected (Sentry TAURI-RUST-4Z1) (#2808)

Co-authored-by: M3gA-Mind <megamind@mahadao.com>
This commit is contained in:
CodeGhost21
2026-05-29 00:48:16 +05:30
committed by GitHub
co-authored by M3gA-Mind
parent 3cc25e4391
commit 72cdfe9e6a
+145
View File
@@ -212,6 +212,36 @@ pub enum ExpectedErrorKind {
/// - `"kv key cannot contain personal identifiers"`
/// - `"kv namespace/key cannot contain personal identifiers"`
MemoryStorePiiRejection,
/// The provider/model completed a turn with a completely empty body
/// (`text_chars=0 thinking_chars=0 tool_calls=0`), so the agent harness
/// bailed with the user-facing `"The model returned an empty response.
/// Please try again."` string
/// (`agent::harness::session::turn`). This is a model/user-config
/// condition — a quirky or broken local fine-tune that returns nothing,
/// a provider that dropped the stream — not a code bug. The UI already
/// surfaces the typed error and the user can retry; Sentry has no
/// remediation path.
///
/// `agent::run_single` already suppresses the **agent-layer** Sentry
/// event for this condition via the typed
/// `AgentError::EmptyProviderResponse` + `AgentError::skips_sentry()`
/// (PR #2790, TAURI-RUST-4JX). But `channels::providers::web::
/// run_chat_task` **re-reports** the same failure under
/// `domain=web_channel operation=run_chat_task` after the typed error
/// has been flattened to a `String` at the native-bus boundary — so the
/// typed suppression can't reach it and it escapes as a fresh Sentry
/// event (TAURI-RUST-4Z1). This string classifier closes that second
/// emit site, mirroring how `MaxIterationsExceeded` is handled at both
/// layers. See [`is_empty_provider_response_message`].
///
/// Although the immediate trigger is the `web_channel.run_chat_task`
/// re-report, this classifier runs in the central `expected_error_kind`
/// dispatcher, so any caller of `report_error_or_expected`
/// (`channels/runtime/dispatch.rs`, `channels/runtime/supervision.rs`,
/// any future channel provider) whose error chain contains `"model
/// returned an empty response"` is also demoted — no per-channel typed
/// suppression needed.
EmptyProviderResponse,
}
pub fn expected_error_kind(message: &str) -> Option<ExpectedErrorKind> {
@@ -304,6 +334,15 @@ pub fn expected_error_kind(message: &str) -> Option<ExpectedErrorKind> {
if is_memory_store_pii_rejection(&lower) {
return Some(ExpectedErrorKind::MemoryStorePiiRejection);
}
// Empty-provider-response re-report from the web-channel layer. Runs
// last so an earlier, more specific matcher always wins. See the
// variant doc-comment and [`is_empty_provider_response_message`] for
// the two-emit-site rationale (agent layer is handled by the typed
// `AgentError::skips_sentry()` in PR #2790; this covers the
// web_channel re-report where the type was flattened to a String).
if is_empty_provider_response_message(&lower) {
return Some(ExpectedErrorKind::EmptyProviderResponse);
}
None
}
@@ -899,6 +938,34 @@ fn is_memory_store_pii_rejection(lower: &str) -> bool {
lower.contains("cannot contain personal identifiers")
}
/// Detect the agent harness's empty-provider-response bail.
///
/// Anchored on the literal user-facing string emitted at
/// `agent::harness::session::turn` —
/// `"The model returned an empty response. Please try again."` — which is
/// preserved verbatim as the provider/model returns a body with
/// `text_chars=0 thinking_chars=0 tool_calls=0`.
///
/// This catches the **web-channel re-report** (Sentry TAURI-RUST-4Z1):
/// `channels::providers::web::run_chat_task` wraps the failure as
/// `"run_chat_task failed client_id=… error=The model returned an empty
/// response. Please try again."` and routes it through
/// `report_error_or_expected` after the typed
/// `AgentError::EmptyProviderResponse` was flattened to a `String` at the
/// native-bus boundary (so the agent-layer `skips_sentry()` suppression
/// from PR #2790 can't reach it).
///
/// Anchored on `"model returned an empty response"` (not the looser
/// `"empty response"`) so the sibling phrases stay actionable:
/// `"summarizer returned empty response, falling through"`
/// (`payload_summarizer`) and `"provider returned an empty response;
/// returning empty extraction"` (`subagent_runner::extract_tool`) are
/// internal fall-through paths with different wording and are NOT
/// silenced.
fn is_empty_provider_response_message(lower: &str) -> bool {
lower.contains("model returned an empty response")
}
/// Capture an error to Sentry with structured tags.
///
/// `domain` and `operation` are required and become tags `domain:<…>` and
@@ -1175,6 +1242,24 @@ fn report_expected_message(kind: ExpectedErrorKind, message: &str, domain: &str,
"[observability] {domain}.{operation} skipped expected memory-store PII rejection"
);
}
ExpectedErrorKind::EmptyProviderResponse => {
// Model/user-config condition — the provider returned a
// completely empty body and the agent harness bailed with the
// user-facing retry message. The agent layer already suppresses
// this via the typed `AgentError::skips_sentry()` (PR #2790);
// this arm covers the `web_channel.run_chat_task` re-report
// where the type was flattened to a String. Demote to `warn!`
// (breadcrumb only) — same tier as `MaxIterationsExceeded`,
// the other deterministic agent-state outcome surfaced to the
// user via the `chat_error` event.
tracing::warn!(
domain = domain,
operation = operation,
kind = "empty_provider_response",
error = %message,
"[observability] {domain}.{operation} skipped expected empty-provider-response error: {message}"
);
}
}
}
@@ -1891,6 +1976,66 @@ mod tests {
}
}
// ── EmptyProviderResponse (TAURI-RUST-4Z1) ─────────────────────────────
#[test]
fn classifies_empty_provider_response_web_channel_rereport() {
// TAURI-RUST-4Z1: the web-channel re-report of the agent harness's
// empty-provider-response bail. `run_chat_task` wraps the flattened
// string and routes it through `report_error_or_expected` — the
// agent-layer typed suppression (PR #2790) can't reach it, so this
// string classifier must.
assert_eq!(
expected_error_kind(
"run_chat_task failed client_id=l1uxaLd20_1mAdhp \
thread_id=thread-8f03e7f7-3477-42cd-9283-f0bacd4bfbca \
request_id=a73716a3-a85a-4045-984b-315772c5b3b8 \
error=The model returned an empty response. Please try again."
),
Some(ExpectedErrorKind::EmptyProviderResponse)
);
// Bare user-facing string (the verbatim `turn.rs` emission), in case
// a different call site re-reports it without the run_chat_task wrap.
assert_eq!(
expected_error_kind("The model returned an empty response. Please try again."),
Some(ExpectedErrorKind::EmptyProviderResponse)
);
}
#[test]
fn does_not_classify_unrelated_empty_response_phrases() {
// Polarity contract: the anchor is `"model returned an empty
// response"`, NOT the looser `"empty response"`. The sibling paths
// below use different subjects or phrasings and are not user-facing
// failures — they must stay out of this bucket so a real regression
// in those paths still reaches Sentry.
for raw in [
// payload_summarizer.rs:261 — internal fall-through, not a failure.
"[payload_summarizer] summarizer returned empty response, falling through",
// subagent_runner/extract_tool.rs:379 — graceful empty extraction.
"[extract_from_result] provider returned an empty response; returning empty extraction",
// Generic mention without the model-subject anchor.
"warning: empty response body from health probe",
// channels/bus.rs:185 — channel-inbound graceful fallback (routes
// through report_error_or_expected; subject is "agent", not "model").
"[channel-inbound] agent returned empty response — finalizing draft with fallback",
// memory/query/walk.rs:292 — debug-level memory walk, not a failure.
"[memory_tree_walk] turn=3 LLM gave up (empty response)",
// learning/reflection.rs:576 — reflection skip, not a failure.
"[learning] reflection skipped (empty response — gate off or local AI unavailable)",
// agent/harness/session/turn.rs:811 — "provider returned an empty
// final response" uses subject "provider", not "model"; must not match.
"[agent_loop] provider returned an empty final response (i=2, no text, no tool calls)",
] {
assert_eq!(
expected_error_kind(raw),
None,
"must NOT classify as EmptyProviderResponse: {raw}"
);
}
}
#[test]
fn classifies_memory_store_pii_rejection_errors() {
// TAURI-RUST-54T: ~915 events from one user where the PII guard