use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use machi_types::{ErrorCode, MachiError, Message, ToolCall};
pub const NUDGE_THRESHOLD: u32 = 8;
pub const HARD_STOP_THRESHOLD: u32 = 16;
#[derive(Debug, Clone, Copy, Default)]
pub struct StationarityTracker {
last_fingerprint: Option<u64>,
streak: u32,
nudged: bool,
}
#[derive(Debug, Clone)]
pub enum StationarityAction {
Ok,
Nudge {
reminder: String,
},
HardStop {
error: MachiError,
},
}
impl StationarityTracker {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub const fn streak(&self) -> u32 {
self.streak
}
pub fn observe_tool_batch(&mut self, calls: &[ToolCall]) -> StationarityAction {
if calls.is_empty() {
self.reset();
return StationarityAction::Ok;
}
let fp = fingerprint_batch(calls);
if self.last_fingerprint == Some(fp) {
self.streak = self.streak.saturating_add(1);
} else {
self.last_fingerprint = Some(fp);
self.streak = 1;
self.nudged = false;
}
if self.streak >= HARD_STOP_THRESHOLD {
return StationarityAction::HardStop {
error: MachiError::new(
ErrorCode::RuntimeStationarity,
format!(
"identical tool calls repeated {HARD_STOP_THRESHOLD} times (stationarity hard stop)"
),
),
};
}
if self.streak >= NUDGE_THRESHOLD && !self.nudged {
self.nudged = true;
return StationarityAction::Nudge {
reminder: format!(
"You have called the same tool(s) with the same arguments {NUDGE_THRESHOLD} times \
in a row. Change strategy, use different tools, or finish without repeating."
),
};
}
StationarityAction::Ok
}
pub fn reset(&mut self) {
self.last_fingerprint = None;
self.streak = 0;
self.nudged = false;
}
}
#[must_use]
pub fn fingerprint_batch(calls: &[ToolCall]) -> u64 {
let mut hasher = DefaultHasher::new();
for call in calls {
call.name.hash(&mut hasher);
call.arguments.to_string().hash(&mut hasher);
}
hasher.finish()
}
#[must_use]
pub fn nudge_message(reminder: String) -> Message {
Message::user(reminder)
}
#[cfg(test)]
#[allow(clippy::expect_used, reason = "unit tests")]
mod tests {
use super::*;
use machi_types::ToolCallId;
use serde_json::json;
fn call(name: &str, args: serde_json::Value) -> ToolCall {
ToolCall {
id: ToolCallId::new("c1").expect("id"),
name: name.into(),
arguments: args,
}
}
#[test]
fn resets_on_different_call() {
let mut t = StationarityTracker::new();
for _ in 0..5 {
assert!(matches!(
t.observe_tool_batch(&[call("a", json!({"x": 1}))]),
StationarityAction::Ok
));
}
assert_eq!(t.streak(), 5);
assert!(matches!(
t.observe_tool_batch(&[call("b", json!({"x": 1}))]),
StationarityAction::Ok
));
assert_eq!(t.streak(), 1);
}
#[test]
fn nudge_then_hard_stop() {
let mut t = StationarityTracker::new();
let batch = [call("calc", json!({"expr": "1+1"}))];
for i in 1..NUDGE_THRESHOLD {
let a = t.observe_tool_batch(&batch);
assert!(matches!(a, StationarityAction::Ok), "i={i}");
}
assert!(matches!(
t.observe_tool_batch(&batch),
StationarityAction::Nudge { .. }
));
for _ in (NUDGE_THRESHOLD + 1)..HARD_STOP_THRESHOLD {
let _ = t.observe_tool_batch(&batch);
}
assert!(matches!(
t.observe_tool_batch(&batch),
StationarityAction::HardStop { .. }
));
}
}