Skip to main content

gglib_core/
settings_loop_guard.rs

1//! What the proxy's loop guard does when a replayed history trips it.
2//!
3//! A `#[path]` sibling of `settings.rs` because the type is a settings value
4//! with a wire spelling and a generated TypeScript mirror, and because the
5//! precedence rule that reconciles it with the boolean it replaces is a page
6//! of argument that belongs beside it rather than in the middle of the
7//! `Settings` struct.
8
9use serde::{Deserialize, Serialize};
10
11/// What the loop guard does with a request whose replayed history trips it.
12///
13/// Replaces the boolean [`proxy_loop_detection`](super::Settings::proxy_loop_detection),
14/// which could only say "scan" or "do not scan" and made the scan's only
15/// answer a terminal HTTP 400. ADR 0011 records that 400 ending a Copilot
16/// session on its sixth turn: an external agentic client has no recovery path
17/// from a refusal, and because it replays the whole conversation every turn,
18/// the refusal repeats for the rest of the session.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
20#[serde(rename_all = "lowercase")]
21#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS), ts(export))]
22pub enum LoopGuardMode {
23    /// Do not scan at all.
24    ///
25    /// The escape hatch for a client that legitimately repeats identical
26    /// tool-call batches with nothing in between, or repeats a response. What
27    /// `proxy_loop_detection = Some(false)` meant, and still means.
28    Off,
29    /// Forward the request, with a note appended to the last message saying
30    /// what repeated and how often.
31    ///
32    /// The default. The model is told what gglib can see and is left to act
33    /// on it, which is the one thing a refusal cannot offer a client that has
34    /// no recovery path. The cost is that a genuinely runaway client now
35    /// spends a generation per stuck turn instead of being stopped at
36    /// threshold + 1 — the trade #1052 asks for.
37    #[default]
38    Note,
39    /// Refuse the request with HTTP 400, before any catalog, admission or
40    /// model-swap cost.
41    ///
42    /// What the guard did by default until #1052. Still the right answer for
43    /// an operator who would rather a stuck session fail loudly than burn a
44    /// shared GPU.
45    Refuse,
46}
47
48impl LoopGuardMode {
49    /// Whether the history is scanned at all under this mode.
50    #[must_use]
51    pub const fn scans(self) -> bool {
52        !matches!(self, Self::Off)
53    }
54}