refactor(agent): extract claude driver into hive-claude crate
This commit is contained in:
parent
e35acca814
commit
a3b66241d1
13 changed files with 907 additions and 442 deletions
106
hive-claude/src/classify.rs
Normal file
106
hive-claude/src/classify.rs
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
//! 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 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) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// Trust a rate-limit hit on a JSON `error` event's serialized form.
|
||||
pub(crate) fn scan_stdout_json(&self, event: &serde_json::Value) {
|
||||
if event.get("type").and_then(|t| t.as_str()) == Some("error")
|
||||
&& RATE_LIMIT_MARKERS
|
||||
.iter()
|
||||
.any(|m| event.to_string().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
|
||||
/// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue