Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
145 changes: 145 additions & 0 deletions src/core/observability.rs
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,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> {
Expand Down Expand Up @@ -290,6 +320,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
}

Expand Down Expand Up @@ -885,6 +924,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
Expand Down Expand Up @@ -1161,6 +1228,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}"
);
}
}
}

Expand Down Expand Up @@ -1877,6 +1962,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
Expand Down
Loading