use super::RecoveryAction;
use std::collections::HashMap;
#[derive(Debug, Clone)]
pub enum RecoveryDirective {
Action(RecoveryAction),
CompressContext { target_tokens: usize },
ReloadCredentials,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum FailureKind {
LlmUnreachable,
AuthError,
RateLimited,
ToolTimeout,
ToolError,
ContextOverflow,
Deadlock,
ConfigInvalid,
DiskFull,
Unknown,
}
pub fn classify(error_message: &str) -> FailureKind {
let msg = error_message.to_lowercase();
let llm_unreachable_markers = [
"connection refused",
"connect",
"timed out connecting",
"dns",
"unreachable",
"tcp",
];
for marker in &llm_unreachable_markers {
if msg.contains(marker) {
return FailureKind::LlmUnreachable;
}
}
let auth_markers = [
"401",
"403",
"unauthorized",
"invalid api key",
"authentication",
];
for marker in &auth_markers {
if msg.contains(marker) {
return FailureKind::AuthError;
}
}
let rate_markers = ["429", "rate limit", "too many requests"];
for marker in &rate_markers {
if msg.contains(marker) {
return FailureKind::RateLimited;
}
}
let tool_timeout_markers = ["tool", "timeout", "timed out"];
for marker in &tool_timeout_markers {
if msg.contains(marker) {
return FailureKind::ToolTimeout;
}
}
let context_markers = [
"context",
"token limit",
"maximum context",
"too many tokens",
];
for marker in &context_markers {
if msg.contains(marker) {
return FailureKind::ContextOverflow;
}
}
let config_markers = ["config", "invalid configuration", "missing field"];
for marker in &config_markers {
if msg.contains(marker) {
return FailureKind::ConfigInvalid;
}
}
let disk_markers = ["no space", "disk full", "enospc"];
for marker in &disk_markers {
if msg.contains(marker) {
return FailureKind::DiskFull;
}
}
FailureKind::Unknown
}
#[derive(Debug, Clone)]
pub struct FailureSignal {
pub kind: FailureKind,
pub message: String,
}
impl FailureSignal {
pub fn from_message(message: &str) -> Self {
Self {
kind: classify(message),
message: message.to_string(),
}
}
}
#[derive(Debug, Clone)]
pub enum ResolutionOutcome {
Resolved(RecoveryDirective),
Escalate,
Unresolvable,
}
pub struct RecoveryContext<'a> {
pub config: &'a crate::config::Config,
pub message: &'a str,
}
pub trait Resolution: Send + Sync {
fn name(&self) -> &'static str;
fn offline_capable(&self) -> bool;
fn resolve(&self, ctx: &RecoveryContext) -> ResolutionOutcome;
}
pub struct LocalEndpointFallback {
enabled: bool,
}
const OLLAMA_LOCAL_ENDPOINT: &str = "http://localhost:11434/v1";
const ENDPOINT_FALLBACK_ENV: &str = "SELFWARE_ENDPOINT_FALLBACK";
impl LocalEndpointFallback {
pub fn opt_in() -> Self {
Self { enabled: true }
}
pub fn from_env() -> Self {
Self {
enabled: Self::env_opted_in(std::env::var(ENDPOINT_FALLBACK_ENV).ok().as_deref()),
}
}
fn env_opted_in(value: Option<&str>) -> bool {
matches!(
value.map(|v| v.trim().to_ascii_lowercase()),
Some(ref v) if v == "1" || v == "true" || v == "yes" || v == "on"
)
}
fn pick_local_endpoint(_config: &crate::config::Config) -> String {
Self::choose_local_endpoint(std::env::var("SELFWARE_LOCAL_ENDPOINT").ok().as_deref())
}
fn choose_local_endpoint(override_endpoint: Option<&str>) -> String {
if let Some(ep) = override_endpoint {
let ep = ep.trim();
if !ep.is_empty() {
return ep.to_string();
}
}
OLLAMA_LOCAL_ENDPOINT.to_string()
}
}
impl Resolution for LocalEndpointFallback {
fn name(&self) -> &'static str {
"LocalEndpointFallback"
}
fn offline_capable(&self) -> bool {
true
}
fn resolve(&self, ctx: &RecoveryContext) -> ResolutionOutcome {
if classify(ctx.message) != FailureKind::LlmUnreachable {
return ResolutionOutcome::Escalate;
}
if !self.enabled {
tracing::debug!(
"LocalEndpointFallback: endpoint rerouting not enabled \
(set {}=1 to opt in) — escalating",
ENDPOINT_FALLBACK_ENV
);
return ResolutionOutcome::Escalate;
}
let current = &ctx.config.endpoint;
if crate::config::is_local_endpoint(current) {
return ResolutionOutcome::Escalate;
}
let target = Self::pick_local_endpoint(ctx.config);
tracing::warn!(
"⚠️ Self-healing is switching the LLM endpoint from '{}' to '{}' \
for the rest of this session because the configured endpoint was \
classified as unreachable. This change is session-scoped and NOT \
saved; update the `endpoint` in selfware.toml to make it \
permanent, or unset {} to disable automatic fallback.",
current,
target,
ENDPOINT_FALLBACK_ENV
);
ResolutionOutcome::Resolved(RecoveryDirective::Action(RecoveryAction::Fallback {
target,
}))
}
}
pub struct RateLimitBackoff {
pub delay_ms: u64,
pub max_attempts: u32,
}
impl RateLimitBackoff {
pub fn new(delay_ms: u64, max_attempts: u32) -> Self {
Self {
delay_ms,
max_attempts,
}
}
}
impl Resolution for RateLimitBackoff {
fn name(&self) -> &'static str {
"RateLimitBackoff"
}
fn offline_capable(&self) -> bool {
true
}
fn resolve(&self, ctx: &RecoveryContext) -> ResolutionOutcome {
if classify(ctx.message) != FailureKind::RateLimited {
return ResolutionOutcome::Escalate;
}
ResolutionOutcome::Resolved(RecoveryDirective::Action(RecoveryAction::Retry {
delay_ms: self.delay_ms,
max_attempts: self.max_attempts,
}))
}
}
pub struct ContextOverflowCompress {
pub target_tokens: usize,
}
impl Resolution for ContextOverflowCompress {
fn name(&self) -> &'static str {
"ContextOverflowCompress"
}
fn offline_capable(&self) -> bool {
true
}
fn resolve(&self, ctx: &RecoveryContext) -> ResolutionOutcome {
if classify(ctx.message) != FailureKind::ContextOverflow {
return ResolutionOutcome::Escalate;
}
ResolutionOutcome::Resolved(RecoveryDirective::CompressContext {
target_tokens: self.target_tokens,
})
}
}
pub struct CredentialReload;
impl CredentialReload {
pub fn find_available_key(endpoint: &str) -> Option<String> {
if let Ok(key) = std::env::var("SELFWARE_API_KEY") {
if !key.is_empty() {
return Some(key);
}
}
match crate::config::load_api_key_from_keyring(endpoint) {
Ok(Some(key)) if !key.is_empty() => Some(key),
_ => None,
}
}
}
impl Resolution for CredentialReload {
fn name(&self) -> &'static str {
"CredentialReload"
}
fn offline_capable(&self) -> bool {
true
}
fn resolve(&self, ctx: &RecoveryContext) -> ResolutionOutcome {
if classify(ctx.message) != FailureKind::AuthError {
return ResolutionOutcome::Escalate;
}
if Self::find_available_key(&ctx.config.endpoint).is_some() {
ResolutionOutcome::Resolved(RecoveryDirective::ReloadCredentials)
} else {
ResolutionOutcome::Escalate
}
}
}
pub struct RecoveryTree {
resolvers: HashMap<FailureKind, Vec<Box<dyn Resolution>>>,
}
impl RecoveryTree {
pub fn new() -> Self {
Self {
resolvers: HashMap::new(),
}
}
pub fn with_defaults() -> Self {
let mut tree = Self::new();
tree.register(
FailureKind::LlmUnreachable,
Box::new(LocalEndpointFallback::from_env()),
);
tree.register(
FailureKind::RateLimited,
Box::new(RateLimitBackoff::new(2000, 3)),
);
tree.register(
FailureKind::ContextOverflow,
Box::new(ContextOverflowCompress {
target_tokens: 8_000,
}),
);
tree.register(FailureKind::AuthError, Box::new(CredentialReload));
tree
}
pub fn register(&mut self, kind: FailureKind, resolver: Box<dyn Resolution>) {
self.resolvers.entry(kind).or_default().push(resolver);
}
pub fn resolve(&self, signal: &FailureSignal, ctx: &RecoveryContext) -> ResolutionOutcome {
match self.resolvers.get(&signal.kind) {
Some(list) => {
let mut saw_escalate = false;
for resolver in list {
match resolver.resolve(ctx) {
ResolutionOutcome::Resolved(directive) => {
return ResolutionOutcome::Resolved(directive);
}
ResolutionOutcome::Escalate => {
saw_escalate = true;
}
ResolutionOutcome::Unresolvable => {
return ResolutionOutcome::Unresolvable;
}
}
}
if saw_escalate {
ResolutionOutcome::Escalate
} else {
ResolutionOutcome::Escalate
}
}
None => ResolutionOutcome::Unresolvable,
}
}
}
impl Default for RecoveryTree {
fn default() -> Self {
Self::with_defaults()
}
}
pub struct DeadlockDetector {
no_progress_waits: u32,
threshold: u32,
}
impl DeadlockDetector {
pub fn new(threshold: u32) -> Self {
Self {
no_progress_waits: 0,
threshold,
}
}
pub fn observe(&mut self, is_waiting: bool, made_progress: bool) -> Option<FailureKind> {
if made_progress {
self.no_progress_waits = 0;
return None;
}
if is_waiting {
self.no_progress_waits += 1;
if self.no_progress_waits >= self.threshold {
self.no_progress_waits = 0;
return Some(FailureKind::Deadlock);
}
}
None
}
pub fn current_count(&self) -> u32 {
self.no_progress_waits
}
}
#[cfg(test)]
#[allow(clippy::field_reassign_with_default)] #[path = "../../tests/unit/self_healing/recovery_tree/recovery_tree_test.rs"]
mod tests;