Skip to main content

zeph_tools/
utility.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Utility-guided tool dispatch gate (arXiv:2603.19896).
5//!
6//! Computes a scalar utility score for each candidate tool call before execution.
7//! Calls below the configured threshold are skipped (fail-closed on scoring errors).
8
9use std::collections::{HashMap, HashSet};
10use std::hash::{DefaultHasher, Hash, Hasher};
11use std::sync::LazyLock;
12
13use regex::Regex;
14
15use crate::config::UtilityScoringConfig;
16use crate::executor::ToolCall;
17
18/// Returns `true` when a user message explicitly requests tool invocation.
19///
20/// Patterns are matched case-insensitively against the user message text.
21/// This is intentionally limited to unambiguous phrasings to avoid false positives
22/// that would incorrectly bypass the utility gate.
23///
24/// Safe to call on user-supplied text — does NOT bypass prompt-injection defences
25/// because those are enforced on tool OUTPUT paths, not on gate routing decisions.
26#[must_use]
27pub fn has_explicit_tool_request(user_message: &str) -> bool {
28    static RE: LazyLock<Regex> = LazyLock::new(|| {
29        Regex::new(
30            r"(?xi)
31            using\s+a\s+tool
32            | call\s+(the\s+)?[a-z_]+\s+tool
33            | use\s+(the\s+)?[a-z_]+\s+tool
34            | run\s+(the\s+)?[a-z_]+\s+tool
35            | invoke\s+(the\s+)?[a-z_]+\s+tool
36            | execute\s+(the\s+)?[a-z_]+\s+tool
37            | show\s+me\s+the\s+result\s+of\s*:
38            | run\s*:
39            | execute\s*:
40            | what\s+(does|would|is\s+the\s+output\s+of)
41            ",
42        )
43        .expect("static regex is valid")
44    });
45    // Inline code blocks with shell syntax are matched separately to avoid
46    // making the extended-mode regex unwieldy with backticks.
47    static RE_CODE: LazyLock<Regex> =
48        LazyLock::new(|| Regex::new(r"`[^`]*[|><$;&][^`]*`").expect("static regex is valid"));
49    RE.is_match(user_message) || RE_CODE.is_match(user_message)
50}
51
52/// Estimated gain for known tool categories.
53///
54/// Keys are exact tool name prefixes or names. Higher value = more expected gain.
55/// Unknown tools default to 0.5 (neutral).
56///
57/// Tools scoring `>= 0.7` take the direct `ToolCall` branch in `recommend_action`
58/// before the exploratory `Retrieve` rule (which requires `gain < 0.7`) is ever
59/// considered. Deterministic, self-contained actions — a compiler diagnostics run,
60/// a file edit or rename, a directory mutation — gain nothing from being routed
61/// through "retrieve context first, then retry": there is no missing context a
62/// memory search could supply, so forcing that detour only produces a stalled
63/// retry that then gets vetoed again as a redundant duplicate call (#5650).
64///
65/// This table only covers built-in tool ids known at compile time. Dynamically
66/// registered tools — most notably MCP tools, whose ids are `{server_id}_{name}`
67/// (see `McpTool::sanitized_id`) — never match a hardcoded name here and fall to the
68/// generic `0.5` bucket, exposing them to the same stall #5650 fixed for built-ins.
69/// `UtilityScorer::score` checks `UtilityScoringConfig::high_gain_tools` before
70/// calling this function so operators can opt individual MCP (or future built-in)
71/// tool ids into the `0.75` tier without a code change (#5659).
72fn default_gain(tool_name: &str) -> f32 {
73    if tool_name.starts_with("memory") {
74        return 0.8;
75    }
76    match tool_name {
77        "bash" | "shell" => 0.6,
78        "read" | "write" => 0.55,
79        "search_code" | "grep" | "glob" | "find_path" | "list_directory" => 0.65,
80        "diagnostics" | "edit" | "format" | "create_directory" | "delete_path" | "move_path"
81        | "copy_path" => 0.75,
82        _ => 0.5,
83    }
84}
85
86/// Computed utility components for a candidate tool call.
87#[derive(Debug, Clone)]
88pub struct UtilityScore {
89    /// Estimated information gain from executing the tool.
90    pub gain: f32,
91    /// Normalized token cost: `tokens_consumed / token_budget`.
92    pub cost: f32,
93    /// Redundancy penalty: 1.0 if identical `(tool_name, params_hash)` was seen this turn.
94    pub redundancy: f32,
95    /// Exploration bonus: decreases as turn progresses (`1 - tool_calls_this_turn / max_calls`).
96    pub uncertainty: f32,
97    /// Weighted aggregate.
98    pub total: f32,
99}
100
101impl UtilityScore {
102    /// Returns `true` when the score components are all finite.
103    fn is_valid(&self) -> bool {
104        self.gain.is_finite()
105            && self.cost.is_finite()
106            && self.redundancy.is_finite()
107            && self.uncertainty.is_finite()
108            && self.total.is_finite()
109    }
110}
111
112/// Context required to compute utility — provided by the agent loop.
113#[derive(Debug, Clone)]
114pub struct UtilityContext {
115    /// Number of tool calls already dispatched in the current LLM turn.
116    pub tool_calls_this_turn: usize,
117    /// Tokens consumed so far in this turn.
118    pub tokens_consumed: usize,
119    /// Token budget for the current turn. 0 = budget unknown (cost component treated as 0).
120    pub token_budget: usize,
121    /// True when the user explicitly requested tool invocation — either via a `/tool` slash
122    /// command or when the user message contains an unambiguous tool-invocation phrase detected
123    /// by [`has_explicit_tool_request`]. Must NOT be set from LLM call content or tool outputs.
124    pub user_requested: bool,
125    /// True when this exact call is the mandated retry the `Retrieve` rule itself asked for:
126    /// the same `(tool_id, params)` call was vetoed by rule 8 earlier this turn, and the
127    /// injected system hint explicitly instructed the LLM to call it again with the same
128    /// arguments. Set via [`UtilityScorer::take_mandated_retry`].
129    ///
130    /// When `true`, `recommend_action` must not re-veto the retry through the redundancy
131    /// (`Respond`) rule — doing so fabricates a rejection despite the model correctly
132    /// complying with the gate's own hint, stalling tools like `find_path`/`list_directory`
133    /// in a doomed Retrieve-then-redundant-Respond cycle (#5719).
134    pub mandated_retry: bool,
135}
136
137#[non_exhaustive]
138/// Recommended action from the utility policy (arXiv:2603.19896, §4.2).
139#[derive(Debug, Clone, Copy, PartialEq, Eq)]
140pub enum UtilityAction {
141    /// Generate a text response without executing the proposed tool.
142    Respond,
143    /// Retrieve additional context (memory search, RAG, graph recall) before responding.
144    Retrieve,
145    /// Execute the proposed tool call.
146    ToolCall,
147    /// Verify the previous tool result before proceeding.
148    Verify,
149    /// Stop the tool loop entirely (budget exhausted or loop limit).
150    Stop,
151}
152
153/// Hashes `(tool_name, serialized_params)` pre-execution for redundancy detection.
154fn call_hash(call: &ToolCall) -> u64 {
155    let mut h = DefaultHasher::new();
156    call.tool_id.hash(&mut h);
157    // Stable iteration order is not guaranteed for serde_json::Map, but it is insertion-order
158    // in practice for the same LLM output. Using the debug representation is simple and
159    // deterministic within a session (no cross-session persistence of these hashes).
160    format!("{:?}", call.params).hash(&mut h);
161    h.finish()
162}
163
164/// Computes utility scores for tool calls before dispatch.
165///
166/// Not `Send + Sync` — lives on the agent's single-threaded tool loop (same lifecycle as
167/// `ToolResultCache` and `recent_tool_calls`).
168#[derive(Debug)]
169pub struct UtilityScorer {
170    config: UtilityScoringConfig,
171    /// Hashes of `(tool_name, params)` seen in the current LLM turn for redundancy detection.
172    recent_calls: HashMap<u64, u32>,
173    /// Count of consecutive non-`ToolCall` recommendations since the last `ToolCall` or turn reset.
174    consecutive_low: usize,
175    /// Hashes of `(tool_name, params)` calls vetoed by the `Retrieve` rule this turn that are
176    /// awaiting the mandated retry the injected hint requested (#5719). Entries are consumed by
177    /// [`take_mandated_retry`](Self::take_mandated_retry) so the bypass applies exactly once per
178    /// veto — a genuine subsequent duplicate call is scored normally.
179    mandated_retries: HashSet<u64>,
180}
181
182impl UtilityScorer {
183    /// Create a new scorer from the given config.
184    #[must_use]
185    pub fn new(config: UtilityScoringConfig) -> Self {
186        Self {
187            config,
188            recent_calls: HashMap::new(),
189            consecutive_low: 0,
190            mandated_retries: HashSet::new(),
191        }
192    }
193
194    /// Whether utility scoring is enabled.
195    #[must_use]
196    pub fn is_enabled(&self) -> bool {
197        self.config.enabled
198    }
199
200    /// Score a candidate tool call.
201    ///
202    /// Returns `None` when scoring is disabled. When scoring produces a non-finite
203    /// result (misconfigured weights), returns `None` — the caller treats `None` as
204    /// fail-closed (skip the tool call) unless `user_requested` is set.
205    #[must_use]
206    pub fn score(&self, call: &ToolCall, ctx: &UtilityContext) -> Option<UtilityScore> {
207        if !self.config.enabled {
208            return None;
209        }
210
211        let gain = if self.is_high_gain(call.tool_id.as_str()) {
212            0.75
213        } else {
214            default_gain(call.tool_id.as_str())
215        };
216
217        let cost = if ctx.token_budget > 0 {
218            #[allow(clippy::cast_precision_loss)]
219            (ctx.tokens_consumed as f32 / ctx.token_budget as f32).clamp(0.0, 1.0)
220        } else {
221            0.0
222        };
223
224        let hash = call_hash(call);
225        let redundancy = if self.recent_calls.contains_key(&hash) {
226            1.0_f32
227        } else {
228            0.0_f32
229        };
230
231        // Uncertainty decreases as turn progresses. At tool call 0 it equals 1.0;
232        // at tool_calls_this_turn >= 10 it saturates to 0.0.
233        #[allow(clippy::cast_precision_loss)]
234        let uncertainty = (1.0_f32 - ctx.tool_calls_this_turn as f32 / 10.0).clamp(0.0, 1.0);
235
236        let total = self.config.gain_weight * gain
237            - self.config.cost_weight * cost
238            - self.config.redundancy_weight * redundancy
239            + self.config.uncertainty_bonus * uncertainty;
240
241        let score = UtilityScore {
242            gain,
243            cost,
244            redundancy,
245            uncertainty,
246            total,
247        };
248
249        if score.is_valid() { Some(score) } else { None }
250    }
251
252    /// Recommend an action based on the utility score and turn context.
253    ///
254    /// Decision tree (thresholds from arXiv:2603.19896):
255    /// 1. `user_requested` → always `ToolCall` (bypass policy).
256    /// 2. Scoring disabled → always `ToolCall`.
257    /// 3. `mandated_retry` → always `ToolCall` — this is the retry the `Retrieve` rule (8)
258    ///    itself demanded via the injected hint; the gate must honor its own contract instead
259    ///    of re-vetoing compliance as a redundant duplicate (#5719).
260    /// 4. `score` is `None` (invalid score, scoring enabled) → `Stop` (fail-closed).
261    /// 5. `cost > 0.9` (budget nearly exhausted) → `Stop`.
262    /// 6. `redundancy == 1.0` (duplicate call) → `Respond`.
263    /// 7. `gain >= 0.7 && total >= threshold` → `ToolCall`.
264    /// 8. `gain >= 0.5 && uncertainty > 0.5` → `Retrieve`.
265    /// 9. `total < threshold && tool_calls_this_turn > 0` → `Verify`.
266    /// 10. `total >= threshold` → `ToolCall`.
267    /// 11. Default → `Respond`.
268    #[must_use]
269    pub fn recommend_action(
270        &self,
271        score: Option<&UtilityScore>,
272        ctx: &UtilityContext,
273    ) -> UtilityAction {
274        // Bypass: user-requested tools are never gated.
275        if ctx.user_requested {
276            return UtilityAction::ToolCall;
277        }
278        // Pass-through: scoring disabled → always execute.
279        if !self.config.enabled {
280            return UtilityAction::ToolCall;
281        }
282        // Bypass: this call is the mandated retry the Retrieve rule itself asked for (#5719).
283        if ctx.mandated_retry {
284            return UtilityAction::ToolCall;
285        }
286        let Some(s) = score else {
287            // Invalid score with scoring enabled → fail-closed.
288            return UtilityAction::Stop;
289        };
290
291        // Budget nearly exhausted.
292        if s.cost > 0.9 {
293            return UtilityAction::Stop;
294        }
295        // Duplicate call — skip tool.
296        if s.redundancy >= 1.0 {
297            return UtilityAction::Respond;
298        }
299        // High-gain tool call above threshold.
300        if s.gain >= 0.7 && s.total >= self.config.threshold {
301            return UtilityAction::ToolCall;
302        }
303        // Uncertain — gather more context first.
304        if s.gain >= 0.5 && s.uncertainty > 0.5 {
305            return UtilityAction::Retrieve;
306        }
307        // Below threshold but prior results exist — verify before proceeding.
308        if s.total < self.config.threshold && ctx.tool_calls_this_turn > 0 {
309            return UtilityAction::Verify;
310        }
311        // Above threshold (low-gain but low-cost / low-redundancy).
312        if s.total >= self.config.threshold {
313            return UtilityAction::ToolCall;
314        }
315        UtilityAction::Respond
316    }
317
318    /// Record a call as executed for redundancy tracking.
319    ///
320    /// Must be called after `score()` and before the next call to `score()` for the
321    /// same tool in the same turn.
322    pub fn record_call(&mut self, call: &ToolCall) {
323        let hash = call_hash(call);
324        *self.recent_calls.entry(hash).or_insert(0) += 1;
325    }
326
327    /// Reset per-turn state. Call at the start of each LLM tool round.
328    pub fn clear(&mut self) {
329        self.recent_calls.clear();
330        self.consecutive_low = 0;
331        self.mandated_retries.clear();
332    }
333
334    /// Marks `call` as an in-flight mandated retry after the `Retrieve` rule instructs the LLM
335    /// to call it again with the same arguments (#5719).
336    ///
337    /// Called by the tool loop when it injects the "you MUST call it again" hint. The next
338    /// occurrence of the identical call this turn bypasses the redundancy veto — see
339    /// [`take_mandated_retry`](Self::take_mandated_retry).
340    pub fn mark_mandated_retry(&mut self, call: &ToolCall) {
341        self.mandated_retries.insert(call_hash(call));
342    }
343
344    /// Returns `true` and consumes the pending mandated-retry marker for `call`, if any.
345    ///
346    /// Consuming rather than merely peeking ensures the bypass applies exactly once per
347    /// `Retrieve` veto: a genuine third identical call afterward is scored normally instead of
348    /// being exempted forever.
349    pub fn take_mandated_retry(&mut self, call: &ToolCall) -> bool {
350        self.mandated_retries.remove(&call_hash(call))
351    }
352
353    /// Record the recommended action and check whether the consecutive-low-utility window is
354    /// exhausted.
355    ///
356    /// Returns `true` when `config.utility_window > 0` and `consecutive_low >= utility_window`,
357    /// indicating that the current batch should be downgraded and the caller should signal
358    /// a hard break of the outer iteration loop. Always returns `false` when
359    /// `utility_window == 0` (disabled) so existing behaviour is fully preserved.
360    ///
361    /// Must be called only for calls that actually went through `recommend_action` — exempt and
362    /// pre-exec-blocked calls bypass scoring and must NOT call this method.
363    pub fn note_action(&mut self, action: &UtilityAction) -> bool {
364        if *action == UtilityAction::ToolCall {
365            self.consecutive_low = 0;
366        } else {
367            self.consecutive_low = self.consecutive_low.saturating_add(1);
368        }
369        self.config.utility_window > 0 && self.consecutive_low >= self.config.utility_window
370    }
371
372    /// Returns `true` when `tool_name` case-insensitively matches an entry in `list`.
373    ///
374    /// Shared lookup behind `is_exempt` and `is_high_gain` — both are "does a configured
375    /// tool-name list contain this `tool_id`" checks and must use identical matching rules.
376    ///
377    /// Normalizes `:` to `_` on both sides before comparing (#5713): MCP tool ids are dispatched
378    /// as `McpTool::sanitized_id()` ("`{server_id}_{name}`", underscore-separated), but the only
379    /// runtime surface that lists them to an operator — the TUI `mcp:list` command — displays
380    /// `McpTool::qualified_name()` ("`{server_id}:{name}`", colon-separated). Without this
381    /// normalization, an id copied straight from `mcp:list` into config never matches the
382    /// incoming `tool_id`.
383    fn contains_tool_name(list: &[String], tool_name: &str) -> bool {
384        let normalize = |s: &str| s.to_lowercase().replace(':', "_");
385        let normalized = normalize(tool_name);
386        list.iter().any(|e| normalize(e) == normalized)
387    }
388
389    /// Returns `true` when `tool_name` is in the exempt list (case-insensitive).
390    ///
391    /// Exempt tools bypass the utility gate unconditionally and always receive `ToolCall`.
392    #[must_use]
393    pub fn is_exempt(&self, tool_name: &str) -> bool {
394        Self::contains_tool_name(&self.config.exempt_tools, tool_name)
395    }
396
397    /// Returns `true` when `tool_name` is in the `high_gain_tools` opt-in list (case-insensitive).
398    ///
399    /// Tools in this list receive the same `0.75` "direct action" gain as
400    /// `diagnostics`/`edit`/etc, regardless of whether `default_gain` has a hardcoded entry
401    /// for them. Intended for MCP-registered tools whose ids `default_gain` can never match
402    /// (#5659).
403    #[must_use]
404    pub fn is_high_gain(&self, tool_name: &str) -> bool {
405        Self::contains_tool_name(&self.config.high_gain_tools, tool_name)
406    }
407
408    /// The configured threshold.
409    #[must_use]
410    pub fn threshold(&self) -> f32 {
411        self.config.threshold
412    }
413
414    /// The configured consecutive-low-utility window size. 0 means disabled.
415    #[must_use]
416    pub fn utility_window(&self) -> usize {
417        self.config.utility_window
418    }
419}
420
421#[cfg(test)]
422mod tests {
423    use super::*;
424    use crate::ToolName;
425    use serde_json::json;
426
427    fn make_call(name: &str, params: serde_json::Value) -> ToolCall {
428        ToolCall {
429            tool_id: ToolName::new(name),
430            params: if let serde_json::Value::Object(m) = params {
431                m
432            } else {
433                serde_json::Map::new()
434            },
435            caller_id: None,
436            context: None,
437
438            tool_call_id: String::new(),
439            skill_name: None,
440        }
441    }
442
443    fn default_ctx() -> UtilityContext {
444        UtilityContext {
445            tool_calls_this_turn: 0,
446            tokens_consumed: 0,
447            token_budget: 1000,
448            user_requested: false,
449            mandated_retry: false,
450        }
451    }
452
453    fn default_config() -> UtilityScoringConfig {
454        UtilityScoringConfig {
455            enabled: true,
456            ..UtilityScoringConfig::default()
457        }
458    }
459
460    #[test]
461    fn disabled_returns_none() {
462        let scorer = UtilityScorer::new(UtilityScoringConfig::default());
463        assert!(!scorer.is_enabled());
464        let call = make_call("bash", json!({}));
465        let score = scorer.score(&call, &default_ctx());
466        assert!(score.is_none());
467        // When disabled, recommend_action always returns ToolCall (never gated).
468        assert_eq!(
469            scorer.recommend_action(score.as_ref(), &default_ctx()),
470            UtilityAction::ToolCall
471        );
472    }
473
474    #[test]
475    fn first_call_passes_default_threshold() {
476        let scorer = UtilityScorer::new(default_config());
477        let call = make_call("bash", json!({"cmd": "ls"}));
478        let score = scorer.score(&call, &default_ctx());
479        assert!(score.is_some());
480        let s = score.unwrap();
481        assert!(
482            s.total >= 0.1,
483            "first call should exceed threshold: {}",
484            s.total
485        );
486        // First call with high uncertainty may trigger Retrieve (gather context) — that is also
487        // a non-blocking outcome. Only Stop/Respond are considered failures here.
488        let action = scorer.recommend_action(Some(&s), &default_ctx());
489        assert!(
490            action == UtilityAction::ToolCall || action == UtilityAction::Retrieve,
491            "first call should not be blocked, got {action:?}",
492        );
493    }
494
495    #[test]
496    fn redundant_call_penalized() {
497        let mut scorer = UtilityScorer::new(default_config());
498        let call = make_call("bash", json!({"cmd": "ls"}));
499        scorer.record_call(&call);
500        let score = scorer.score(&call, &default_ctx()).unwrap();
501        assert!((score.redundancy - 1.0).abs() < f32::EPSILON);
502    }
503
504    #[test]
505    fn clear_resets_redundancy() {
506        let mut scorer = UtilityScorer::new(default_config());
507        let call = make_call("bash", json!({"cmd": "ls"}));
508        scorer.record_call(&call);
509        scorer.clear();
510        let score = scorer.score(&call, &default_ctx()).unwrap();
511        assert!(score.redundancy.abs() < f32::EPSILON);
512    }
513
514    #[test]
515    fn user_requested_always_executes() {
516        let scorer = UtilityScorer::new(default_config());
517        // Simulate a call that would score very low.
518        let score = UtilityScore {
519            gain: 0.0,
520            cost: 1.0,
521            redundancy: 1.0,
522            uncertainty: 0.0,
523            total: -100.0,
524        };
525        let ctx = UtilityContext {
526            user_requested: true,
527            ..default_ctx()
528        };
529        assert_eq!(
530            scorer.recommend_action(Some(&score), &ctx),
531            UtilityAction::ToolCall
532        );
533    }
534
535    #[test]
536    fn none_score_fail_closed_when_enabled() {
537        let scorer = UtilityScorer::new(default_config());
538        // Scoring failure (None with scoring enabled) → Stop (fail-closed).
539        assert_eq!(
540            scorer.recommend_action(None, &default_ctx()),
541            UtilityAction::Stop
542        );
543    }
544
545    #[test]
546    fn none_score_executes_when_disabled() {
547        let scorer = UtilityScorer::new(UtilityScoringConfig::default()); // disabled
548        assert_eq!(
549            scorer.recommend_action(None, &default_ctx()),
550            UtilityAction::ToolCall
551        );
552    }
553
554    #[test]
555    fn cost_increases_with_token_consumption() {
556        let scorer = UtilityScorer::new(default_config());
557        let call = make_call("bash", json!({}));
558        let ctx_low = UtilityContext {
559            tokens_consumed: 100,
560            token_budget: 1000,
561            ..default_ctx()
562        };
563        let ctx_high = UtilityContext {
564            tokens_consumed: 900,
565            token_budget: 1000,
566            ..default_ctx()
567        };
568        let s_low = scorer.score(&call, &ctx_low).unwrap();
569        let s_high = scorer.score(&call, &ctx_high).unwrap();
570        assert!(s_low.cost < s_high.cost);
571        assert!(s_low.total > s_high.total);
572    }
573
574    #[test]
575    fn uncertainty_decreases_with_call_count() {
576        let scorer = UtilityScorer::new(default_config());
577        let call = make_call("bash", json!({}));
578        let ctx_early = UtilityContext {
579            tool_calls_this_turn: 0,
580            ..default_ctx()
581        };
582        let ctx_late = UtilityContext {
583            tool_calls_this_turn: 9,
584            ..default_ctx()
585        };
586        let s_early = scorer.score(&call, &ctx_early).unwrap();
587        let s_late = scorer.score(&call, &ctx_late).unwrap();
588        assert!(s_early.uncertainty > s_late.uncertainty);
589    }
590
591    #[test]
592    fn memory_tool_has_higher_gain_than_scrape() {
593        let scorer = UtilityScorer::new(default_config());
594        let mem_call = make_call("memory_search", json!({}));
595        let web_call = make_call("scrape", json!({}));
596        let s_mem = scorer.score(&mem_call, &default_ctx()).unwrap();
597        let s_web = scorer.score(&web_call, &default_ctx()).unwrap();
598        assert!(s_mem.gain > s_web.gain);
599    }
600
601    #[test]
602    fn zero_token_budget_zeroes_cost() {
603        let scorer = UtilityScorer::new(default_config());
604        let call = make_call("bash", json!({}));
605        let ctx = UtilityContext {
606            tokens_consumed: 500,
607            token_budget: 0,
608            ..default_ctx()
609        };
610        let s = scorer.score(&call, &ctx).unwrap();
611        assert!(s.cost.abs() < f32::EPSILON);
612    }
613
614    #[test]
615    fn validate_rejects_negative_weights() {
616        let cfg = UtilityScoringConfig {
617            enabled: true,
618            gain_weight: -1.0,
619            ..UtilityScoringConfig::default()
620        };
621        assert!(cfg.validate().is_err());
622    }
623
624    #[test]
625    fn validate_rejects_nan_weights() {
626        let cfg = UtilityScoringConfig {
627            enabled: true,
628            threshold: f32::NAN,
629            ..UtilityScoringConfig::default()
630        };
631        assert!(cfg.validate().is_err());
632    }
633
634    #[test]
635    fn validate_accepts_default() {
636        assert!(UtilityScoringConfig::default().validate().is_ok());
637    }
638
639    #[test]
640    fn threshold_zero_all_calls_pass() {
641        // threshold=0.0: every call with a non-negative total should execute.
642        let scorer = UtilityScorer::new(UtilityScoringConfig {
643            enabled: true,
644            threshold: 0.0,
645            ..UtilityScoringConfig::default()
646        });
647        let call = make_call("bash", json!({}));
648        let score = scorer.score(&call, &default_ctx()).unwrap();
649        // total must be >= 0.0 for a fresh call with default weights.
650        assert!(
651            score.total >= 0.0,
652            "total should be non-negative: {}",
653            score.total
654        );
655        // With threshold=0 any non-blocking action (ToolCall or Retrieve) is acceptable.
656        let action = scorer.recommend_action(Some(&score), &default_ctx());
657        assert!(
658            action == UtilityAction::ToolCall || action == UtilityAction::Retrieve,
659            "threshold=0 should not block calls, got {action:?}",
660        );
661    }
662
663    #[test]
664    fn threshold_one_blocks_all_calls() {
665        // threshold=1.0: realistic scores never reach 1.0, so every call is blocked.
666        let scorer = UtilityScorer::new(UtilityScoringConfig {
667            enabled: true,
668            threshold: 1.0,
669            ..UtilityScoringConfig::default()
670        });
671        let call = make_call("bash", json!({}));
672        let score = scorer.score(&call, &default_ctx()).unwrap();
673        assert!(
674            score.total < 1.0,
675            "realistic score should be below 1.0: {}",
676            score.total
677        );
678        // Below threshold, no prior calls → Respond.
679        assert_ne!(
680            scorer.recommend_action(Some(&score), &default_ctx()),
681            UtilityAction::ToolCall
682        );
683    }
684
685    // ── recommend_action tests ────────────────────────────────────────────────
686
687    #[test]
688    fn recommend_action_user_requested_always_tool_call() {
689        let scorer = UtilityScorer::new(default_config());
690        let score = UtilityScore {
691            gain: 0.0,
692            cost: 1.0,
693            redundancy: 1.0,
694            uncertainty: 0.0,
695            total: -100.0,
696        };
697        let ctx = UtilityContext {
698            user_requested: true,
699            ..default_ctx()
700        };
701        assert_eq!(
702            scorer.recommend_action(Some(&score), &ctx),
703            UtilityAction::ToolCall
704        );
705    }
706
707    #[test]
708    fn recommend_action_disabled_scorer_always_tool_call() {
709        let scorer = UtilityScorer::new(UtilityScoringConfig::default()); // disabled
710        let ctx = default_ctx();
711        assert_eq!(scorer.recommend_action(None, &ctx), UtilityAction::ToolCall);
712    }
713
714    #[test]
715    fn recommend_action_none_score_enabled_stops() {
716        let scorer = UtilityScorer::new(default_config());
717        let ctx = default_ctx();
718        assert_eq!(scorer.recommend_action(None, &ctx), UtilityAction::Stop);
719    }
720
721    #[test]
722    fn recommend_action_budget_exhausted_stops() {
723        let scorer = UtilityScorer::new(default_config());
724        let score = UtilityScore {
725            gain: 0.8,
726            cost: 0.95,
727            redundancy: 0.0,
728            uncertainty: 0.5,
729            total: 0.5,
730        };
731        assert_eq!(
732            scorer.recommend_action(Some(&score), &default_ctx()),
733            UtilityAction::Stop
734        );
735    }
736
737    #[test]
738    fn recommend_action_redundant_responds() {
739        let scorer = UtilityScorer::new(default_config());
740        let score = UtilityScore {
741            gain: 0.8,
742            cost: 0.1,
743            redundancy: 1.0,
744            uncertainty: 0.5,
745            total: 0.5,
746        };
747        assert_eq!(
748            scorer.recommend_action(Some(&score), &default_ctx()),
749            UtilityAction::Respond
750        );
751    }
752
753    #[test]
754    fn recommend_action_high_gain_above_threshold_tool_call() {
755        let scorer = UtilityScorer::new(default_config());
756        let score = UtilityScore {
757            gain: 0.8,
758            cost: 0.1,
759            redundancy: 0.0,
760            uncertainty: 0.4,
761            total: 0.6,
762        };
763        assert_eq!(
764            scorer.recommend_action(Some(&score), &default_ctx()),
765            UtilityAction::ToolCall
766        );
767    }
768
769    #[test]
770    fn recommend_action_uncertain_retrieves() {
771        let scorer = UtilityScorer::new(default_config());
772        // gain >= 0.5, uncertainty > 0.5, but gain < 0.7 so rule 3 not triggered
773        let score = UtilityScore {
774            gain: 0.6,
775            cost: 0.1,
776            redundancy: 0.0,
777            uncertainty: 0.8,
778            total: 0.4,
779        };
780        assert_eq!(
781            scorer.recommend_action(Some(&score), &default_ctx()),
782            UtilityAction::Retrieve
783        );
784    }
785
786    #[test]
787    fn recommend_action_below_threshold_with_prior_calls_verifies() {
788        let scorer = UtilityScorer::new(default_config());
789        let score = UtilityScore {
790            gain: 0.3,
791            cost: 0.1,
792            redundancy: 0.0,
793            uncertainty: 0.2,
794            total: 0.05, // below default threshold 0.1
795        };
796        let ctx = UtilityContext {
797            tool_calls_this_turn: 1,
798            ..default_ctx()
799        };
800        assert_eq!(
801            scorer.recommend_action(Some(&score), &ctx),
802            UtilityAction::Verify
803        );
804    }
805
806    #[test]
807    fn recommend_action_default_responds() {
808        let scorer = UtilityScorer::new(default_config());
809        let score = UtilityScore {
810            gain: 0.3,
811            cost: 0.1,
812            redundancy: 0.0,
813            uncertainty: 0.2,
814            total: 0.05, // below threshold, no prior calls
815        };
816        let ctx = UtilityContext {
817            tool_calls_this_turn: 0,
818            ..default_ctx()
819        };
820        assert_eq!(
821            scorer.recommend_action(Some(&score), &ctx),
822            UtilityAction::Respond
823        );
824    }
825
826    // ── #5650 regression: direct-action tools bypass the Retrieve detour ────────
827
828    #[test]
829    fn default_gain_direct_action_tools_reach_tool_call_threshold() {
830        for tool in [
831            "diagnostics",
832            "edit",
833            "format",
834            "create_directory",
835            "delete_path",
836            "move_path",
837            "copy_path",
838        ] {
839            let gain = default_gain(tool);
840            assert!(gain >= 0.7, "{tool} gain should be >= 0.7, got {gain}");
841        }
842    }
843
844    #[test]
845    fn default_gain_find_path_and_list_directory_match_grep_glob_tier() {
846        for tool in ["find_path", "list_directory", "grep", "glob"] {
847            let gain = default_gain(tool);
848            assert!(
849                (gain - 0.65).abs() < f32::EPSILON,
850                "{tool} gain should be 0.65, got {gain}"
851            );
852        }
853    }
854
855    #[test]
856    fn recommend_action_direct_tools_execute_on_first_call() {
857        // Regression test for #5650: these tools previously fell through to the
858        // generic 0.5 gain bucket, which routed a fresh first call (uncertainty ~1.0)
859        // through rule 7 (Retrieve) instead of rule 6 (ToolCall), stalling the tool
860        // behind a doomed Retrieve -> redundant-Respond cycle.
861        let scorer = UtilityScorer::new(default_config());
862        let ctx = default_ctx(); // tool_calls_this_turn: 0 -> uncertainty == 1.0
863        for tool in [
864            "diagnostics",
865            "edit",
866            "format",
867            "create_directory",
868            "delete_path",
869            "move_path",
870            "copy_path",
871        ] {
872            let call = make_call(tool, json!({}));
873            let score = scorer.score(&call, &ctx).unwrap();
874            assert!(
875                score.gain >= 0.7,
876                "{tool} gain should be >= 0.7, got {}",
877                score.gain
878            );
879            assert_eq!(
880                scorer.recommend_action(Some(&score), &ctx),
881                UtilityAction::ToolCall,
882                "{tool} should execute immediately on first call, not stall on Retrieve"
883            );
884        }
885    }
886
887    #[test]
888    fn recommend_action_unclassified_tools_still_retrieve_on_first_call() {
889        // Documents preserved, intentional behavior: tools that genuinely benefit
890        // from a "retrieve context first" detour (or unknown tool ids) remain in the
891        // 0.5 default-gain bucket and may still receive Retrieve on a fresh first
892        // call. This is not the #5650 regression — it only affected tools that have
893        // no exploratory value to gain from the detour.
894        let scorer = UtilityScorer::new(default_config());
895        let ctx = default_ctx();
896        for tool in ["fetch", "totally_unrecognized_tool_xyz"] {
897            let call = make_call(tool, json!({}));
898            let score = scorer.score(&call, &ctx).unwrap();
899            assert!((score.gain - 0.5).abs() < f32::EPSILON);
900            assert_eq!(
901                scorer.recommend_action(Some(&score), &ctx),
902                UtilityAction::Retrieve,
903                "{tool} should still be eligible for Retrieve on first call"
904            );
905        }
906    }
907
908    #[test]
909    fn recommend_action_diagnostics_never_enters_the_retrieve_redundant_respond_stall() {
910        // Contrasts the fixed diagnostics tool with the still-affected fetch tool:
911        // fetch's first call recommends Retrieve, and once the identical retry is
912        // recorded it becomes a redundant duplicate that resolves to Respond — the
913        // exact two-step no-op #5650 reported. diagnostics's gain (0.75) means it
914        // takes the ToolCall branch on the very first call, so it never enters that
915        // stall in the first place.
916        let mut scorer = UtilityScorer::new(default_config());
917        let ctx = default_ctx();
918
919        let fetch_call = make_call("fetch", json!({"url": "https://example.com"}));
920        let fetch_score = scorer.score(&fetch_call, &ctx).unwrap();
921        assert_eq!(
922            scorer.recommend_action(Some(&fetch_score), &ctx),
923            UtilityAction::Retrieve
924        );
925        scorer.record_call(&fetch_call);
926        let fetch_retry_score = scorer.score(&fetch_call, &ctx).unwrap();
927        assert_eq!(
928            scorer.recommend_action(Some(&fetch_retry_score), &ctx),
929            UtilityAction::Respond,
930            "identical retry should be flagged as redundant, reproducing the stall"
931        );
932
933        let diagnostics_call = make_call("diagnostics", json!({}));
934        let diagnostics_score = scorer.score(&diagnostics_call, &ctx).unwrap();
935        assert_eq!(
936            scorer.recommend_action(Some(&diagnostics_score), &ctx),
937            UtilityAction::ToolCall,
938            "diagnostics must execute on the first call, bypassing the stall entirely"
939        );
940    }
941
942    // ── has_explicit_tool_request tests ──────────────────────────────────────
943
944    #[test]
945    fn explicit_request_using_a_tool() {
946        assert!(has_explicit_tool_request(
947            "Please list the files in the current directory using a tool"
948        ));
949    }
950
951    #[test]
952    fn explicit_request_call_the_tool() {
953        assert!(has_explicit_tool_request("call the list_directory tool"));
954    }
955
956    #[test]
957    fn explicit_request_use_the_tool() {
958        assert!(has_explicit_tool_request("use the shell tool to run ls"));
959    }
960
961    #[test]
962    fn explicit_request_run_the_tool() {
963        assert!(has_explicit_tool_request("run the bash tool"));
964    }
965
966    #[test]
967    fn explicit_request_invoke_the_tool() {
968        assert!(has_explicit_tool_request("invoke the search_code tool"));
969    }
970
971    #[test]
972    fn explicit_request_execute_the_tool() {
973        assert!(has_explicit_tool_request("execute the grep tool for me"));
974    }
975
976    #[test]
977    fn explicit_request_case_insensitive() {
978        assert!(has_explicit_tool_request("USING A TOOL to find files"));
979    }
980
981    #[test]
982    fn explicit_request_no_match_plain_message() {
983        assert!(!has_explicit_tool_request("what is the weather today?"));
984    }
985
986    #[test]
987    fn explicit_request_no_match_tool_mentioned_without_invocation() {
988        assert!(!has_explicit_tool_request(
989            "the shell tool is very useful in general"
990        ));
991    }
992
993    #[test]
994    fn explicit_request_show_me_result_of() {
995        assert!(has_explicit_tool_request(
996            "show me the result of: echo hello"
997        ));
998    }
999
1000    #[test]
1001    fn explicit_request_run_colon() {
1002        assert!(has_explicit_tool_request("run: echo hello"));
1003    }
1004
1005    #[test]
1006    fn explicit_request_execute_colon() {
1007        assert!(has_explicit_tool_request("execute: ls -la"));
1008    }
1009
1010    #[test]
1011    fn explicit_request_what_does() {
1012        assert!(has_explicit_tool_request("what does echo hello output?"));
1013    }
1014
1015    #[test]
1016    fn explicit_request_what_would() {
1017        assert!(has_explicit_tool_request("what would cat /etc/hosts show?"));
1018    }
1019
1020    #[test]
1021    fn explicit_request_what_is_the_output_of() {
1022        assert!(has_explicit_tool_request(
1023            "what is the output of ls | grep foo?"
1024        ));
1025    }
1026
1027    #[test]
1028    fn explicit_request_inline_code_pipe() {
1029        assert!(has_explicit_tool_request("try running `ls | grep foo`"));
1030    }
1031
1032    #[test]
1033    fn explicit_request_inline_code_redirect() {
1034        assert!(has_explicit_tool_request("run `echo hello > /tmp/out`"));
1035    }
1036
1037    #[test]
1038    fn explicit_request_inline_code_dollar() {
1039        assert!(has_explicit_tool_request("check `$HOME/bin`"));
1040    }
1041
1042    #[test]
1043    fn explicit_request_inline_code_and() {
1044        assert!(has_explicit_tool_request("try `git fetch && git rebase`"));
1045    }
1046
1047    #[test]
1048    fn no_match_run_the_tests() {
1049        assert!(!has_explicit_tool_request("run the tests please"));
1050    }
1051
1052    #[test]
1053    fn no_match_execute_the_plan() {
1054        assert!(!has_explicit_tool_request("execute the plan we discussed"));
1055    }
1056
1057    #[test]
1058    fn no_match_inline_code_no_shell_syntax() {
1059        assert!(!has_explicit_tool_request(
1060            "the function `process_items` handles it"
1061        ));
1062    }
1063
1064    // "what does this function do?" triggers the wide `what\s+(does|...)` pattern.
1065    // This is an acceptable false positive: users asking "what does X do?" in the
1066    // context of shell commands benefit from the gate bypass, and the cost of an
1067    // occasional extra tool call for a prose question is low.
1068    #[test]
1069    fn known_fp_what_does_function_do() {
1070        // Documents known false-positive: prose "what does X do?" also matches.
1071        assert!(has_explicit_tool_request("what does this function do?"));
1072    }
1073
1074    #[test]
1075    fn no_match_show_me_result_without_colon() {
1076        // Without the trailing colon the phrase is ambiguous prose, should not match.
1077        assert!(!has_explicit_tool_request(
1078            "show me the result of running it"
1079        ));
1080    }
1081
1082    #[test]
1083    fn is_exempt_matches_case_insensitively() {
1084        let scorer = UtilityScorer::new(UtilityScoringConfig {
1085            enabled: true,
1086            exempt_tools: vec!["Read".to_owned(), "file_read".to_owned()],
1087            ..UtilityScoringConfig::default()
1088        });
1089        assert!(scorer.is_exempt("read"));
1090        assert!(scorer.is_exempt("READ"));
1091        assert!(scorer.is_exempt("FILE_READ"));
1092        assert!(!scorer.is_exempt("write"));
1093        assert!(!scorer.is_exempt("bash"));
1094    }
1095
1096    #[test]
1097    fn is_exempt_empty_list_returns_false() {
1098        let scorer = UtilityScorer::new(UtilityScoringConfig::default());
1099        assert!(!scorer.is_exempt("read"));
1100    }
1101
1102    // ── high_gain_tools opt-in tests (#5659) ─────────────────────────────────
1103
1104    #[test]
1105    fn is_high_gain_matches_case_insensitively() {
1106        let scorer = UtilityScorer::new(UtilityScoringConfig {
1107            enabled: true,
1108            high_gain_tools: vec!["Github_create_issue".to_owned()],
1109            ..UtilityScoringConfig::default()
1110        });
1111        assert!(scorer.is_high_gain("github_create_issue"));
1112        assert!(scorer.is_high_gain("GITHUB_CREATE_ISSUE"));
1113        assert!(!scorer.is_high_gain("bash"));
1114    }
1115
1116    #[test]
1117    fn is_high_gain_empty_list_returns_false() {
1118        let scorer = UtilityScorer::new(UtilityScoringConfig::default());
1119        assert!(!scorer.is_high_gain("github_create_issue"));
1120    }
1121
1122    #[test]
1123    fn default_gain_unconfigured_mcp_shaped_tool_id_stays_neutral() {
1124        // Real MCP tool ids are "{server_id}_{name}" (McpTool::sanitized_id), not literally
1125        // prefixed with "mcp_". Without an opt-in high_gain_tools entry, such an id has no
1126        // hardcoded match and stays in the generic 0.5 bucket.
1127        assert!((default_gain("github_create_issue") - 0.5).abs() < f32::EPSILON);
1128    }
1129
1130    #[test]
1131    fn score_high_gain_tools_overrides_default_gain_for_mcp_shaped_tool_id() {
1132        // Reproduces the #5659 gap: an MCP tool id ("github_create_issue", shaped like
1133        // McpTool::sanitized_id's "{server_id}_{name}") has no entry in default_gain's
1134        // hardcoded table and would default to 0.5. Opting it into high_gain_tools must
1135        // raise its gain to 0.75 and let it take the direct ToolCall branch on the first
1136        // call, exactly like the #5650 fix does for built-in direct-action tools.
1137        let scorer = UtilityScorer::new(UtilityScoringConfig {
1138            enabled: true,
1139            high_gain_tools: vec!["github_create_issue".to_owned()],
1140            ..UtilityScoringConfig::default()
1141        });
1142        let ctx = default_ctx(); // tool_calls_this_turn: 0 -> uncertainty == 1.0
1143        let call = make_call("github_create_issue", json!({}));
1144        let score = scorer.score(&call, &ctx).unwrap();
1145        assert!(
1146            (score.gain - 0.75).abs() < f32::EPSILON,
1147            "high_gain_tools entry should raise gain to 0.75, got {}",
1148            score.gain
1149        );
1150        assert_eq!(
1151            scorer.recommend_action(Some(&score), &ctx),
1152            UtilityAction::ToolCall,
1153            "high-gain MCP tool should execute immediately on first call, not stall on Retrieve"
1154        );
1155    }
1156
1157    #[test]
1158    fn score_high_gain_tools_does_not_affect_unlisted_tools() {
1159        let scorer = UtilityScorer::new(UtilityScoringConfig {
1160            enabled: true,
1161            high_gain_tools: vec!["github_create_issue".to_owned()],
1162            ..UtilityScoringConfig::default()
1163        });
1164        let ctx = default_ctx();
1165        let call = make_call("fetch", json!({}));
1166        let score = scorer.score(&call, &ctx).unwrap();
1167        assert!(
1168            (score.gain - 0.5).abs() < f32::EPSILON,
1169            "unlisted tool must keep its default_gain value, got {}",
1170            score.gain
1171        );
1172    }
1173
1174    // ── high_gain_tools colon/underscore dual-form matching (#5713) ─────────
1175
1176    #[test]
1177    fn is_high_gain_matches_qualified_name_config_against_sanitized_id_call() {
1178        // Operator copies the colon-separated `McpTool::qualified_name()` form from `mcp:list`
1179        // into config, but the incoming tool_id is always the underscore-separated
1180        // `McpTool::sanitized_id()` dispatch form.
1181        let scorer = UtilityScorer::new(UtilityScoringConfig {
1182            enabled: true,
1183            high_gain_tools: vec!["myserver:mytool".to_owned()],
1184            ..UtilityScoringConfig::default()
1185        });
1186        assert!(scorer.is_high_gain("myserver_mytool"));
1187    }
1188
1189    #[test]
1190    fn is_high_gain_matches_sanitized_id_config_against_qualified_name_call() {
1191        // Symmetric case: config already uses the underscore form, incoming id uses colons.
1192        let scorer = UtilityScorer::new(UtilityScoringConfig {
1193            enabled: true,
1194            high_gain_tools: vec!["myserver_mytool".to_owned()],
1195            ..UtilityScoringConfig::default()
1196        });
1197        assert!(scorer.is_high_gain("myserver:mytool"));
1198    }
1199
1200    #[test]
1201    fn is_exempt_matches_qualified_name_config_against_sanitized_id_call() {
1202        // is_exempt shares contains_tool_name with is_high_gain and must get the same fix.
1203        let scorer = UtilityScorer::new(UtilityScoringConfig {
1204            enabled: true,
1205            exempt_tools: vec!["myserver:mytool".to_owned()],
1206            ..UtilityScoringConfig::default()
1207        });
1208        assert!(scorer.is_exempt("myserver_mytool"));
1209    }
1210
1211    #[test]
1212    fn is_high_gain_dual_form_still_case_insensitive() {
1213        let scorer = UtilityScorer::new(UtilityScoringConfig {
1214            enabled: true,
1215            high_gain_tools: vec!["MyServer:MyTool".to_owned()],
1216            ..UtilityScoringConfig::default()
1217        });
1218        assert!(scorer.is_high_gain("myserver_mytool"));
1219        assert!(scorer.is_high_gain("MYSERVER_MYTOOL"));
1220    }
1221
1222    #[test]
1223    fn is_high_gain_dual_form_does_not_match_unrelated_tool() {
1224        let scorer = UtilityScorer::new(UtilityScoringConfig {
1225            enabled: true,
1226            high_gain_tools: vec!["myserver:mytool".to_owned()],
1227            ..UtilityScoringConfig::default()
1228        });
1229        assert!(!scorer.is_high_gain("otherserver_othertool"));
1230    }
1231
1232    // ── mandated-retry bypass (#5719) ────────────────────────────────────────
1233    //
1234    // Reproduces the stall reported in #5719: find_path/list_directory (default_gain 0.65)
1235    // trigger rule 8 (Retrieve) on a fresh first call. The injected hint tells the LLM to
1236    // retry with the same arguments, but record_call() already logged the call hash, so the
1237    // identical retry scores redundancy=1.0 and rule 6 (Respond) vetoes it a second time —
1238    // the tool never executes and the turn ends with a fabricated "restriction" apology.
1239
1240    #[test]
1241    fn mark_and_take_mandated_retry_is_consumed_exactly_once() {
1242        let mut scorer = UtilityScorer::new(default_config());
1243        let call = make_call("find_path", json!({"pattern": "*.rs"}));
1244
1245        assert!(
1246            !scorer.take_mandated_retry(&call),
1247            "no marker set yet — must not report a pending retry"
1248        );
1249
1250        scorer.mark_mandated_retry(&call);
1251        assert!(
1252            scorer.take_mandated_retry(&call),
1253            "marker set — first take must report the pending retry"
1254        );
1255        assert!(
1256            !scorer.take_mandated_retry(&call),
1257            "marker consumed — second take must not report a pending retry again"
1258        );
1259    }
1260
1261    #[test]
1262    fn recommend_action_mandated_retry_bypasses_redundancy_veto() {
1263        let scorer = UtilityScorer::new(default_config());
1264        // Simulate the exact retry scenario: identical call already recorded (redundancy=1.0),
1265        // which alone would trigger rule 6 (Respond).
1266        let score = UtilityScore {
1267            gain: 0.65,
1268            cost: 0.1,
1269            redundancy: 1.0,
1270            uncertainty: 0.7,
1271            total: 0.5,
1272        };
1273        let ctx = UtilityContext {
1274            mandated_retry: true,
1275            ..default_ctx()
1276        };
1277        assert_eq!(
1278            scorer.recommend_action(Some(&score), &ctx),
1279            UtilityAction::ToolCall,
1280            "mandated retry must bypass the redundancy veto and execute"
1281        );
1282    }
1283
1284    #[test]
1285    fn find_path_retrieve_then_mandated_retry_executes_not_redundant_respond() {
1286        let mut scorer = UtilityScorer::new(default_config());
1287        let ctx = default_ctx(); // tool_calls_this_turn: 0 -> uncertainty == 1.0
1288        let call = make_call("find_path", json!({"pattern": "*.rs"}));
1289
1290        // First attempt: gain 0.65 (>= 0.5) with high uncertainty -> Retrieve (rule 8).
1291        let first_score = scorer.score(&call, &ctx).unwrap();
1292        assert_eq!(
1293            scorer.recommend_action(Some(&first_score), &ctx),
1294            UtilityAction::Retrieve
1295        );
1296        // record_call() always runs regardless of the recommended action (mirrors
1297        // compute_utility_actions in tier_loop.rs), and the tool loop marks the call as an
1298        // in-flight mandated retry when it injects the "you MUST call it again" hint.
1299        scorer.record_call(&call);
1300        scorer.mark_mandated_retry(&call);
1301
1302        // Without the fix: the retry's redundancy is 1.0 (same hash already recorded), which
1303        // would trigger rule 6 (Respond) — reproducing the fabricated-restriction stall.
1304        let retry_ctx = UtilityContext {
1305            mandated_retry: scorer.take_mandated_retry(&call),
1306            ..default_ctx()
1307        };
1308        assert!(
1309            retry_ctx.mandated_retry,
1310            "retry must be recognized as mandated"
1311        );
1312        let retry_score = scorer.score(&call, &retry_ctx).unwrap();
1313        assert!(
1314            (retry_score.redundancy - 1.0).abs() < f32::EPSILON,
1315            "retry is indeed flagged redundant by the raw score — the bypass must come from \
1316             recommend_action, not from suppressing the redundancy component"
1317        );
1318        assert_eq!(
1319            scorer.recommend_action(Some(&retry_score), &retry_ctx),
1320            UtilityAction::ToolCall,
1321            "mandated retry must execute instead of being re-vetoed as a redundant duplicate"
1322        );
1323
1324        // A genuine third identical call afterward (not requested by any hint) is scored
1325        // normally again — the bypass must not persist beyond the one mandated retry.
1326        scorer.record_call(&call);
1327        let third_ctx = UtilityContext {
1328            mandated_retry: scorer.take_mandated_retry(&call),
1329            ..default_ctx()
1330        };
1331        assert!(
1332            !third_ctx.mandated_retry,
1333            "marker was consumed by the mandated retry — a third call is not exempted"
1334        );
1335        let third_score = scorer.score(&call, &third_ctx).unwrap();
1336        assert_eq!(
1337            scorer.recommend_action(Some(&third_score), &third_ctx),
1338            UtilityAction::Respond,
1339            "a genuine third identical call must be treated as a redundant duplicate"
1340        );
1341    }
1342
1343    #[test]
1344    fn clear_resets_mandated_retries() {
1345        let mut scorer = UtilityScorer::new(default_config());
1346        let call = make_call("find_path", json!({}));
1347        scorer.mark_mandated_retry(&call);
1348        scorer.clear();
1349        assert!(
1350            !scorer.take_mandated_retry(&call),
1351            "clear() must reset mandated-retry state at turn start"
1352        );
1353    }
1354
1355    #[test]
1356    fn note_action_window_zero_never_fires() {
1357        let mut scorer = UtilityScorer::new(UtilityScoringConfig {
1358            enabled: true,
1359            utility_window: 0,
1360            ..UtilityScoringConfig::default()
1361        });
1362        // Any number of non-ToolCall actions must not trigger early-stop when window=0.
1363        for _ in 0..100 {
1364            assert!(!scorer.note_action(&UtilityAction::Stop));
1365        }
1366    }
1367
1368    #[test]
1369    fn note_action_window_three_fires_on_third() {
1370        let mut scorer = UtilityScorer::new(UtilityScoringConfig {
1371            enabled: true,
1372            utility_window: 3,
1373            ..UtilityScoringConfig::default()
1374        });
1375        assert!(!scorer.note_action(&UtilityAction::Stop));
1376        assert!(!scorer.note_action(&UtilityAction::Respond));
1377        assert!(scorer.note_action(&UtilityAction::Stop));
1378    }
1379
1380    #[test]
1381    fn note_action_tool_call_resets_counter() {
1382        let mut scorer = UtilityScorer::new(UtilityScoringConfig {
1383            enabled: true,
1384            utility_window: 2,
1385            ..UtilityScoringConfig::default()
1386        });
1387        assert!(!scorer.note_action(&UtilityAction::Stop));
1388        // ToolCall resets the counter.
1389        assert!(!scorer.note_action(&UtilityAction::ToolCall));
1390        // One more non-ToolCall does not fire — counter was reset.
1391        assert!(!scorer.note_action(&UtilityAction::Stop));
1392    }
1393
1394    #[test]
1395    fn note_action_clear_resets_counter() {
1396        let mut scorer = UtilityScorer::new(UtilityScoringConfig {
1397            enabled: true,
1398            utility_window: 1,
1399            ..UtilityScoringConfig::default()
1400        });
1401        // First Stop would fire (window=1)...
1402        assert!(scorer.note_action(&UtilityAction::Stop));
1403        // ...but after clear() the counter is reset so it fires again from scratch.
1404        scorer.clear();
1405        assert!(scorer.note_action(&UtilityAction::Stop));
1406    }
1407}