mod types;
use types::LadderState;
pub use types::{NoProgress, NoProgressTracker, ToolAttempt};
use std::sync::Mutex;
pub const DEFAULT_IDENTICAL_HALT_THRESHOLD: usize = 3;
const IDENTICAL_NUDGE_THRESHOLD: usize = 2;
const NO_PROGRESS_HALT_THRESHOLD: usize = 6;
const NO_PROGRESS_NUDGE_THRESHOLD: usize = 4;
const HARD_REJECT_HALT_THRESHOLD: usize = 2;
impl NoProgressTracker {
pub fn new(identical_halt_threshold: usize) -> Self {
Self {
identical_halt_threshold: identical_halt_threshold.max(IDENTICAL_NUDGE_THRESHOLD + 1),
state: Mutex::new(LadderState::default()),
}
}
pub fn reset(&self) {
*self.state.lock().unwrap() = LadderState::default();
}
pub fn record(&self, step: usize, attempt: &ToolAttempt) -> NoProgress {
let mut state = self.state.lock().unwrap();
let Some(err) = attempt.error else {
*state = LadderState::default();
return NoProgress::Continue;
};
let err_line = err.lines().next().unwrap_or(err);
let sig = format!(
"{}\u{1f}{}\u{1f}{err_line}",
attempt.tool, attempt.arg_fingerprint
);
if !attempt.recoverable_miss {
state.consecutive += 1;
}
let same_count = match &state.last_sig {
Some(prev) if *prev == sig => {
state.same_count += 1;
state.same_count
}
_ => {
state.last_sig = Some(sig.clone());
state.same_count = 1;
state.nudged_sig = None;
1
}
};
if attempt.hard_reject && same_count >= HARD_REJECT_HALT_THRESHOLD {
let summary = format!(
"Stopping: the `{}` call is blocked by the security policy and was re-issued with \
identical arguments — it can never succeed this way. Reason:\n{}\n\nDo not repeat \
this call; use an allowed alternative or report that it can't be done here.",
attempt.tool,
truncate_for_halt(err),
);
*state = LadderState::default();
return NoProgress::Halt(summary);
}
if same_count >= self.identical_halt_threshold {
let summary = format!(
"Stopping: the `{}` call was retried {same_count} times with identical arguments \
and kept failing — repeating it will not help. Last error:\n{}\n\nThis looks \
unrecoverable in the current environment. Report this back instead of retrying.",
attempt.tool,
truncate_for_halt(err),
);
*state = LadderState::default();
return NoProgress::Halt(summary);
}
if state.consecutive >= NO_PROGRESS_HALT_THRESHOLD {
let summary = format!(
"Stopping: {} tool calls in a row failed with no progress. Last error (from \
`{}`):\n{}\n\nDifferent commands are all failing — the goal looks unreachable in \
this environment. Report this back instead of retrying.",
state.consecutive,
attempt.tool,
truncate_for_halt(err),
);
*state = LadderState::default();
return NoProgress::Halt(summary);
}
if same_count == IDENTICAL_NUDGE_THRESHOLD && state.nudged_sig.as_deref() != Some(&sig) {
state.nudged_sig = Some(sig);
return NoProgress::Nudge(identical_nudge(step, attempt.tool, same_count, err));
}
if !attempt.recoverable_miss
&& state.consecutive == NO_PROGRESS_NUDGE_THRESHOLD
&& !state.nudged_streak
{
state.nudged_streak = true;
return NoProgress::Nudge(varied_nudge(step, attempt.tool, state.consecutive, err));
}
NoProgress::Continue
}
}
fn identical_nudge(step: usize, tool: &str, count: usize, err: &str) -> String {
format!(
"[no progress since step {step}] The `{tool}` call has now failed {count} times with the \
same arguments and the same error — you are retrying an identical action that cannot \
succeed as-is. Do NOT repeat it. Change strategy on your next step: use a different tool \
or different arguments (for a missing path, enumerate the directory first; for a failing \
query, correct or broaden it), or report back that it can't be done here. Last error:\n{}",
truncate_for_halt(err),
)
}
fn varied_nudge(step: usize, tool: &str, count: usize, err: &str) -> String {
format!(
"[no progress since step {step}] {count} tool calls in a row have failed without making \
progress. Stop cycling through variations of the same approach — step back and try a \
different strategy (enumerate/inspect before acting, pick a different tool, or narrow the \
goal). Last error (from `{tool}`):\n{}",
truncate_for_halt(err),
)
}
fn truncate_for_halt(text: &str) -> String {
const MAX: usize = 600;
if text.chars().count() <= MAX {
text.to_string()
} else {
let head: String = text.chars().take(MAX).collect();
format!("{head}…")
}
}
#[cfg(test)]
mod test;