fix(hive-claude): don't scan model-authored assistant/user content for CLI failure markers
This commit is contained in:
parent
25540755f4
commit
50d801b6c9
2 changed files with 90 additions and 28 deletions
|
|
@ -50,10 +50,42 @@ pub(crate) struct Sentinels {
|
|||
}
|
||||
|
||||
impl Sentinels {
|
||||
/// Scan a raw line (stdout or stderr) for the always-on markers:
|
||||
/// prompt-too-long, auth-failed, session-not-found. Rate-limit is handled
|
||||
/// separately because on stdout it must only fire on JSON `error` events.
|
||||
pub(crate) fn scan_line(&self, line: &str) {
|
||||
/// 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;
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
|
@ -65,26 +97,6 @@ impl Sentinels {
|
|||
}
|
||||
}
|
||||
|
||||
/// Trust a rate-limit hit only on a JSON `error` event (so a model
|
||||
/// *discussing* a rate limit in prose can't trigger it). The `type` gate
|
||||
/// needs the parsed `event`; the marker match runs on `raw`, the original
|
||||
/// line — the same bytes, so we don't re-serialize the value.
|
||||
pub(crate) fn scan_stdout_json(&self, event: &serde_json::Value, raw: &str) {
|
||||
if event.get("type").and_then(|t| t.as_str()) == Some("error")
|
||||
&& RATE_LIMIT_MARKERS.iter().any(|m| raw.contains(m))
|
||||
{
|
||||
self.rate_limited.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
/// Trust a rate-limit hit on raw text (non-JSON stdout, or any stderr) —
|
||||
/// these are CLI messages, not conversation content.
|
||||
pub(crate) fn scan_rate_limit_text(&self, line: &str) {
|
||||
if RATE_LIMIT_MARKERS.iter().any(|m| line.contains(m)) {
|
||||
self.rate_limited.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
|
||||
|
|
@ -105,3 +117,51 @@ impl Sentinels {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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 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)));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -152,12 +152,14 @@ fn build_command(program: &str, config: &Config, attach: &Attach) -> Command {
|
|||
async fn pump_stdout(stdout: ChildStdout, sink: &impl Sink, sentinels: &Sentinels) {
|
||||
let mut lines = BufReader::new(stdout).lines();
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
sentinels.scan_line(&line);
|
||||
if let Ok(event) = serde_json::from_str::<serde_json::Value>(&line) {
|
||||
// JSON stdout: classify with the model-content gate so an
|
||||
// `assistant`/`user` message quoting a marker can't trip it.
|
||||
sentinels.scan_stdout_json(&event, &line);
|
||||
sink.on_event(&event);
|
||||
} else {
|
||||
sentinels.scan_rate_limit_text(&line);
|
||||
// Non-JSON stdout is CLI text, not conversation — trust all markers.
|
||||
sentinels.scan_cli_line(&line);
|
||||
sink.on_stdout_line(&line);
|
||||
}
|
||||
}
|
||||
|
|
@ -170,8 +172,8 @@ async fn pump_stderr(stderr: ChildStderr, sink: &impl Sink, sentinels: &Sentinel
|
|||
let mut lines = BufReader::new(stderr).lines();
|
||||
let mut tail: VecDeque<String> = VecDeque::with_capacity(STDERR_TAIL_LINES);
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
sentinels.scan_line(&line);
|
||||
sentinels.scan_rate_limit_text(&line);
|
||||
// stderr is always CLI output — trust all markers.
|
||||
sentinels.scan_cli_line(&line);
|
||||
sink.on_stderr_line(&line);
|
||||
if tail.len() >= STDERR_TAIL_LINES {
|
||||
tail.pop_front();
|
||||
|
|
|
|||
Loading…
Reference in a new issue