hyperhive/hive-claude/src/classify.rs

219 lines
9.5 KiB
Rust

//! Sentinel detection: mapping claude-code CLI output onto [`crate::Error`]
//! variants.
//!
//! These marker strings are claude-code CLI knowledge, not app knowledge. They
//! are empirically stable across CLI versions; if one drifts the run degrades
//! gracefully (a clean turn, or a hard [`crate::Error::Exit`] on a non-zero
//! exit) rather than misbehaving.
use std::sync::atomic::{AtomicBool, Ordering};
/// Emitted when the prompt/context exceeds the model's window.
const PROMPT_TOO_LONG_MARKER: &str = "Prompt is too long";
/// Substrings indicating the API refused for rate-limit / usage-cap / credit
/// reasons. On stdout these are only trusted inside a JSON `error` event (see
/// [`Sentinels::scan_stdout_json`] / [`Sentinels::scan_rate_limit_text`]) so a
/// model *discussing* a rate limit in prose can't trigger a false positive.
const RATE_LIMIT_MARKERS: [&str; 5] = [
"rate_limit_error",
"overloaded_error",
"Credit balance is too low",
"Usage limit reached",
"Request rate limit exceeded",
];
/// Substrings indicating the API rejected the request as unauthenticated (401)
/// — an expired/revoked OAuth session. Sourced from claude-code's `api_retry`
/// JSON events and its human-readable give-up line.
const AUTH_FAIL_MARKERS: [&str; 3] = [
"\"error\":\"authentication_failed\"",
"\"error_status\":401",
"Failed to authenticate. API Error: 401",
];
/// Substrings indicating `--resume` could not resolve its target: no session
/// with the given title, or no conversation with the given id.
const SESSION_NOT_FOUND_MARKERS: [&str; 2] = [
"does not match any session title",
"No conversation found with session ID",
];
/// Shared, lock-free sentinel flags accumulated while both output streams are
/// pumped concurrently. Read once after the child exits.
#[derive(Default)]
pub(crate) struct Sentinels {
prompt_too_long: AtomicBool,
rate_limited: AtomicBool,
auth_failed: AtomicBool,
session_not_found: AtomicBool,
}
impl Sentinels {
/// Scan a CLI-authored line — stderr, or a non-JSON stdout line — for every
/// marker. These bytes are always claude-code's own output, never model
/// conversation, so all markers are trusted.
pub(crate) fn scan_cli_line(&self, line: &str) {
self.scan_failure_markers(line);
if RATE_LIMIT_MARKERS.iter().any(|m| line.contains(m)) {
self.rate_limited.store(true, Ordering::Relaxed);
}
}
/// Scan a parsed stdout JSON event. **Skips model-authored `assistant` /
/// `user` message events**, whose serialized content can quote any marker
/// verbatim (an agent discussing this very code, say) — a false positive
/// that would otherwise trip a needless compact/retry or a spurious
/// auth/session error. Every real signal here is emitted *instead of* a
/// model turn (the API rejected the prompt, the auth failed, or `--resume`
/// missed before any inference), so it can only appear on a control event
/// (`error` / `result` / `system`) or as raw non-JSON text — never inside
/// an assistant/user message. This holds whatever exact shape claude-code
/// uses for the message, so the gate can't suppress a genuine signal.
pub(crate) fn scan_stdout_json(&self, event: &serde_json::Value, raw: &str) {
let ty = event.get("type").and_then(|t| t.as_str());
if matches!(ty, Some("assistant" | "user")) {
return;
}
// The terminal `result` event carries the model's final answer in its
// `result` field — model-authored text, same trust level as an
// assistant message. A marker quoted there (e.g. the model explaining
// *this* code, or echoing an error string back) would falsely trip a
// sentinel and needlessly kill/downgrade the turn. So for a `result`
// event, scan the control fields (`subtype` / `error` / `is_error`)
// but scrub the model-authored `result` field first; a genuine
// prompt-too-long/auth/session signal lives in those control fields
// (it's emitted *instead of* a successful model answer), never in the
// `result` text. Other control events (`error` / `system`) are
// claude-authored end to end, so they scan raw.
if ty == Some("result") {
let mut scrubbed = event.clone();
if let Some(obj) = scrubbed.as_object_mut() {
obj.remove("result");
}
self.scan_failure_markers(&scrubbed.to_string());
} else {
self.scan_failure_markers(raw);
}
// Rate-limit stays scoped to `error` events (unchanged): the only
// control event that carries a rate-limit marker.
if ty == Some("error") && RATE_LIMIT_MARKERS.iter().any(|m| raw.contains(m)) {
self.rate_limited.store(true, Ordering::Relaxed);
}
}
/// The prompt-too-long / auth-failed / session-not-found markers. Callers
/// gate *where* this runs (see `scan_cli_line` / `scan_stdout_json`).
fn scan_failure_markers(&self, line: &str) {
if line.contains(PROMPT_TOO_LONG_MARKER) {
self.prompt_too_long.store(true, Ordering::Relaxed);
}
if AUTH_FAIL_MARKERS.iter().any(|m| line.contains(m)) {
self.auth_failed.store(true, Ordering::Relaxed);
}
if SESSION_NOT_FOUND_MARKERS.iter().any(|m| line.contains(m)) {
self.session_not_found.store(true, Ordering::Relaxed);
}
}
/// The recognized-sentinel error, if any fired — `None` means no sentinel
/// (so the run either completed or failed hard on its exit code). The
/// sentinels keep a fixed priority (too-long > rate > auth); a
/// session-not-found can only arise on a resume that made no model call,
/// so it never coincides with the others.
pub(crate) fn soft_error(&self) -> Option<crate::Error> {
use crate::Error;
if self.prompt_too_long.load(Ordering::Relaxed) {
Some(Error::PromptTooLong)
} else if self.rate_limited.load(Ordering::Relaxed) {
Some(Error::RateLimited)
} else if self.auth_failed.load(Ordering::Relaxed) {
Some(Error::AuthFailed)
} else if self.session_not_found.load(Ordering::Relaxed) {
Some(Error::SessionNotFound)
} else {
None
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Error;
fn json(raw: &str) -> serde_json::Value {
serde_json::from_str(raw).unwrap()
}
#[test]
fn assistant_content_quoting_marker_is_ignored() {
// An agent discussing this code emits the marker verbatim in an
// assistant message — must NOT trip a sentinel.
let s = Sentinels::default();
let raw = r#"{"type":"assistant","message":{"content":[{"type":"text","text":"the CLI prints Prompt is too long on overflow"}]}}"#;
s.scan_stdout_json(&json(raw), raw);
assert!(s.soft_error().is_none());
}
#[test]
fn control_event_marker_is_detected() {
let s = Sentinels::default();
let raw = r#"{"type":"result","subtype":"error","error":"Prompt is too long"}"#;
s.scan_stdout_json(&json(raw), raw);
assert!(matches!(s.soft_error(), Some(Error::PromptTooLong)));
}
#[test]
fn result_field_model_text_quoting_marker_is_ignored() {
// The terminal `result` event's `result` field is the model's final
// answer. A marker quoted there (the model discussing this very code,
// or echoing an API error string) must NOT trip a sentinel — that was
// a turn-kill DoS. Covers prompt-too-long, auth, and session markers.
for marker in [
"Prompt is too long",
"Failed to authenticate. API Error: 401",
"does not match any session title",
] {
let s = Sentinels::default();
let raw = format!(
r#"{{"type":"result","subtype":"success","is_error":false,"result":"the harness scans stdout for {marker} — see classify.rs"}}"#
);
s.scan_stdout_json(&json(&raw), &raw);
assert!(
s.soft_error().is_none(),
"marker {marker:?} in the model-authored result field must be ignored"
);
}
}
#[test]
fn result_control_field_still_trips_even_with_clean_result_text() {
// Scrubbing `result` must not blind us to a genuine signal in a
// control field of the same event.
let s = Sentinels::default();
let raw = r#"{"type":"result","subtype":"error","is_error":true,"error":"Failed to authenticate. API Error: 401","result":"ok"}"#;
s.scan_stdout_json(&json(raw), raw);
assert!(matches!(s.soft_error(), Some(Error::AuthFailed)));
}
#[test]
fn raw_non_json_marker_is_detected() {
let s = Sentinels::default();
s.scan_cli_line("API Error: Prompt is too long");
assert!(matches!(s.soft_error(), Some(Error::PromptTooLong)));
}
#[test]
fn rate_limit_still_only_on_error_event() {
// A non-error control event mentioning the marker must not trip it.
let s = Sentinels::default();
let raw = r#"{"type":"result","summary":"we hit a rate_limit_error earlier"}"#;
s.scan_stdout_json(&json(raw), raw);
assert!(s.soft_error().is_none());
// A genuine error event does.
let raw2 = r#"{"type":"error","error":{"type":"rate_limit_error"}}"#;
s.scan_stdout_json(&json(raw2), raw2);
assert!(matches!(s.soft_error(), Some(Error::RateLimited)));
}
}