oy-cli 0.8.1

Local AI coding CLI for inspecting, editing, running commands, and auditing repositories
Documentation
use anyhow::{Result, bail};
use serde_json::Value;
use std::collections::BTreeSet;

#[derive(Default)]
pub(super) struct RepeatedNoopTools {
    seen: BTreeSet<String>,
}

impl RepeatedNoopTools {
    pub(super) fn record(&mut self, name: &str, args: &Value, result: &Value) -> Result<()> {
        if !is_noop_tool_result(name, result) {
            self.seen.clear();
            return Ok(());
        }
        let key = format!(
            "{}:{}",
            name,
            serde_json::to_string(args).unwrap_or_default()
        );
        if !self.seen.insert(key) {
            bail!(
                "tool loop made no progress: repeated no-op {name}; inspect the latest tool output and choose a different action"
            )
        }
        Ok(())
    }
}

fn is_noop_tool_result(name: &str, result: &Value) -> bool {
    match name {
        "replace" => {
            result.get("replacement_count").and_then(Value::as_u64) == Some(0)
                && result
                    .get("changed_file_count")
                    .and_then(Value::as_u64)
                    .unwrap_or(0)
                    == 0
                && result
                    .get("errors")
                    .and_then(Value::as_array)
                    .is_none_or(Vec::is_empty)
        }
        _ => false,
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;

    #[test]
    fn repeated_noop_tools_rejects_repeated_zero_replace() {
        let mut guard = RepeatedNoopTools::default();
        let args = json!({"path": "src/main.rs", "pattern": "missing", "replacement": "x"});
        let result = json!({
            "changed_file_count": 0,
            "replacement_count": 0,
            "errors": []
        });

        guard.record("replace", &args, &result).unwrap();
        let err = guard.record("replace", &args, &result).unwrap_err();

        assert!(err.to_string().contains("repeated no-op replace"));
    }

    #[test]
    fn repeated_noop_tools_allows_retry_after_progress() {
        let mut guard = RepeatedNoopTools::default();
        let args = json!({"path": "src/main.rs", "pattern": "missing", "replacement": "x"});
        let noop = json!({
            "changed_file_count": 0,
            "replacement_count": 0,
            "errors": []
        });
        let progress = json!({
            "changed_file_count": 1,
            "replacement_count": 1,
            "errors": []
        });

        guard.record("replace", &args, &noop).unwrap();
        guard.record("replace", &args, &progress).unwrap();

        assert!(guard.record("replace", &args, &noop).is_ok());
    }
}