Skip to main content

agentmux_srv/ambient/
validate.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! What a well-formed answer must still satisfy. The reply format (`reply`) has
5//! already turned away anything that isn't an `ANSWER:` or `SKIP`; these checks
6//! judge the answer text itself: its size, and whether it is a real title (a
7//! stored one too, from an older build), or a safe suggestion.
8//!
9//! The absence and refusal lists below are frozen. They judge titles that older
10//! builds stored, before the reply format existed, and the frontend's port of
11//! [`is_usable_title`] shares them through `title_corpus.json`. New wordings are
12//! the reply format's job, not a new list entry.
13
14/// Size bounds for a one-line reply.
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub struct Limits {
17    pub max_words: usize,
18    pub max_chars: usize,
19}
20
21/// A pane/session title asked for `word_target` words. The slack covers a model
22/// that runs a little over; a paragraph is still rejected.
23pub fn title_limits(word_target: u32) -> Limits {
24    Limits { max_words: word_target as usize + 8, max_chars: 200 }
25}
26
27/// A subagent or workflow-batch name (asked for ~5 words).
28pub const NAME: Limits = Limits { max_words: 12, max_chars: 100 };
29/// A definition's conversation preview (asked for 12 words or fewer).
30pub const PREVIEW: Limits = Limits { max_words: 24, max_chars: 240 };
31/// A one-sentence narration (asked for under 20 words).
32pub const NARRATION: Limits = Limits { max_words: 40, max_chars: 300 };
33/// A continued session's pick-up note: two or three sentences (asked for under
34/// 60 words), still one line.
35pub const CONTINUED_NOTE: Limits = Limits { max_words: 80, max_chars: 520 };
36/// A next-prompt suggestion is one short imperative; anything longer is an
37/// explanation, not a command.
38pub const NEXT_PROMPT: Limits = Limits { max_words: 28, max_chars: 160 };
39
40/// First-person refusals and offers: a model talking to the reader instead of
41/// producing the text. Specific enough not to fire on an ordinary title.
42const REFUSAL_PHRASES: &[&str] = &[
43    "i don't have",
44    "i do not have",
45    "i don't know",
46    "i cannot",
47    "i can't",
48    "i'm unable",
49    "i am unable",
50    "i'm not able",
51    "i would need",
52    "i'd need",
53    "i need to see",
54    "i need more",
55    "if you'd like",
56    "if you would like",
57    "if you want me",
58    "without knowing",
59    "not enough context",
60    "not enough information",
61    "as an ai",
62];
63
64/// Things a ghost suggestion must not propose on its own initiative: one Tab and
65/// Enter away from the user sending them without having thought of them.
66const RISKY_PHRASES: &[&str] = &[
67    "rm -rf",
68    "--force",
69    "force push",
70    "force-push",
71    "reset --hard",
72    "drop table",
73    "drop database",
74    "delete the database",
75    "delete all",
76    "wipe",
77    "password",
78    "secret",
79    "credential",
80    "api key",
81    "api_key",
82    "token",
83];
84
85/// Lower-cased text with typographic apostrophes straightened, for phrase
86/// matching only (the text handed back is untouched).
87fn normalized(text: &str) -> String {
88    text.replace(['\u{2019}', '\u{2018}'], "'").to_lowercase()
89}
90
91/// Whether `phrase` occurs in `lower` as whole words. A bare substring match
92/// would read "has an AI summary bug" as the refusal "as an ai".
93fn has_phrase(lower: &str, phrase: &str) -> bool {
94    let mut from = 0;
95    while let Some(found) = lower[from..].find(phrase) {
96        let start = from + found;
97        let end = start + phrase.len();
98        let before_ok = lower[..start].chars().next_back().map_or(true, |c| !c.is_alphanumeric());
99        let after_ok = lower[end..].chars().next().map_or(true, |c| !c.is_alphanumeric());
100        if before_ok && after_ok {
101            return true;
102        }
103        // Advance past this match's first character, staying on a char boundary.
104        from = start + lower[start..].chars().next().map_or(1, |c| c.len_utf8());
105    }
106    false
107}
108
109fn has_any(lower: &str, phrases: &[&str]) -> bool {
110    phrases.iter().any(|p| has_phrase(lower, p))
111}
112
113/// Text that is ABOUT the absence of a title rather than a title. Compared after
114/// [`absence_form`], so brackets, quotes and punctuation do not matter.
115///
116/// Exact entries are whole replies only: "none" must not reject "None of the tests
117/// pass on Windows". Prefix entries are specific enough that a real title is very
118/// unlikely to begin with them. The first real examples were `(none yet)`, which is
119/// the title prompt's own placeholder echoed back, and `no goal established yet`;
120/// see SPEC_AMBIENT_SWARM_SUMMARY_HARDENING_2026_10_02.md. The corpus in
121/// `title_corpus.json` is the contract, shared with the frontend.
122const ABSENCE_EXACT: &[&str] = &[
123    "none",
124    "n a",
125    "na",
126    "null",
127    "nothing",
128    "empty",
129    "unknown",
130    "untitled",
131    "untitled session",
132    "untitled conversation",
133    "untitled chat",
134    "tbd",
135    "to be determined",
136    "placeholder",
137    "pending",
138    // The abstain token the title prompts tell the model to use for "no change".
139    // It is not a title, so it must never be stored or shown.
140    "keep",
141    // A model that abstains in its own words instead of the exact token. Exact only:
142    // "Keep alive pings" is a real title.
143    "keep the current title",
144    "keep current title",
145    "keep it",
146    "no change",
147    "no changes",
148    "no update",
149    "unchanged",
150    "not set",
151    "not yet",
152    "no title",
153    "no title yet",
154    "title unavailable",
155    "no summary",
156    "no goal",
157    "no task",
158    "no activity",
159    "no recent activity",
160    "nothing yet",
161];
162
163/// Deliberately few and specific. A prefix rejects every title that begins with it,
164/// so "no title" and "title not" are NOT here: "No title bar on Windows" and "Title
165/// not updating in swarm row" are real titles (ReAgent on #4185). Anything that is
166/// only absence when it is the WHOLE reply belongs in `ABSENCE_EXACT`.
167const ABSENCE_PREFIXES: &[&str] = &[
168    "none yet",
169    "no summary yet",
170    "no goal established",
171    "no goal yet",
172    "no goal set",
173    "no task yet",
174    "no activity yet",
175    "nothing yet",
176    "not yet established",
177];
178
179/// Lower-case, every non-alphanumeric run collapsed to one space, trimmed. So
180/// `(None yet)`, `"none yet."` and `none-yet` all become `none yet`, while
181/// `No-op rename threshold` becomes `no op rename threshold`.
182fn absence_form(text: &str) -> String {
183    let mut out = String::with_capacity(text.len());
184    let mut gap = false;
185    for c in normalized(text).chars() {
186        if c.is_alphanumeric() {
187            if gap && !out.is_empty() {
188                out.push(' ');
189            }
190            gap = false;
191            out.push(c);
192        } else {
193            gap = true;
194        }
195    }
196    out
197}
198
199/// A reply that is entirely a parenthetical or bracketed note. A title is not a
200/// note about the title.
201///
202/// Only ONE balanced pair that wraps the WHOLE text counts. `(WIP) Fix login redirect
203/// (again)` and `[Windows] Fix installer crash [x64]` start and end with brackets but
204/// are real titles: the first pair closes before the end (ReAgent on #4185).
205fn is_wrapped_note(text: &str) -> bool {
206    let t = text.trim();
207    let (open, close) = match (t.chars().next(), t.chars().last()) {
208        (Some('('), Some(')')) => ('(', ')'),
209        (Some('['), Some(']')) => ('[', ']'),
210        _ => return false,
211    };
212    let last = t.chars().count() - 1;
213    let mut depth = 0i32;
214    for (i, c) in t.chars().enumerate() {
215        if c == open {
216            depth += 1;
217        } else if c == close {
218            depth -= 1;
219            // The opening bracket closed before the end: not one wrapping pair.
220            if depth <= 0 && i != last {
221                return false;
222            }
223        }
224    }
225    depth == 0
226}
227
228/// The word the title prompts used for "no change" before the reply format.
229const LEGACY_KEEP_TOKEN: &str = "KEEP";
230
231/// A reply that LEADS with the abstain token in capitals, "KEEP — the title still
232/// fits": the model said "no change" and explained itself. Case-sensitive on purpose:
233/// "Keep alive pings" and "Keep the swarm summary fresh" are real titles, while an
234/// upper-case KEEP is the token the prompt asked for.
235fn leads_with_abstain_token(text: &str) -> bool {
236    let first = text.trim().split_whitespace().next().unwrap_or("");
237    first.trim_matches(|c: char| !c.is_alphanumeric()) == LEGACY_KEEP_TOKEN
238}
239
240fn is_absence(text: &str) -> bool {
241    if is_wrapped_note(text) || leads_with_abstain_token(text) {
242        return true;
243    }
244    let form = absence_form(text);
245    ABSENCE_EXACT.contains(&form.as_str())
246        || ABSENCE_PREFIXES
247            .iter()
248            .any(|p| form == *p || form.strip_prefix(*p).is_some_and(|rest| rest.starts_with(' ')))
249}
250
251/// Returns the line to use, or `None` if the reply is not usable text: empty,
252/// multi-line, over the limits, a placeholder, or a model refusal. This is the
253/// check every ambient reply gets.
254pub fn accept_line(raw: &str, limits: &Limits) -> Option<String> {
255    let text = raw.trim();
256    if text.is_empty() || text.contains('\n') {
257        return None;
258    }
259    if text.chars().count() > limits.max_chars
260        || text.split_whitespace().count() > limits.max_words
261    {
262        return None;
263    }
264    if !text.chars().any(|c| c.is_alphabetic()) {
265        return None;
266    }
267    if is_absence(text) {
268        return None;
269    }
270    let lower = normalized(text);
271    if has_any(&lower, REFUSAL_PHRASES) {
272        return None;
273    }
274    Some(text.to_string())
275}
276
277/// Bounds for judging a title that is ALREADY stored. Generous: a title written
278/// under an older word target or by an older build may be longer than today's.
279pub const STORED_TITLE: Limits = Limits { max_words: 28, max_chars: 200 };
280
281/// Is this stored value a real title? The one predicate behind accepting a reply,
282/// feeding a stored title back into a prompt, and (in the frontend's port of it)
283/// displaying one. A value already in a database from before the check existed,
284/// such as `(none yet)`, is judged here too, so it is never fed back to the model
285/// and never shown.
286pub fn is_usable_title(stored: &str) -> bool {
287    accept_line(stored, &STORED_TITLE).is_some()
288}
289
290/// Returns the suggestion to show, or `None` if the answer is not a usable next
291/// prompt: anything [`accept_line`] rejects, a question, or a risky command.
292pub fn accept_next_prompt(raw: &str) -> Option<String> {
293    let text = accept_line(raw, &NEXT_PROMPT)?;
294    // A question is the model asking, not the user telling.
295    if text.ends_with('?') {
296        return None;
297    }
298    if has_any(&normalized(&text), RISKY_PHRASES) {
299        return None;
300    }
301    Some(text)
302}
303
304#[cfg(test)]
305mod tests {
306    use super::*;
307
308    #[test]
309    fn a_short_imperative_passes_through() {
310        assert_eq!(accept_next_prompt("Run the tests"), Some("Run the tests".into()));
311        assert_eq!(
312            accept_next_prompt("  Debug the blank preview bug next  "),
313            Some("Debug the blank preview bug next".into())
314        );
315    }
316
317    #[test]
318    fn the_reported_refusal_is_rejected() {
319        let reported = "I don't have access to the details of the recent activity beyond the cost summary. \
320            Without knowing what work was done or what the current state of the project is, I cannot \
321            plausibly predict the next instruction. If you'd like me to predict your next step, I'd \
322            need to see the actual recent work or conversation history.";
323        assert_eq!(accept_next_prompt(reported), None);
324        assert_eq!(accept_line(reported, &title_limits(7)), None);
325    }
326
327    #[test]
328    fn short_refusals_and_meta_talk_are_rejected() {
329        for s in [
330            "I cannot predict the next step",
331            "I don't have enough information",
332            "I don\u{2019}t have enough information",
333            "If you'd like me to continue, say so",
334            "Not enough context to decide",
335        ] {
336            assert_eq!(accept_next_prompt(s), None, "{s}");
337        }
338    }
339
340    /// The shared corpus (see `title_corpus.json`): what is about the absence of a
341    /// title is rejected, and a real title that merely contains such a word is not.
342    #[test]
343    fn the_shared_corpus_is_honoured() {
344        #[derive(serde::Deserialize)]
345        struct Corpus {
346            rejected: Vec<String>,
347            accepted: Vec<String>,
348        }
349        let corpus: Corpus = serde_json::from_str(include_str!("title_corpus.json")).expect("corpus parses");
350        assert!(corpus.rejected.len() >= 30 && corpus.accepted.len() >= 10, "the corpus must not be hollowed out");
351        for s in &corpus.rejected {
352            assert_eq!(accept_line(s, &title_limits(7)), None, "must reject {s:?}");
353            assert!(!is_usable_title(s), "a stored {s:?} is not a usable title");
354        }
355        for s in &corpus.accepted {
356            assert!(accept_line(s, &title_limits(7)).is_some(), "must accept {s:?}");
357            assert!(is_usable_title(s), "a stored {s:?} is a usable title");
358        }
359    }
360
361    /// The two values actually found in the owner's database on 2026-10-02.
362    #[test]
363    fn the_values_found_in_the_wild_are_not_titles() {
364        assert!(!is_usable_title("(none yet)"));
365        assert!(!is_usable_title("no goal established yet"));
366        assert!(is_usable_title("Develop hardening spec for swarm ambient summary quality"));
367    }
368
369    // ReAgent on #4185: both of these were false rejects in the first version.
370    #[test]
371    fn titles_that_merely_start_with_a_risky_prefix_or_end_in_brackets_are_accepted() {
372        for s in [
373            "No title bar on Windows",
374            "Title not updating in swarm row",
375            "(WIP) Fix login redirect (again)",
376            "[Windows] Fix installer crash [x64]",
377            "(Draft) Review the plan",
378        ] {
379            assert!(accept_line(s, &title_limits(7)).is_some(), "{s:?}");
380            assert!(is_usable_title(s), "{s:?}");
381        }
382    }
383
384    #[test]
385    fn only_one_balanced_pair_wrapping_the_whole_text_is_a_note() {
386        for note in ["(none yet)", "[no summary]", "(waiting for the user's first message)", "((none yet))", "([x] pending)"] {
387            assert!(is_wrapped_note(note), "{note:?}");
388        }
389        for title in ["(a) b (c)", "[a] b [c]", "(a) (b)", "(a", "a)", "plain"] {
390            assert!(!is_wrapped_note(title), "{title:?}");
391        }
392    }
393
394    // The real model returned the exact token in every abstain case when tried live, but
395    // a reply that abstains in its own words must not overwrite a good title either.
396    #[test]
397    fn an_abstain_in_the_models_own_words_is_not_a_title() {
398        for s in [
399            "KEEP — the title still fits",
400            "KEEP: unchanged",
401            "KEEP the current title",
402            "Keep current title",
403            "keep the current title",
404            "No change",
405            "Unchanged",
406            "No update",
407        ] {
408            assert_eq!(accept_line(s, &title_limits(7)), None, "{s:?}");
409        }
410        for s in ["Keep alive pings", "Keep the swarm summary fresh", "Unchanged files report fix"] {
411            assert!(accept_line(s, &title_limits(7)).is_some(), "{s:?}");
412        }
413    }
414
415    #[test]
416    fn the_abstain_token_is_never_a_title() {
417        for s in ["KEEP", "keep", "Keep.", "(KEEP)", " KEEP "] {
418            assert_eq!(accept_line(s, &title_limits(7)), None, "{s:?}");
419        }
420    }
421
422    #[test]
423    fn whole_word_matching_keeps_real_titles_that_start_with_an_absence_word() {
424        for s in ["None of the tests pass", "Untitled-tab bug", "Nothing else matters here fix", "Keep alive pings"] {
425            assert!(accept_line(s, &title_limits(7)).is_some(), "{s:?}");
426        }
427    }
428
429    #[test]
430    fn empty_and_placeholder_text_is_rejected() {
431        for s in ["", "   ", "...", "-", "None", "N/A", "none.", "(empty)", "null"] {
432            assert_eq!(accept_next_prompt(s), None, "{s:?}");
433            assert_eq!(accept_line(s, &NAME), None, "{s:?}");
434        }
435    }
436
437    #[test]
438    fn questions_are_rejected_as_next_prompts_but_not_as_titles() {
439        assert_eq!(accept_next_prompt("Should I run the tests?"), None);
440        assert_eq!(accept_next_prompt("What should happen next?"), None);
441        assert_eq!(
442            accept_line("Why does login fail?", &title_limits(7)),
443            Some("Why does login fail?".into())
444        );
445    }
446
447    #[test]
448    fn multi_line_and_over_long_text_is_rejected() {
449        assert_eq!(accept_next_prompt("Run the tests\nthen push"), None);
450        let long = "Fix ".to_string() + &"the thing and ".repeat(20);
451        assert_eq!(accept_next_prompt(&long), None);
452        let many_words = vec!["go"; 30].join(" ");
453        assert_eq!(accept_next_prompt(&many_words), None);
454    }
455
456    #[test]
457    fn risky_commands_are_rejected() {
458        for s in [
459            "Run rm -rf on the build folder",
460            "Force push the branch",
461            "Push with --force",
462            "git reset --hard origin/main",
463            "Print the API token",
464            "Drop table users",
465            "Wipe the disk",
466        ] {
467            assert_eq!(accept_next_prompt(s), None, "{s}");
468        }
469    }
470
471    #[test]
472    fn ordinary_commands_pass_and_over_blocking_is_deliberate() {
473        // Plurals ("tokens", "secrets") are separate words and are NOT matched by
474        // "token"/"secret": add them to the list if that ever matters.
475        assert_eq!(
476            accept_next_prompt("Merge the PR once CI is green"),
477            Some("Merge the PR once CI is green".into())
478        );
479    }
480
481    #[test]
482    fn the_text_handed_back_is_not_rewritten() {
483        // Apostrophes are straightened for matching only.
484        assert_eq!(
485            accept_line("Fix the user\u{2019}s login", &NAME),
486            Some("Fix the user\u{2019}s login".into())
487        );
488    }
489
490    #[test]
491    fn titles_and_names_may_name_problems_and_secrets_in_ordinary_words() {
492        assert_eq!(
493            accept_line("Fix insufficient permissions error", &title_limits(7)),
494            Some("Fix insufficient permissions error".into())
495        );
496        assert_eq!(
497            accept_line("Rotate the API token flow", &NAME),
498            Some("Rotate the API token flow".into())
499        );
500    }
501
502    #[test]
503    fn titles_and_names_reject_refusals_paragraphs_and_overruns() {
504        assert_eq!(accept_line("I cannot determine a title without more context", &title_limits(7)), None);
505        assert_eq!(accept_line("Task\nName: login", &NAME), None);
506        let long = vec!["word"; 40].join(" ");
507        assert_eq!(accept_line(&long, &NAME), None);
508        assert_eq!(accept_line(&long, &title_limits(7)), None);
509        assert!(accept_line("Fix login blank screen bug", &NAME).is_some());
510    }
511
512    #[test]
513    fn narration_allows_a_sentence_but_not_a_paragraph() {
514        assert!(accept_line("I started the dev server in the background.", &NARRATION).is_some());
515        let paragraph = vec!["word"; 60].join(" ");
516        assert_eq!(accept_line(&paragraph, &NARRATION), None);
517    }
518
519    #[test]
520    fn phrases_match_whole_words_only() {
521        // "has an AI" contains the substring "as an ai".
522        for t in ["Fix pane has an AI summary bug", "Agent was an AI wrapper", "Rewire the bias in token counts"] {
523            assert_eq!(accept_line(t, &title_limits(7)).as_deref(), Some(t), "{t}");
524        }
525        assert_eq!(accept_line("As an AI, I have no title", &NAME), None);
526        assert_eq!(accept_line("Sorry, as an AI model I can't say", &NAME), None);
527        assert!(has_phrase("sorry. i cannot do that", "i cannot"));
528        assert!(!has_phrase("lexi cannot", "i cannot"));
529    }
530
531    #[test]
532    fn risky_words_inside_other_words_no_longer_block_a_next_prompt() {
533        // "secret" used to block "secretary"; whole-word matching fixes the false
534        // positive while "secrets" and "tokens" stay out of reach by design.
535        assert_eq!(accept_next_prompt("Email the secretary"), Some("Email the secretary".into()));
536        assert_eq!(accept_next_prompt("Print the secret"), None);
537    }
538
539    #[test]
540    fn title_limits_scale_with_the_requested_word_count() {
541        assert_eq!(title_limits(7).max_words, 15);
542        assert_eq!(title_limits(20).max_words, 28);
543    }
544}