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}