Skip to main content

agentmux_srv/ambient/
sanitize.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Strips the formatting a model wraps around its whole reply. Whether what is
5//! left is usable is the reply format's question (`reply`), then the purpose's own
6//! check (`validate`).
7
8/// Removes, repeatedly, then trims: a code fence wrapping the whole text
9/// (optionally with a language tag), one matching pair of quotes wrapping it, and
10/// a conversational opener ("Yeah, let's…"). A reply left with nothing but
11/// backticks (an empty fence) becomes "", which callers already treat as no
12/// reply.
13///
14/// Run on the whole reply by the CLI, and again on the text after `ANSWER:` by
15/// `reply::parse`, which the first pass can't reach past the prefix.
16pub fn sanitize_ambient_text(raw: &str) -> String {
17    let mut s = raw.trim().to_string();
18    for _ in 0..4 {
19        let before = s.clone();
20        if let Some(inner) = strip_wrapping_fence(&s) {
21            s = inner;
22        }
23        if let Some(inner) = strip_wrapping_quotes(&s) {
24            s = inner;
25        }
26        if let Some(rest) = strip_conversational_preamble(&s) {
27            s = rest;
28        }
29        if s == before {
30            break;
31        }
32    }
33    if !s.is_empty() && s.chars().all(|c| c == '`') {
34        s.clear();
35    }
36    s
37}
38
39/// Strip a wrapping code fence (triple-backtick, optionally with a language
40/// tag on the opening line, or a single inline backtick) if the *entire*
41/// string is wrapped — a fence-like substring embedded mid-sentence is left
42/// alone. Returns the un-fenced inner text, trimmed.
43fn strip_wrapping_fence(s: &str) -> Option<String> {
44    let s = s.trim();
45    for fence in ["```", "`"] {
46        if s.len() >= fence.len() * 2 && s.starts_with(fence) && s.ends_with(fence) {
47            let mut inner = &s[fence.len()..s.len() - fence.len()];
48            if fence == "```" {
49                if let Some(nl) = inner.find('\n') {
50                    inner = &inner[nl + 1..];
51                }
52            }
53            return Some(inner.trim().to_string());
54        }
55    }
56    None
57}
58
59/// Strip ONE pair of quotes that wraps the whole string. A quote at only one end,
60/// or a pair that closes before the end (`"a" and "b"`), is part of the text.
61fn strip_wrapping_quotes(s: &str) -> Option<String> {
62    const PAIRS: &[(char, char)] = &[('"', '"'), ('\'', '\''), ('\u{201C}', '\u{201D}'), ('\u{2018}', '\u{2019}')];
63    let s = s.trim();
64    for &(open, close) in PAIRS {
65        if let Some(inner) = s.strip_prefix(open).and_then(|r| r.strip_suffix(close)) {
66            if !inner.contains(open) && !inner.contains(close) {
67                return Some(inner.trim().to_string());
68            }
69        }
70    }
71    None
72}
73
74/// Conversational-filler openers a model reaches for despite being told to
75/// respond with a bare, direct instruction — e.g. "Yeah, let's fix the
76/// bug" instead of "Fix the bug". Matched case-insensitively against the
77/// very start of the string only (a "let's" appearing mid-sentence is left
78/// alone — this strips openers, not arbitrary word choice). Longer/more
79/// specific entries first so e.g. "let's go ahead and " matches whole
80/// rather than the shorter "let's " eating only part of it.
81const CONVERSATIONAL_PREAMBLES: &[&str] = &[
82    "let's go ahead and ",
83    "lets go ahead and ",
84    "yeah, let's ",
85    "yeah let's ",
86    "yeah, lets ",
87    "yeah lets ",
88    "sure, let's ",
89    "sure let's ",
90    "ok, let's ",
91    "okay, let's ",
92    "alright, let's ",
93    "go ahead and ",
94    "let's ",
95    "lets ",
96    "sure, i'll ",
97    "sure, i will ",
98    "sure, ",
99    "yeah, ",
100    "yeah ",
101    "ok, ",
102    "okay, ",
103    "alright, ",
104    "i'll ",
105    "i will ",
106    "i should ",
107    "we should ",
108    "next up, ",
109    "next, ",
110];
111
112/// Strip one leading conversational-filler opener (see
113/// `CONVERSATIONAL_PREAMBLES`) and re-capitalize the new first letter, so
114/// "Yeah, let's debug the bug" becomes "Debug the bug" — matching the
115/// direct-instruction register every ambient-call prompt asks for. Returns
116/// `None` if no known opener matches (left alone rather than guessed at —
117/// this list is deliberately not exhaustive NLP-style filler detection,
118/// just the handful of openers a model actually reaches for here).
119fn strip_conversational_preamble(s: &str) -> Option<String> {
120    for prefix in CONVERSATIONAL_PREAMBLES {
121        if let Some(byte_len) = case_insensitive_prefix_byte_len(s, prefix) {
122            let rest = &s[byte_len..];
123            let mut chars = rest.chars();
124            return Some(match chars.next() {
125                Some(c) => c.to_uppercase().collect::<String>() + chars.as_str(),
126                None => String::new(),
127            });
128        }
129    }
130    None
131}
132
133/// Byte length in `s` of a prefix that case-insensitively matches `prefix`
134/// (always plain ASCII lowercase), or `None` if `s` doesn't start with it.
135/// Walks `s`'s own char boundaries rather than slicing by an offset computed
136/// against a separately-lowercased copy of `s` — a char's lowercase mapping
137/// can change UTF-8 byte length (e.g. the Kelvin sign 'K' -> 'k') or even
138/// character count (e.g. Turkish 'İ' -> "i̇"), which would otherwise misalign
139/// the offset or land it off a char boundary and panic on slice.
140fn case_insensitive_prefix_byte_len(s: &str, prefix: &str) -> Option<usize> {
141    let mut prefix_chars = prefix.chars().peekable();
142    for (byte_idx, c) in s.char_indices() {
143        if prefix_chars.peek().is_none() {
144            return Some(byte_idx);
145        }
146        for lc in c.to_lowercase() {
147            if prefix_chars.next_if_eq(&lc).is_none() {
148                return None;
149            }
150        }
151    }
152    if prefix_chars.peek().is_none() {
153        Some(s.len())
154    } else {
155        None
156    }
157}
158
159#[cfg(test)]
160mod sanitize_ambient_text_tests {
161    use super::*;
162
163    #[test]
164    fn passes_plain_text_through_unchanged() {
165        assert_eq!(sanitize_ambient_text("Run the tests"), "Run the tests");
166        assert_eq!(sanitize_ambient_text("ANSWER: Run the tests"), "ANSWER: Run the tests");
167    }
168
169    #[test]
170    fn strips_a_triple_backtick_fence() {
171        assert_eq!(sanitize_ambient_text("```\nRun the tests\n```"), "Run the tests");
172    }
173
174    #[test]
175    fn strips_a_fence_with_a_language_tag() {
176        assert_eq!(sanitize_ambient_text("```text\nRun the tests\n```"), "Run the tests");
177    }
178
179    #[test]
180    fn empty_fence_collapses_to_empty_string() {
181        assert_eq!(sanitize_ambient_text("```\n```"), "");
182        assert_eq!(sanitize_ambient_text("``````"), "");
183    }
184
185    #[test]
186    fn strips_wrapping_single_backticks() {
187        assert_eq!(sanitize_ambient_text("`Run the tests`"), "Run the tests");
188    }
189
190    #[test]
191    fn strips_one_wrapping_pair_of_quotes() {
192        assert_eq!(sanitize_ambient_text("\"Run the tests\""), "Run the tests");
193        assert_eq!(sanitize_ambient_text("'Run the tests'"), "Run the tests");
194        assert_eq!(sanitize_ambient_text("\u{201C}Run the tests\u{201D}"), "Run the tests");
195    }
196
197    /// Quotes were trimmed from each end independently, so a reply ending in a
198    /// quoted name lost its closing quote.
199    #[test]
200    fn a_quote_at_only_one_end_is_text() {
201        assert_eq!(sanitize_ambient_text("Rename it to \"foo\""), "Rename it to \"foo\"");
202        assert_eq!(sanitize_ambient_text("\"foo\" is the new name"), "\"foo\" is the new name");
203        assert_eq!(sanitize_ambient_text("\"foo\" and \"bar\""), "\"foo\" and \"bar\"");
204    }
205
206    #[test]
207    fn strips_nested_fence_and_quotes() {
208        assert_eq!(sanitize_ambient_text("```\n\"Run the tests\"\n```"), "Run the tests");
209    }
210
211    #[test]
212    fn leaves_an_embedded_fence_like_substring_alone() {
213        let s = "Run `npm test` next";
214        assert_eq!(sanitize_ambient_text(s), s);
215    }
216
217    #[test]
218    fn lone_backticks_with_no_content_collapse_to_empty() {
219        assert_eq!(sanitize_ambient_text("```"), "");
220    }
221
222    #[test]
223    fn strips_yeah_lets_preamble() {
224        assert_eq!(
225            sanitize_ambient_text("Yeah, let's debug the blank preview bug next"),
226            "Debug the blank preview bug next"
227        );
228        assert_eq!(sanitize_ambient_text("Yeah let's fix the login bug"), "Fix the login bug");
229    }
230
231    #[test]
232    fn strips_go_ahead_and_preamble() {
233        assert_eq!(
234            sanitize_ambient_text("Go ahead and add tests for the parser"),
235            "Add tests for the parser"
236        );
237    }
238
239    #[test]
240    fn strips_sure_ok_alright_preamble() {
241        assert_eq!(sanitize_ambient_text("Sure, fix the typo"), "Fix the typo");
242        assert_eq!(sanitize_ambient_text("OK, run the tests"), "Run the tests");
243        assert_eq!(sanitize_ambient_text("Alright, let's ship it"), "Ship it");
244    }
245
246    #[test]
247    fn strips_preamble_from_inside_quotes_and_fences() {
248        assert_eq!(sanitize_ambient_text("\"Yeah, let's fix the bug\""), "Fix the bug");
249        assert_eq!(sanitize_ambient_text("```\nYeah, let's fix the bug\n```"), "Fix the bug");
250    }
251
252    #[test]
253    fn leaves_a_mid_sentence_lets_alone() {
254        let s = "Check whether the retry logic still lets errors through";
255        assert_eq!(sanitize_ambient_text(s), s);
256    }
257
258    #[test]
259    fn a_filler_word_with_no_trailing_content_is_left_alone() {
260        // "Yeah," alone (no instruction after it) doesn't match any
261        // CONVERSATIONAL_PREAMBLES entry — they all require trailing
262        // content after the opener, matching the "strip openers, not
263        // guess at degenerate whole-string filler" scope this function
264        // documents.
265        assert_eq!(sanitize_ambient_text("Yeah,"), "Yeah,");
266    }
267
268    #[test]
269    fn preamble_strip_handles_byte_length_changing_case_folding() {
270        // U+212A KELVIN SIGN lowercases to ASCII 'k' (3 bytes -> 1 byte). If the
271        // preamble strip computed its slice offset from a separately-lowercased
272        // copy of the string instead of walking the original string's own char
273        // boundaries, this would misalign the slice (or panic on a non-char-
274        // boundary index) instead of matching "ok, " normally.
275        let s = "O\u{212A}, run the tests";
276        assert_eq!(sanitize_ambient_text(s), "Run the tests");
277    }
278
279    #[test]
280    fn preamble_strip_handles_char_count_changing_case_folding() {
281        // U+0130 LATIN CAPITAL LETTER I WITH DOT ABOVE lowercases to "i̇" (2
282        // chars) under Rust's default Unicode case folding. This does not
283        // match any configured preamble, but must not panic or corrupt the
284        // string while failing to match.
285        let s = "\u{0130} think we should refactor this";
286        assert_eq!(sanitize_ambient_text(s), s);
287    }
288}