agentmux_srv/ambient/
reply.rs1pub 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#[derive(Debug, Clone, PartialEq, Eq)]
30pub enum Verdict {
31 Use(String),
33 Skip,
35 Reject(super::outcome::RejectReason),
37}
38
39pub 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#[derive(Debug, Clone, PartialEq, Eq)]
57pub enum Parsed {
58 Answer(String),
60 Skip,
62 Malformed,
64}
65
66pub 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 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 #[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 #[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}