Ports grok-build's action stationarity breaker. A polling loop looks like progress from inside the turn — every step succeeds — so nothing stops it and it burns the whole budget re-reading one file. Nudge at 8 identical batches, halt at 16, halt a pure no-op run at 4. Three defects were found and fixed before this landed, none of which the gates caught: - The check runs AFTER execute_tool_calls, not before. Before it, the assistant's tool_calls are recorded with no results, so pushing the nudge triggers repair_dangling_tool_calls: synthetic "cancelled by the user" results get spliced in, the real results land after a user message, and dedup misses them — the orphan tool_result shape that draws a provider 400. It is Ok-gated too, since one `?` inside can leave a call resultless. - `TurnOutcome::StationarityHalted` is its own variant grouped with Completed, not a reuse of Cancelled. As a cancellation it would report StopReason::Cancelled, fire the abort lifecycle, kill the turn's subagents, grow the goal harness back-off streak until three halts paused a running goal, and let completion-requirement recovery re-run the very turn halted for looping. - The no-op gate is size-based, not name-based. kigi registers the shell tool as `run_terminal_cmd` and renames it `run_terminal_command`, so matching on "bash" silently disabled the tighter ceiling while every test still passed.
217 lines
7.0 KiB
Rust
217 lines
7.0 KiB
Rust
//! Detects a model stuck repeating one identical tool call.
|
|
|
|
/// Consecutive identical batches after which the turn is halted.
|
|
pub(crate) const MAX_CONSECUTIVE_IDENTICAL_TOOL_CALLS: u32 = 16;
|
|
|
|
/// Nudge threshold; half the budget remains after it.
|
|
pub(crate) const NUDGE_AFTER_IDENTICAL_TOOL_CALLS: u32 = 8;
|
|
|
|
/// Below the nudge on purpose: no-op runs get none.
|
|
pub(crate) const MAX_CONSECUTIVE_TRUE_NOOPS: u32 = 4;
|
|
|
|
const _: () = assert!(NUDGE_AFTER_IDENTICAL_TOOL_CALLS < MAX_CONSECUTIVE_IDENTICAL_TOOL_CALLS);
|
|
const _: () = assert!(MAX_CONSECUTIVE_TRUE_NOOPS < NUDGE_AFTER_IDENTICAL_TOOL_CALLS);
|
|
|
|
/// A shell command that does nothing whatsoever.
|
|
pub(crate) fn command_is_true(cmd: &str) -> bool {
|
|
cmd.trim().eq_ignore_ascii_case("true")
|
|
}
|
|
|
|
/// Hashes name+args per call; separators prevent concatenation collisions.
|
|
pub(crate) fn hash_batch<'a>(calls: impl IntoIterator<Item = (&'a str, &'a str)>) -> u64 {
|
|
use std::hash::{Hash, Hasher};
|
|
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
|
for (name, args) in calls {
|
|
name.hash(&mut hasher);
|
|
'\u{1f}'.hash(&mut hasher);
|
|
args.hash(&mut hasher);
|
|
'\u{1e}'.hash(&mut hasher);
|
|
}
|
|
hasher.finish()
|
|
}
|
|
|
|
/// One identity, so re-spelling a no-op cannot reset it.
|
|
const TRUE_NOOP_HASH: u64 = u64::MAX;
|
|
|
|
/// How many times the current batch has repeated unchanged.
|
|
#[derive(Default)]
|
|
pub(crate) struct IdenticalToolCallRun {
|
|
/// Hash only: signatures hold raw tool arguments.
|
|
last_hash: Option<u64>,
|
|
tool_name: String,
|
|
run_len: u32,
|
|
is_true_noop_run: bool,
|
|
}
|
|
|
|
impl IdenticalToolCallRun {
|
|
/// Records one batch and returns the length of the run it belongs to.
|
|
pub(crate) fn observe(&mut self, batch_hash: u64, tool_name: &str, is_true_noop: bool) -> u32 {
|
|
let hash = if is_true_noop {
|
|
TRUE_NOOP_HASH
|
|
} else {
|
|
batch_hash
|
|
};
|
|
if self.last_hash == Some(hash) {
|
|
self.run_len += 1;
|
|
} else {
|
|
self.run_len = 1;
|
|
self.last_hash = Some(hash);
|
|
self.is_true_noop_run = is_true_noop;
|
|
}
|
|
self.tool_name = tool_name.to_string();
|
|
self.run_len
|
|
}
|
|
|
|
/// The run length at which this turn must be halted.
|
|
pub(crate) fn hard_stop_threshold(&self) -> u32 {
|
|
if self.is_true_noop_run {
|
|
MAX_CONSECUTIVE_TRUE_NOOPS
|
|
} else {
|
|
MAX_CONSECUTIVE_IDENTICAL_TOOL_CALLS
|
|
}
|
|
}
|
|
|
|
pub(crate) fn tool_name(&self) -> &str {
|
|
&self.tool_name
|
|
}
|
|
|
|
pub(crate) fn is_true_noop_run(&self) -> bool {
|
|
self.is_true_noop_run
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn call(name: &str, args: &str) -> u64 {
|
|
hash_batch([(name, args)])
|
|
}
|
|
|
|
#[test]
|
|
fn an_unchanged_batch_accumulates_a_run() {
|
|
let mut run = IdenticalToolCallRun::default();
|
|
let h = call("read_file", "a.rs");
|
|
assert_eq!(run.observe(h, "read_file", false), 1);
|
|
assert_eq!(run.observe(h, "read_file", false), 2);
|
|
assert_eq!(run.observe(h, "read_file", false), 3);
|
|
}
|
|
|
|
#[test]
|
|
fn any_change_in_the_arguments_restarts_the_run() {
|
|
let mut run = IdenticalToolCallRun::default();
|
|
run.observe(call("read_file", "a.rs"), "read_file", false);
|
|
run.observe(call("read_file", "a.rs"), "read_file", false);
|
|
assert_eq!(
|
|
run.observe(call("read_file", "b.rs"), "read_file", false),
|
|
1,
|
|
"a different argument is a different action"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn two_batches_cannot_collide_by_concatenation() {
|
|
// Unseparated these would hash identically.
|
|
assert_ne!(
|
|
hash_batch([("ab", "cd")]),
|
|
hash_batch([("a", "b"), ("c", "d")])
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn no_ops_share_one_run_however_they_are_spelled() {
|
|
let mut run = IdenticalToolCallRun::default();
|
|
assert_eq!(run.observe(call("bash", "true"), "bash", true), 1);
|
|
assert_eq!(
|
|
run.observe(call("bash", " TRUE "), "bash", true),
|
|
2,
|
|
"re-spelling a no-op must not reset the tighter ceiling"
|
|
);
|
|
assert_eq!(run.hard_stop_threshold(), MAX_CONSECUTIVE_TRUE_NOOPS);
|
|
}
|
|
|
|
#[test]
|
|
fn a_real_call_after_a_noop_run_restores_the_ordinary_ceiling() {
|
|
let mut run = IdenticalToolCallRun::default();
|
|
run.observe(call("bash", "true"), "bash", true);
|
|
assert_eq!(run.hard_stop_threshold(), MAX_CONSECUTIVE_TRUE_NOOPS);
|
|
run.observe(call("read_file", "a.rs"), "read_file", false);
|
|
assert_eq!(
|
|
run.hard_stop_threshold(),
|
|
MAX_CONSECUTIVE_IDENTICAL_TOOL_CALLS
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn an_ordinary_repeated_call_is_halted_at_its_ceiling_and_not_before() {
|
|
let mut run = IdenticalToolCallRun::default();
|
|
let h = call("read_file", "a.rs");
|
|
for expected in 1..MAX_CONSECUTIVE_IDENTICAL_TOOL_CALLS {
|
|
let len = run.observe(h, "read_file", false);
|
|
assert_eq!(len, expected);
|
|
assert!(len < run.hard_stop_threshold(), "must not halt early");
|
|
}
|
|
assert_eq!(
|
|
run.observe(h, "read_file", false),
|
|
run.hard_stop_threshold(),
|
|
"the turn halts on the 16th identical call"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_repeated_no_op_is_halted_far_sooner_and_without_a_nudge() {
|
|
let mut run = IdenticalToolCallRun::default();
|
|
let h = call("bash", "true");
|
|
let mut len = 0;
|
|
while len < run.hard_stop_threshold() {
|
|
len = run.observe(h, "bash", true);
|
|
}
|
|
assert_eq!(len, MAX_CONSECUTIVE_TRUE_NOOPS);
|
|
assert!(
|
|
len < NUDGE_AFTER_IDENTICAL_TOOL_CALLS,
|
|
"documented: a no-op run is halted before any nudge could fire"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn the_nudge_lands_with_budget_left_to_act_on_it() {
|
|
let mut run = IdenticalToolCallRun::default();
|
|
let h = call("read_file", "a.rs");
|
|
let mut len = 0;
|
|
while len < NUDGE_AFTER_IDENTICAL_TOOL_CALLS {
|
|
len = run.observe(h, "read_file", false);
|
|
}
|
|
assert!(
|
|
len < run.hard_stop_threshold(),
|
|
"a warning the model cannot act on is not a warning"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn work_interleaved_with_repeats_is_never_halted() {
|
|
let mut run = IdenticalToolCallRun::default();
|
|
for i in 0..MAX_CONSECUTIVE_IDENTICAL_TOOL_CALLS * 2 {
|
|
let h = if i % 2 == 0 {
|
|
call("read_file", "a.rs")
|
|
} else {
|
|
call("read_file", "b.rs")
|
|
};
|
|
let len = run.observe(h, "read_file", false);
|
|
assert!(
|
|
len < run.hard_stop_threshold(),
|
|
"alternating calls are progress, not a loop"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn only_a_bare_true_counts_as_a_no_op() {
|
|
assert!(command_is_true("true"));
|
|
assert!(command_is_true(" true "));
|
|
assert!(command_is_true("TRUE"));
|
|
assert!(!command_is_true("true && make"));
|
|
assert!(!command_is_true("truely"));
|
|
assert!(!command_is_true(""));
|
|
}
|
|
}
|