oxicode_agent/advisor/emission_guard.rs
1//! Per-session policy gate for advisor `advise()` calls — ported from omp
2//! `emission-guard.ts`.
3//!
4//! The advisor system prompt tells the watcher model "at most one `advise`
5//! per update" and "NEVER repeat advice you already gave." Real advisor models
6//! violate this. omp issue #3520 captured a session that recorded 309 `advise`
7//! calls covering 92 unique notes — 114× "Stop.", 52× "No issue; continue." —
8//! flooding the primary transcript with `<advisory severity="blocker">Stop.
9//! </advisory>` after the task was already complete.
10//!
11//! The fix is to make the rules load-bearing in code, not prose: silently drop
12//! duplicates, content-free self-talk, and over-budget calls at the `advise`
13//! boundary so the primary stays clean even when the advisor misbehaves. The
14//! gate is invisible to the advisor model — `AdviseTool` still returns
15//! `Recorded.` for a suppressed call.
16//!
17//! # Attribution
18//!
19//! Translated to Rust from omp (oh-my-pi), MIT licensed.
20
21use std::collections::{HashSet, VecDeque};
22
23use parking_lot::Mutex;
24
25/// Case-insensitive, punctuation-folded normalization. Lowercases, applies
26/// NFKC, collapses every run of non-letter / non-digit characters into a single
27/// space, and trims — so `"Stop."`, `"*Stop*"`, and `" stop "` all key to
28/// `stop`, while `"No issue; continue."` keys to `no issue continue`.
29/// omp `normalizeAdvisorNote`.
30#[must_use]
31pub fn normalize_advisor_note(note: &str) -> String {
32 use unicode_normalization::UnicodeNormalization;
33
34 let mut out = String::with_capacity(note.len());
35 let mut prev_space = true; // suppress a leading space
36 for c in note.to_lowercase().nfkc() {
37 if c.is_alphanumeric() {
38 out.push(c);
39 prev_space = false;
40 } else if !prev_space {
41 out.push(' ');
42 prev_space = true;
43 }
44 }
45 out.trim().to_string()
46}
47
48/// Normalized phrases the advisor occasionally emits that carry no concrete
49/// actionable content. Each must be the output of [`normalize_advisor_note`]
50/// so a single membership check covers every punctuation/casing variant
51/// (`"Stop."`, `"stop"`, `"STOP!"`). Ported verbatim from omp
52/// `SUPPRESSED_NORMALIZED_PHRASES` — do not edit casually; it is the curated
53/// set observed driving primary-transcript pollution (#3520). A genuine
54/// `blocker` like `"Stop: 'await' missing on writeStream.end()..."` does not
55/// match.
56const SUPPRESSED_NORMALIZED_PHRASES: &[&str] = &[
57 // Self-stop noise — telling the agent to "stop" without a reason is useless.
58 "stop",
59 "stop here",
60 "stop now",
61 "halt",
62 "abort",
63 // Completion self-talk — the agent already finished the task.
64 "done",
65 "task done",
66 "task complete",
67 "complete",
68 "finished",
69 "ok",
70 "okay",
71 "ok done",
72 // "Nothing to flag" — silence is the correct expression of "no concerns".
73 "no issue",
74 "no issues",
75 "no issue continue",
76 "no concerns",
77 "no concern",
78 "nothing to add",
79 "nothing to flag",
80 "nothing to report",
81 "no notes",
82 "no further input",
83 "no further input needed",
84 "no further input required",
85 "no further watcher input",
86 "no further watcher input needed",
87 "no further advice",
88 "no further advice needed",
89 // Endorsements — equivalent to silence.
90 "lgtm",
91 "looks good",
92 "all good",
93 "agent is on track",
94 "agent on track",
95 "on track",
96 "continue",
97 "carry on",
98];
99
100/// Bounds the dedupe history. omp `DEFAULT_HISTORY_CAPACITY`.
101const DEFAULT_HISTORY_CAPACITY: usize = 4096;
102
103#[derive(Default)]
104struct State {
105 /// Normalized notes already delivered, for dedupe.
106 seen: HashSet<String>,
107 /// Insertion-order log to drive FIFO eviction without an extra Map.
108 seen_order: VecDeque<String>,
109 /// Per-update budget: at most one accepted `advise` per update.
110 consumed_this_update: bool,
111}
112
113/// Decides whether an advisor `advise()` call should reach the primary agent.
114/// omp `AdvisorEmissionGuard`.
115///
116/// Thread-safe (`&self` + internal lock) because `accept` is driven from the
117/// advisor agent's tool execution while `begin_update`/`reset` are driven from
118/// the host's turn boundaries — they can overlap.
119pub struct AdvisorEmissionGuard {
120 state: Mutex<State>,
121 capacity: usize,
122}
123
124impl AdvisorEmissionGuard {
125 /// Construct with the default dedupe history capacity (4096).
126 #[must_use]
127 pub fn new() -> Self {
128 Self::with_capacity(DEFAULT_HISTORY_CAPACITY)
129 }
130
131 /// Construct with an explicit dedupe history capacity.
132 #[must_use]
133 pub fn with_capacity(capacity: usize) -> Self {
134 Self {
135 state: Mutex::new(State::default()),
136 capacity,
137 }
138 }
139
140 /// Drop all dedupe and per-update state. Called whenever the advisor
141 /// runtime is reset (compaction, session switch, `/new`) so a re-primed
142 /// advisor can re-raise old issues — the primary transcript was rewritten.
143 /// omp `reset()`.
144 pub fn reset(&self) {
145 let mut s = self.state.lock();
146 s.seen.clear();
147 s.seen_order.clear();
148 s.consumed_this_update = false;
149 }
150
151 /// Clear the per-update rate-limit gate. Called right before each advisor
152 /// `prompt(batch)` cycle so the next advisor model cycle starts with a
153 /// fresh budget of one advise. omp `beginUpdate()`.
154 pub fn begin_update(&self) {
155 self.state.lock().consumed_this_update = false;
156 }
157
158 /// Whether the proposed note should reach the primary. On `true` the gate
159 /// has already recorded the note (consumed the per-update budget and added
160 /// it to the dedupe history) — the caller delivers the note. On `false` the
161 /// caller drops it. omp `accept()`.
162 ///
163 /// Empty/whitespace-only notes are suppressed (defense-in-depth; the
164 /// tool-args contract requires a non-empty string). Content-free filler
165 /// (per `SUPPRESSED_NORMALIZED_PHRASES`) and exact normalized duplicates
166 /// are suppressed. Over-budget calls (a second accept in the same update)
167 /// are suppressed.
168 pub fn accept(&self, note: &str) -> bool {
169 let key = normalize_advisor_note(note);
170 if key.is_empty() {
171 return false;
172 }
173 if SUPPRESSED_NORMALIZED_PHRASES.contains(&key.as_str()) {
174 return false;
175 }
176 let mut s = self.state.lock();
177 if s.seen.contains(&key) {
178 return false;
179 }
180 if s.consumed_this_update {
181 return false;
182 }
183 s.consumed_this_update = true;
184 s.seen.insert(key.clone());
185 s.seen_order.push_back(key);
186 while s.seen_order.len() > self.capacity {
187 if let Some(stale) = s.seen_order.pop_front() {
188 s.seen.remove(&stale);
189 }
190 }
191 true
192 }
193}
194
195impl Default for AdvisorEmissionGuard {
196 fn default() -> Self {
197 Self::new()
198 }
199}
200
201#[cfg(test)]
202mod tests {
203 #![allow(clippy::unwrap_used)]
204 use super::*;
205
206 fn guard() -> AdvisorEmissionGuard {
207 AdvisorEmissionGuard::with_capacity(4)
208 }
209
210 #[test]
211 fn normalize_folds_punctuation_and_case() {
212 assert_eq!(normalize_advisor_note("Stop."), "stop");
213 assert_eq!(normalize_advisor_note("*Stop*"), "stop");
214 assert_eq!(normalize_advisor_note(" stop "), "stop");
215 assert_eq!(
216 normalize_advisor_note("No issue; continue."),
217 "no issue continue"
218 );
219 assert_eq!(normalize_advisor_note(""), "");
220 assert_eq!(normalize_advisor_note(" "), "");
221 }
222
223 #[test]
224 fn accept_suppresses_content_free_phrases() {
225 let g = guard();
226 for phrase in [
227 "Stop.",
228 "STOP!",
229 "done",
230 "No issues.",
231 "looks good",
232 "continue",
233 ] {
234 assert!(!g.accept(phrase), "should suppress {phrase:?}");
235 }
236 }
237
238 #[test]
239 fn accept_suppresses_empty() {
240 let g = guard();
241 assert!(!g.accept(""));
242 assert!(!g.accept(" "));
243 }
244
245 #[test]
246 fn accept_one_per_update_then_blocks_second() {
247 let g = guard();
248 assert!(g.accept("Use saturating_add to avoid overflow on the counter"));
249 // same update -> blocked even if novel
250 assert!(!g.accept("Different, valid note about naming"));
251 // new update -> fresh budget, but the first note is now deduped
252 g.begin_update();
253 assert!(!g.accept("Use saturating_add to avoid overflow on the counter"));
254 assert!(g.accept("Different, valid note about naming"));
255 }
256
257 #[test]
258 fn accept_dedupes_normalized_variants_across_updates() {
259 let g = guard();
260 assert!(g.accept("Use saturating_add."));
261 g.begin_update();
262 // punctuation/case variant normalizes to the same key -> deduped
263 assert!(!g.accept("use saturating add!"));
264 }
265
266 #[test]
267 fn fifo_evicts_at_capacity_then_readmits() {
268 let g = guard();
269 // capacity 4
270 for i in 0..4 {
271 assert!(g.accept(&format!("note number {i}")));
272 g.begin_update();
273 }
274 // 5th evicts the oldest ("note number 0")
275 assert!(g.accept("note number 4"));
276 g.begin_update();
277 // evicted note is readmitted
278 assert!(g.accept("note number 0"));
279 }
280
281 #[test]
282 fn reset_clears_everything() {
283 let g = guard();
284 g.accept("some real advice");
285 g.reset();
286 // same note readmitted after reset
287 assert!(g.accept("some real advice"));
288 }
289
290 #[test]
291 fn genuine_blocker_is_not_suppressed() {
292 let g = guard();
293 assert!(g.accept("Stop: 'await' missing on writeStream.end() will lose buffered writes."));
294 }
295}