Skip to main content

agentmux_srv/ambient/
reply.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! The reply format an ambient call asks for, and the one reader of it.
5//!
6//! The model answers with exactly one of:
7//!
8//! ```text
9//! ANSWER: <the text>
10//! SKIP
11//! ```
12//!
13//! A reply is used only when it has that form. Asked to "output nothing", Haiku
14//! often writes a sentence about doing so instead ("Output nothing at all - the
15//! assistant's message ends by asking for a decision"), and no list of phrases
16//! can name every wording of that. Without the `ANSWER:` prefix, any of them is
17//! simply not an answer. docs/reports/REPORT_AMBIENT_FRAMEWORK_REASSESSMENT_2026_10_08.md
18//! section 6.1.
19
20/// Appended to a prompt that uses this format.
21pub const FORMAT_RULES: &str = "Reply with exactly one line, in one of two forms: ANSWER: followed by the text, \
22or the single word SKIP when the text should not be written. Nothing before or after it.";
23
24const ANSWER_PREFIX: &str = "ANSWER:";
25const SKIP: &str = "SKIP";
26
27/// What a call's judge decided about a reply. Every ambient call ends in one,
28/// and its outcome is recorded from it (`call::Slot::run`).
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub enum Verdict {
31    /// The text to use.
32    Use(String),
33    /// The model decided there is nothing to write (`SKIP`): a healthy answer.
34    Skip,
35    /// Not usable, and why.
36    Reject(super::outcome::RejectReason),
37}
38
39/// Judge a one-line reply in this format: `ANSWER:` text that `check` takes is
40/// used, `SKIP` is a skip, and anything else is refused, by `check` (`Other`) or
41/// for not being in the format (`Format`, or `Empty` for no reply at all).
42pub fn judge_line(raw: &str, check: impl Fn(&str) -> Option<String>) -> Verdict {
43    use super::outcome::RejectReason;
44    match parse(raw) {
45        Parsed::Answer(answer) => match check(&answer) {
46            Some(text) => Verdict::Use(text),
47            None => Verdict::Reject(RejectReason::Other),
48        },
49        Parsed::Skip => Verdict::Skip,
50        Parsed::Malformed if raw.trim().is_empty() => Verdict::Reject(RejectReason::Empty),
51        Parsed::Malformed => Verdict::Reject(RejectReason::Format),
52    }
53}
54
55/// What a reply in this format says.
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub enum Parsed {
58    /// `ANSWER: <text>`, with the text trimmed and non-empty.
59    Answer(String),
60    /// `SKIP`: the model decided there is nothing to write. A healthy result.
61    Skip,
62    /// Anything else, including an empty reply.
63    Malformed,
64}
65
66/// Read a reply. Case is ignored for the prefix and for `SKIP`, as is trailing
67/// punctuation on `SKIP`. The text after `ANSWER:` must be one line, and is
68/// cleaned by `sanitize_ambient_text` (wrapping quotes and fences, a filler
69/// opener): the CLI sanitizes the whole reply, whose start is now the prefix, so
70/// that pass no longer reaches the answer itself.
71pub fn parse(raw: &str) -> Parsed {
72    let text = raw.trim();
73    if text.is_empty() {
74        return Parsed::Malformed;
75    }
76    let bare = text.trim_matches(|c: char| !c.is_alphanumeric());
77    if bare.eq_ignore_ascii_case(SKIP) {
78        return Parsed::Skip;
79    }
80    let Some(head) = text.get(..ANSWER_PREFIX.len()) else {
81        return Parsed::Malformed;
82    };
83    if !head.eq_ignore_ascii_case(ANSWER_PREFIX) {
84        return Parsed::Malformed;
85    }
86    let answer = super::sanitize::sanitize_ambient_text(text[ANSWER_PREFIX.len()..].trim());
87    if answer.is_empty() || answer.contains('\n') {
88        return Parsed::Malformed;
89    }
90    // `ANSWER: SKIP` mixes the two forms; the model meant the skip, and the word
91    // must never be stored as a title, a name or a suggestion.
92    if answer.trim_matches(|c: char| !c.is_alphanumeric()).eq_ignore_ascii_case(SKIP) {
93        return Parsed::Skip;
94    }
95    Parsed::Answer(answer)
96}
97
98#[cfg(test)]
99mod tests {
100    use super::*;
101
102    #[test]
103    fn an_answer_that_is_only_skip_is_a_skip() {
104        for raw in ["ANSWER: SKIP", "answer: skip.", "ANSWER: \"SKIP\""] {
105            assert_eq!(parse(raw), Parsed::Skip, "{raw:?}");
106        }
107        assert_eq!(parse("ANSWER: Skip the flaky test"), Parsed::Answer("Skip the flaky test".into()));
108    }
109
110    #[test]
111    fn an_answer_is_its_text() {
112        assert_eq!(parse("ANSWER: Run the tests"), Parsed::Answer("Run the tests".into()));
113        assert_eq!(parse("  answer:   Add a regression test  "), Parsed::Answer("Add a regression test".into()));
114        assert_eq!(parse("Answer:Fix the login race"), Parsed::Answer("Fix the login race".into()));
115    }
116
117    #[test]
118    fn skip_is_skip_however_it_is_dressed() {
119        for s in ["SKIP", "skip", " Skip. ", "`SKIP`", "\"SKIP\""] {
120            assert_eq!(parse(s), Parsed::Skip, "{s:?}");
121        }
122    }
123
124    /// Every way the model described abstaining in the 2026-10-04..08 logs, and the
125    /// one that reached the composer. None has the prefix, so none is an answer.
126    #[test]
127    fn prose_about_saying_nothing_is_not_an_answer() {
128        for s in [
129            "Output nothing at all - the assistant's message ends by asking for a decision and waiting for user input.",
130            "(No output - the assistant's last message asks the user a question and waits for a decision)",
131            "[Output nothing - the assistant's last message explicitly waits for user decisions on three matters.]",
132            "(no output)",
133            "(nothing)",
134            "I cannot predict a next instruction because the assistant's last message waits for your decision.",
135            "Run the tests",
136            "",
137            "   ",
138            "SKIP this one, the assistant asked a question",
139        ] {
140            assert_eq!(parse(s), Parsed::Malformed, "{s:?}");
141        }
142    }
143
144    /// The answer gets the clean-up the whole reply used to get, which can no
145    /// longer reach it past the prefix.
146    #[test]
147    fn the_answer_text_is_cleaned_of_quotes_fences_and_filler() {
148        assert_eq!(parse("ANSWER: \"Run the tests\""), Parsed::Answer("Run the tests".into()));
149        assert_eq!(parse("ANSWER: `Run the tests`"), Parsed::Answer("Run the tests".into()));
150        assert_eq!(
151            parse("ANSWER: Yeah, let's debug the blank preview bug"),
152            Parsed::Answer("Debug the blank preview bug".into())
153        );
154        assert_eq!(parse("ANSWER: \"\""), Parsed::Malformed);
155    }
156
157    #[test]
158    fn an_answer_must_have_text_on_one_line() {
159        assert_eq!(parse("ANSWER:"), Parsed::Malformed);
160        assert_eq!(parse("ANSWER:   "), Parsed::Malformed);
161        assert_eq!(parse("ANSWER: Run the tests\nthen push"), Parsed::Malformed);
162    }
163
164    #[test]
165    fn one_wrapping_pair_of_quotes_is_dropped_and_inner_quotes_are_kept() {
166        assert_eq!(parse("ANSWER: \"Run the tests\""), Parsed::Answer("Run the tests".into()));
167        assert_eq!(parse("ANSWER: `Fix the build`"), Parsed::Answer("Fix the build".into()));
168        assert_eq!(parse("ANSWER: Rename it to \"foo\""), Parsed::Answer("Rename it to \"foo\"".into()));
169        assert_eq!(
170            parse("ANSWER: \"foo\" and \"bar\" both break"),
171            Parsed::Answer("\"foo\" and \"bar\" both break".into())
172        );
173    }
174
175    #[test]
176    fn a_reply_is_judged_by_its_form_then_by_the_check() {
177        use crate::ambient::outcome::RejectReason;
178        let check = |t: &str| (!t.contains("rm -rf")).then(|| t.to_string());
179        assert_eq!(judge_line("ANSWER: Run the tests", check), Verdict::Use("Run the tests".into()));
180        assert_eq!(judge_line("SKIP", check), Verdict::Skip);
181        assert_eq!(judge_line("ANSWER: rm -rf build", check), Verdict::Reject(RejectReason::Other));
182        assert_eq!(
183            judge_line(
184                "Output nothing at all - the assistant's message ends by asking for a decision and waiting for user input.",
185                check
186            ),
187            Verdict::Reject(RejectReason::Format)
188        );
189        assert_eq!(judge_line("  ", check), Verdict::Reject(RejectReason::Empty));
190    }
191
192    #[test]
193    fn the_format_rules_name_both_forms() {
194        assert!(FORMAT_RULES.contains("ANSWER:"));
195        assert!(FORMAT_RULES.contains("SKIP"));
196    }
197}