malvin 0.2.9

Non-interactive research and coding agent
use std::num::NonZeroU32;
use std::time::{Duration, Instant};

pub const MAX_CONSECUTIVE_SAME_BACKEND_ERRORS: u32 = 3;

const fn default_consecutive() -> NonZeroU32 {
    match NonZeroU32::new(MAX_CONSECUTIVE_SAME_BACKEND_ERRORS) {
        Some(n) => n,
        None => NonZeroU32::MIN,
    }
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct ConsecutiveErrorLimit(NonZeroU32);

impl ConsecutiveErrorLimit {
    #[must_use]
    pub const fn new(n: NonZeroU32) -> Self {
        Self(n)
    }

    #[must_use]
    pub const fn get(self) -> u32 {
        self.0.get()
    }
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct AcpRetryCount(NonZeroU32);

impl AcpRetryCount {
    #[must_use]
    pub const fn at_least_one(raw: u32) -> Self {
        match NonZeroU32::new(raw) {
            Some(n) => Self(n),
            None => Self(NonZeroU32::MIN),
        }
    }

    #[must_use]
    pub const fn get(self) -> u32 {
        self.0.get()
    }

    #[must_use]
    pub const fn as_consecutive_limit(self) -> ConsecutiveErrorLimit {
        ConsecutiveErrorLimit::new(self.0)
    }
}

pub const LOCAL_MAX_BACKEND_ERRORS: u32 = 10;

pub const LOCAL_MAX_BACKEND_ERROR_WINDOW: Duration = Duration::from_mins(5);

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct BackendErrorTracker {
    consecutive_count: u32,
    last_error: Option<String>,
    max_consecutive: ConsecutiveErrorLimit,
    errors_since_success: u32,
    first_error_at: Option<Instant>,
}

impl Default for BackendErrorTracker {
    fn default() -> Self {
        Self::new()
    }
}

impl BackendErrorTracker {
    #[must_use]
    pub const fn new() -> Self {
        Self::with_limit(ConsecutiveErrorLimit::new(default_consecutive()))
    }

    #[must_use]
    pub const fn empty() -> Self {
        Self::new()
    }

    #[must_use]
    pub const fn with_limit(max_consecutive: ConsecutiveErrorLimit) -> Self {
        Self {
            consecutive_count: 0,
            last_error: None,
            max_consecutive,
            errors_since_success: 0,
            first_error_at: None,
        }
    }

    pub const fn set_max_consecutive(&mut self, limit: ConsecutiveErrorLimit) {
        self.max_consecutive = limit;
    }

    #[must_use]
    pub const fn max_consecutive(&self) -> u32 {
        self.max_consecutive.get()
    }

    #[must_use]
    pub const fn consecutive_count(&self) -> u32 {
        self.consecutive_count
    }

    #[must_use]
    pub fn last_error(&self) -> Option<&str> {
        self.last_error.as_deref()
    }

    pub fn record_success(&mut self) {
        self.consecutive_count = 0;
        self.last_error = None;
        self.errors_since_success = 0;
        self.first_error_at = None;
    }

    pub fn record_error(&mut self, error: &str) -> bool {
        self.errors_since_success = self.errors_since_success.saturating_add(1);
        self.first_error_at.get_or_insert_with(Instant::now);
        let is_same = self
            .last_error
            .as_ref()
            .is_some_and(|prev| prev == error || prev.trim() == error.trim());
        if is_same {
            self.consecutive_count = self.consecutive_count.saturating_add(1);
        } else {
            self.consecutive_count = 1;
            self.last_error = Some(error.to_string());
        }
        self.consecutive_count >= self.max_consecutive.get()
    }

    #[must_use]
    pub const fn should_stop_and_exit(&self) -> bool {
        self.consecutive_count >= self.max_consecutive.get()
    }

    #[must_use]
    pub fn local_retry_cap_errors(&self) -> Option<u32> {
        let reached = self.errors_since_success >= LOCAL_MAX_BACKEND_ERRORS
            || self
                .first_error_at
                .is_some_and(|t| t.elapsed() >= LOCAL_MAX_BACKEND_ERROR_WINDOW);
        reached.then_some(self.errors_since_success)
    }
}

#[must_use]
pub fn format_backend_consecutive_error_message(
    backend_label: &str,
    error: &str,
    limit: u32,
) -> String {
    let times = if limit == 1 { "time" } else { "times" };
    format!(
        "{backend_label} backend error repeated {limit} {times} in a row; stopping and exiting. Last error:\n{error}"
    )
}

#[must_use]
pub fn format_local_backend_retry_cap_message(
    backend_label: &str,
    error: &str,
    errors: u32,
) -> String {
    format!(
        "{backend_label} local backend failed {errors} times without a successful turn (limit {LOCAL_MAX_BACKEND_ERRORS} errors or {} s); stopping and exiting. Last error:\n{error}",
        LOCAL_MAX_BACKEND_ERROR_WINDOW.as_secs()
    )
}