use std::time::Duration;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct RetryPolicy {
#[serde(rename = "base_ms", with = "millis")]
pub base: Duration,
#[serde(rename = "max_ms", with = "millis")]
pub max: Duration,
}
mod millis {
use std::time::Duration;
use serde::{Deserialize, Deserializer, Serializer};
#[allow(clippy::trivially_copy_pass_by_ref)]
pub(super) fn serialize<S: Serializer>(d: &Duration, s: S) -> Result<S::Ok, S::Error> {
s.serialize_u64(u64::try_from(d.as_millis()).unwrap_or(u64::MAX))
}
pub(super) fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Duration, D::Error> {
Ok(Duration::from_millis(u64::deserialize(d)?))
}
}
impl Default for RetryPolicy {
fn default() -> Self {
Self {
base: Duration::from_millis(500),
max: Duration::from_secs(30),
}
}
}
impl RetryPolicy {
pub fn wait(&self, attempt: u32, retry_after: Option<Duration>) -> Duration {
if let Some(server) = retry_after {
return server;
}
let shift = attempt.saturating_sub(1).min(16);
let grown = self.base.saturating_mul(1u32 << shift);
grown.min(self.max)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct StallPolicy {
pub window: u32,
pub max_replans: u32,
}
impl Default for StallPolicy {
fn default() -> Self {
Self {
window: 3,
max_replans: 1,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Progressing {
Fine,
Replan,
Stalled,
}
#[derive(Debug, Default, Clone)]
pub struct Progress {
seen: Vec<String>,
unproductive: u32,
replans: u32,
}
impl Progress {
pub fn new() -> Self {
Self::default()
}
pub fn replans(&self) -> u32 {
self.replans
}
pub fn step(&mut self, policy: StallPolicy, changed: bool, signature: &str) -> Progressing {
if policy.window == 0 {
return Progressing::Fine;
}
if changed {
self.seen.clear();
self.unproductive = 0;
return Progressing::Fine;
}
let repeated = self.seen.iter().any(|s| s == signature);
self.seen.push(signature.to_string());
self.unproductive += 1;
if self.unproductive < policy.window || !repeated {
return Progressing::Fine;
}
self.seen.clear();
self.unproductive = 0;
if self.replans < policy.max_replans {
self.replans += 1;
Progressing::Replan
} else {
Progressing::Stalled
}
}
pub fn replan_directive(&self, window: u32, tried: &[String]) -> String {
let mut out = format!(
"\n[no progress] The last {window} steps changed nothing in the workspace, and you have \
repeated a tool call you already made. Whatever you are doing is not working.\n"
);
if !tried.is_empty() {
out.push_str("Already tried, to no effect:\n");
for t in tried {
out.push_str(&format!("- {t}\n"));
}
}
out.push_str(
"Change approach: write the file, or gather something you have not gathered yet. \
Repeating the same call will end the run.\n",
);
out
}
}
#[cfg(test)]
mod tests {
use super::*;
fn policy() -> StallPolicy {
StallPolicy {
window: 3,
max_replans: 1,
}
}
#[test]
fn backoff_doubles_up_to_the_ceiling() {
let p = RetryPolicy {
base: Duration::from_millis(100),
max: Duration::from_millis(500),
};
assert_eq!(p.wait(1, None), Duration::from_millis(100));
assert_eq!(p.wait(2, None), Duration::from_millis(200));
assert_eq!(p.wait(3, None), Duration::from_millis(400));
assert_eq!(p.wait(4, None), Duration::from_millis(500));
assert_eq!(p.wait(64, None), Duration::from_millis(500));
}
#[test]
fn a_servers_retry_after_wins_over_the_ceiling() {
let p = RetryPolicy {
base: Duration::from_millis(100),
max: Duration::from_millis(500),
};
assert_eq!(
p.wait(1, Some(Duration::from_secs(9))),
Duration::from_secs(9)
);
}
#[test]
fn a_window_of_zero_disables_detection_entirely() {
let off = StallPolicy {
window: 0,
max_replans: 1,
};
let mut p = Progress::new();
for _ in 0..50 {
assert_eq!(p.step(off, false, "read a"), Progressing::Fine);
}
assert_eq!(p.replans(), 0);
}
#[test]
fn repeating_one_call_while_nothing_changes_is_a_stall() {
let mut p = Progress::new();
assert_eq!(p.step(policy(), false, "read a"), Progressing::Fine);
assert_eq!(p.step(policy(), false, "read a"), Progressing::Fine);
assert_eq!(p.step(policy(), false, "read a"), Progressing::Replan);
assert_eq!(p.step(policy(), false, "read a"), Progressing::Fine);
assert_eq!(p.step(policy(), false, "read a"), Progressing::Fine);
assert_eq!(p.step(policy(), false, "read a"), Progressing::Stalled);
}
#[test]
fn varied_calls_that_change_nothing_are_exploration_not_a_stall() {
let mut p = Progress::new();
for sig in [
"read a",
"read b",
"read c",
"grep x",
"find *.rs",
"read d",
] {
assert_eq!(p.step(policy(), false, sig), Progressing::Fine);
}
assert_eq!(p.replans(), 0);
}
#[test]
fn changing_the_workspace_clears_the_window() {
let mut p = Progress::new();
assert_eq!(p.step(policy(), false, "read a"), Progressing::Fine);
assert_eq!(p.step(policy(), false, "read a"), Progressing::Fine);
assert_eq!(p.step(policy(), true, "wrote a"), Progressing::Fine);
assert_eq!(p.step(policy(), false, "read a"), Progressing::Fine);
assert_eq!(p.step(policy(), false, "read a"), Progressing::Fine);
assert_eq!(p.step(policy(), false, "read a"), Progressing::Replan);
}
#[test]
fn a_write_that_changed_nothing_does_not_count_as_progress() {
let mut p = Progress::new();
assert_eq!(p.step(policy(), false, "wrote a"), Progressing::Fine);
assert_eq!(p.step(policy(), false, "wrote a"), Progressing::Fine);
assert_eq!(p.step(policy(), false, "wrote a"), Progressing::Replan);
}
#[test]
fn more_replans_than_allowed_is_a_configuration_a_caller_can_make() {
let patient = StallPolicy {
window: 2,
max_replans: 3,
};
let mut p = Progress::new();
let mut replans = 0;
for _ in 0..8 {
if p.step(patient, false, "read a") == Progressing::Replan {
replans += 1;
}
}
assert_eq!(replans, 3, "it stops telling after max_replans");
assert_eq!(p.step(patient, false, "read a"), Progressing::Fine);
assert_eq!(p.step(patient, false, "read a"), Progressing::Stalled);
}
#[test]
fn the_directive_names_what_was_tried() {
let p = Progress::new();
let d = p.replan_directive(3, &["read a".into(), "read b".into()]);
assert!(d.contains("last 3 steps changed nothing"));
assert!(d.contains("- read a") && d.contains("- read b"));
assert!(d.contains("Change approach"));
assert!(d.starts_with("\n[no progress]"));
}
}